gioser

tawasuyu/brahman

Rust 478TOML 190Markdown 50JSON 176650 Definiciones

⎇ 2540e74046fb65bb00b7f7a60ec1e39171ababee crates/apps/shuma-shell/src/main.rs Rust · 1055 líneas · 36.5 KB Historia Quién tocó qué crudo

feat(shuma-shell): divisores arrastrables entre los paneles 90607bd7c · sergio ·

1//! `shuma-shell` — el shell de brahman, ejecutando de verdad.
2//!
3//! El shell trabaja *dentro de una sesión* ([`shuma_session::WorkSession`]):
4//! un directorio actual —que es además el identificador de aislamiento—,
5//! el historial de comandos ejecutados y los grupos reutilizables.
6//!
7//! ```text
8//!   ┌─ estado · cwd · aislamiento ──────────────────────┐
9//!   │ [RUN]   │   comandos ejecutados + su salida   │ [SENS] │
10//!   │ grupos  │   (streaming en vivo)               │ monit. │
11//!   └─ prompt inteligente ─────────────────────────────┘
12//! ```
13//!
14//! El input se analiza con `shuma-line` (resaltado + autocompletado);
15//! al ejecutar, `shuma-exec` lanza el comando y transmite su salida
16//! línea a línea, que se vuelca en el panel central. El cerebro vive en
17//! crates agnósticos — este binario sólo es el frontend GPUI.
18
19use std::panic;
20use std::time::{Duration, SystemTime, UNIX_EPOCH};
21
22use gpui::{
23    div, point, prelude::*, px, App, Bounds, Context, CursorStyle, Element, ElementId, FocusHandle,
24    GlobalElementId, Hsla, InspectorElementId, IntoElement, KeyDownEvent, LayoutId, MouseButton,
25    MouseDownEvent, MouseMoveEvent, MouseUpEvent, PathBuilder, Pixels, Render, SharedString, Style,
26    Window,
27};
28use nahual_launcher::launch_app;
29use nahual_theme::Theme;
30use shuma_exec::{run as exec_run, CommandSpec, RunEvent, RunHandle};
31use shuma_line::{CompletionKind, CompletionSource, LineState, TokenKind};
32use shuma_session::{CommandRun, RunId, RunStatus, WorkSession};
33use shuma_sysmon::{Snapshot, SystemSampler};
34
35/// Cuántas muestras guarda la curva de cada monitor.
36const HISTORY: usize = 80;
37/// Líneas de salida visibles por comando (modo launcher liviano).
38const OUTPUT_LINES: usize = 16;
39
40/// Segundo Unix actual.
41fn unix_now() -> u64 {
42    SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
43}
44
45// =====================================================================
46// Fuente de autocompletado.
47// =====================================================================
48
49struct ShellCompletionSource {
50    commands: Vec<String>,
51    cwd: String,
52}
53
54impl ShellCompletionSource {
55    fn scan(cwd: String) -> Self {
56        let mut commands = Vec::new();
57        if let Ok(path) = std::env::var("PATH") {
58            for dir in path.split(':') {
59                if let Ok(entries) = std::fs::read_dir(dir) {
60                    for e in entries.flatten() {
61                        if let Some(name) = e.file_name().to_str() {
62                            commands.push(name.to_string());
63                        }
64                    }
65                }
66            }
67        }
68        commands.sort();
69        commands.dedup();
70        Self { commands, cwd }
71    }
72}
73
74impl CompletionSource for ShellCompletionSource {
75    fn commands(&self) -> Vec<String> {
76        self.commands.clone()
77    }
78
79    fn paths(&self, prefix: &str) -> Vec<String> {
80        let (dir, partial) = match prefix.rfind('/') {
81            Some(i) => (&prefix[..=i], &prefix[i + 1..]),
82            None => ("", prefix),
83        };
84        // Una ruta relativa se resuelve contra el cwd de la sesión.
85        let base = if dir.starts_with('/') {
86            dir.to_string()
87        } else if dir.is_empty() {
88            self.cwd.clone()
89        } else {
90            format!("{}/{}", self.cwd, dir)
91        };
92        let mut out = Vec::new();
93        if let Ok(entries) = std::fs::read_dir(&base) {
94            for e in entries.flatten() {
95                if let Some(name) = e.file_name().to_str() {
96                    if name.starts_with(partial) {
97                        let is_dir = e.file_type().map(|t| t.is_dir()).unwrap_or(false);
98                        let slash = if is_dir { "/" } else { "" };
99                        out.push(format!("{dir}{name}{slash}"));
100                    }
101                }
102            }
103        }
104        out.sort();
105        out
106    }
107}
108
109// =====================================================================
110// CurveElement — la curva de un monitor.
111// =====================================================================
112
113struct CurveElement {
114    values: Vec<f32>,
115    color: Hsla,
116}
117
118impl CurveElement {
119    fn new(values: Vec<f32>, color: Hsla) -> Self {
120        Self { values, color }
121    }
122}
123
124impl IntoElement for CurveElement {
125    type Element = Self;
126    fn into_element(self) -> Self::Element {
127        self
128    }
129}
130
131impl Element for CurveElement {
132    type RequestLayoutState = ();
133    type PrepaintState = ();
134
135    fn id(&self) -> Option<ElementId> {
136        None
137    }
138    fn source_location(&self) -> Option<&'static panic::Location<'static>> {
139        None
140    }
141
142    fn request_layout(
143        &mut self,
144        _id: Option<&GlobalElementId>,
145        _inspector: Option<&InspectorElementId>,
146        window: &mut Window,
147        cx: &mut App,
148    ) -> (LayoutId, ()) {
149        let mut style = Style::default();
150        style.size.width = gpui::Length::Definite(gpui::DefiniteLength::Fraction(1.0));
151        style.size.height = gpui::Length::Definite(gpui::DefiniteLength::Fraction(1.0));
152        (window.request_layout(style, [], cx), ())
153    }
154
155    fn prepaint(
156        &mut self,
157        _id: Option<&GlobalElementId>,
158        _inspector: Option<&InspectorElementId>,
159        _bounds: Bounds<Pixels>,
160        _layout: &mut (),
161        _window: &mut Window,
162        _cx: &mut App,
163    ) {
164    }
165
166    fn paint(
167        &mut self,
168        _id: Option<&GlobalElementId>,
169        _inspector: Option<&InspectorElementId>,
170        bounds: Bounds<Pixels>,
171        _layout: &mut (),
172        _prepaint: &mut (),
173        window: &mut Window,
174        _cx: &mut App,
175    ) {
176        let n = self.values.len();
177        if n < 2 {
178            return;
179        }
180        let ox: f32 = bounds.origin.x.into();
181        let oy: f32 = bounds.origin.y.into();
182        let bw: f32 = bounds.size.width.into();
183        let bh: f32 = bounds.size.height.into();
184        let mut pb = PathBuilder::stroke(px(1.6));
185        for (i, v) in self.values.iter().enumerate() {
186            let x = ox + bw * (i as f32 / (n - 1) as f32);
187            let y = oy + bh - (v.clamp(0.0, 100.0) / 100.0) * bh;
188            let p = point(px(x), px(y));
189            if i == 0 {
190                pb.move_to(p);
191            } else {
192                pb.line_to(p);
193            }
194        }
195        if let Ok(path) = pb.build() {
196            window.paint_path(path, self.color);
197        }
198    }
199}
200
201// =====================================================================
202// El shell.
203// =====================================================================
204
205/// Qué panel lateral está redimensionando un drag activo.
206#[derive(Clone, Copy)]
207enum Side {
208    Left,
209    Right,
210}
211
212/// Estado de un arrastre de divisor en curso.
213struct Drag {
214    side: Side,
215    /// Posición X del cursor al iniciar el arrastre.
216    start_x: f32,
217    /// Ancho del panel al iniciar el arrastre.
218    start_w: f32,
219}
220
221struct Shell {
222    line: LineState,
223    /// La sesión de trabajo: cwd, historial y grupos.
224    session: WorkSession,
225    /// Comandos en curso, con su canal de salida.
226    active: Vec<(RunId, RunHandle)>,
227    completion: Option<shuma_line::Completion>,
228    completion_index: usize,
229    show_completion: bool,
230    source: ShellCompletionSource,
231    sampler: SystemSampler,
232    snapshot: Snapshot,
233    left_collapsed: bool,
234    right_collapsed: bool,
235    /// Anchos de los paneles laterales (los divisores los ajustan).
236    left_width: f32,
237    right_width: f32,
238    /// Arrastre de divisor en curso, si lo hay.
239    drag: Option<Drag>,
240    focus: FocusHandle,
241    focused_once: bool,
242}
243
244impl Shell {
245    fn new(cx: &mut Context<Self>) -> Self {
246        let cwd = std::env::current_dir()
247            .map(|p| p.to_string_lossy().into_owned())
248            .unwrap_or_else(|_| "/".to_string());
249
250        let mut session = WorkSession::new("sesión", &cwd);
251        // Grupos de ejemplo — recetas reutilizables.
252        session.save_group("estado git", vec!["git status --short".into()]);
253        session.save_group("build", vec!["cargo build --release".into()]);
254
255        let shell = Self {
256            line: LineState::new(),
257            session,
258            active: Vec::new(),
259            completion: None,
260            completion_index: 0,
261            show_completion: false,
262            source: ShellCompletionSource::scan(cwd),
263            sampler: SystemSampler::new(HISTORY),
264            snapshot: Snapshot {
265                cpu_percent: 0.0,
266                mem_percent: 0.0,
267                mem_used_mb: 0,
268                mem_total_mb: 0,
269                valid: false,
270            },
271            left_collapsed: false,
272            right_collapsed: false,
273            left_width: 176.0,
274            right_width: 188.0,
275            drag: None,
276            focus: cx.focus_handle(),
277            focused_once: false,
278        };
279        shell.start_loop(cx);
280        shell
281    }
282
283    /// Bucle de fondo: drena la salida de los comandos (~9/s) y refresca
284    /// los monitores (~1/s).
285    fn start_loop(&self, cx: &mut Context<Self>) {
286        cx.spawn(async move |this, cx| {
287            let mut tick: u32 = 0;
288            loop {
289                cx.background_executor().timer(Duration::from_millis(110)).await;
290                tick += 1;
291                let sysmon = tick % 10 == 0;
292                let alive = this.update(cx, |shell, cx| {
293                    let mut changed = shell.drain_exec();
294                    if sysmon {
295                        shell.snapshot = shell.sampler.sample();
296                        changed = true;
297                    }
298                    if changed {
299                        cx.notify();
300                    }
301                });
302                if alive.is_err() {
303                    break;
304                }
305            }
306        })
307        .detach();
308    }
309
310    /// Vuelca la salida disponible de los comandos en curso al historial.
311    fn drain_exec(&mut self) -> bool {
312        let now = unix_now();
313        let mut changed = false;
314        for (id, handle) in &mut self.active {
315            for ev in handle.try_events() {
316                changed = true;
317                match ev {
318                    RunEvent::Stdout(l) | RunEvent::Stderr(l) => {
319                        self.session.append_output(*id, l)
320                    }
321                    RunEvent::Exited(code) => self.session.finish_run(*id, code, now),
322                    RunEvent::Failed(msg) => {
323                        self.session
324                            .append_output(*id, format!("✗ no se pudo lanzar: {msg}"));
325                        self.session.finish_run(*id, -1, now);
326                    }
327                }
328            }
329        }
330        self.active.retain(|(_, h)| !h.is_finished());
331        changed
332    }
333
334    /// Resuelve el destino de un `cd` contra el cwd de la sesión.
335    fn resolve_cd(&self, arg: &str) -> Result<String, String> {
336        let home = std::env::var("HOME").unwrap_or_else(|_| "/".into());
337        let target = if arg.is_empty() || arg == "~" {
338            home
339        } else if let Some(rest) = arg.strip_prefix("~/") {
340            format!("{home}/{rest}")
341        } else if arg.starts_with('/') {
342            arg.to_string()
343        } else {
344            format!("{}/{}", self.session.cwd(), arg)
345        };
346        match std::fs::canonicalize(&target) {
347            Ok(p) if p.is_dir() => Ok(p.to_string_lossy().into_owned()),
348            Ok(_) => Err(format!("cd: no es un directorio: {target}")),
349            Err(e) => Err(format!("cd: {target}: {e}")),
350        }
351    }
352
353    /// Ejecuta una línea: `cd` se maneja internamente (cambia el cwd y,
354    /// con él, el aislamiento); el resto se lanza con `shuma-exec` y su
355    /// salida se transmite al panel central.
356    fn run_command(&mut self, line: String) {
357        let line = line.trim().to_string();
358        if line.is_empty() {
359            return;
360        }
361        let now = unix_now();
362
363        // `cd` interno — un subproceso no podría cambiar nuestro cwd.
364        if line == "cd" || line.starts_with("cd ") {
365            let arg = line.strip_prefix("cd").unwrap_or("").trim();
366            let id = self.session.begin_run(&line, now);
367            match self.resolve_cd(arg) {
368                Ok(new_cwd) => {
369                    self.session.set_cwd(new_cwd.clone());
370                    self.source.cwd = new_cwd.clone();
371                    self.session.append_output(id, format!("→ {new_cwd}"));
372                    self.session.finish_run(id, 0, now);
373                }
374                Err(e) => {
375                    self.session.append_output(id, e);
376                    self.session.finish_run(id, 1, now);
377                }
378            }
379            return;
380        }
381
382        let id = self.session.begin_run(&line, now);
383        let spec = CommandSpec::bash(&line, self.session.cwd());
384        self.active.push((id, exec_run(&spec)));
385    }
386
387    fn refresh_completion(&mut self) {
388        let comp = self.line.complete(&self.source);
389        self.show_completion =
390            !comp.candidates.is_empty() && comp.replace_end > comp.replace_start;
391        self.completion_index = 0;
392        self.completion = Some(comp);
393    }
394
395    fn on_tab(&mut self) {
396        let comp = self.line.complete(&self.source);
397        if comp.candidates.is_empty() {
398            return;
399        }
400        if self.show_completion {
401            let idx = self.completion_index.min(comp.candidates.len() - 1);
402            let candidate = comp.candidates[idx].clone();
403            self.line.apply_completion(&comp, &candidate);
404            self.show_completion = false;
405            self.completion = None;
406        } else {
407            self.completion_index = 0;
408            self.completion = Some(comp);
409            self.show_completion = true;
410        }
411    }
412
413    fn cycle_completion(&mut self, delta: i32) {
414        if !self.show_completion {
415            return;
416        }
417        if let Some(comp) = &self.completion {
418            let n = comp.candidates.len();
419            if n > 0 {
420                let i = self.completion_index as i32 + delta;
421                self.completion_index = i.rem_euclid(n as i32) as usize;
422            }
423        }
424    }
425
426    /// Enter — ejecuta el contenido del input.
427    fn submit(&mut self) {
428        let line = self.line.text().to_string();
429        self.line.clear();
430        self.completion = None;
431        self.show_completion = false;
432        self.run_command(line);
433    }
434
435    fn handle_key(&mut self, event: &KeyDownEvent, _w: &mut Window, cx: &mut Context<Self>) {
436        let ks = &event.keystroke;
437        let key = ks.key.as_str();
438        let ctrl = ks.modifiers.control;
439        let mut changed = false;
440
441        match key {
442            "enter" => {
443                self.submit();
444                cx.notify();
445                return;
446            }
447            "escape" => {
448                self.show_completion = false;
449                cx.notify();
450                return;
451            }
452            "tab" => {
453                self.on_tab();
454                cx.notify();
455                return;
456            }
457            "up" => {
458                self.cycle_completion(-1);
459                cx.notify();
460                return;
461            }
462            "down" => {
463                self.cycle_completion(1);
464                cx.notify();
465                return;
466            }
467            "backspace" => {
468                self.line.backspace();
469                changed = true;
470            }
471            "delete" => {
472                self.line.delete();
473                changed = true;
474            }
475            "left" => {
476                if ctrl {
477                    self.line.move_word_left();
478                } else {
479                    self.line.move_left();
480                }
481            }
482            "right" => {
483                if ctrl {
484                    self.line.move_word_right();
485                } else {
486                    self.line.move_right();
487                }
488            }
489            "home" => self.line.move_home(),
490            "end" => self.line.move_end(),
491            "a" if ctrl => self.line.move_home(),
492            "e" if ctrl => self.line.move_end(),
493            "u" if ctrl => {
494                self.line.clear();
495                changed = true;
496            }
497            _ => {
498                if !ctrl {
499                    if let Some(ch) = ks.key_char.as_deref() {
500                        if !ch.chars().any(|c| c.is_control()) {
501                            self.line.insert(ch);
502                            changed = true;
503                        }
504                    }
505                }
506            }
507        }
508
509        if changed {
510            self.refresh_completion();
511        }
512        cx.notify();
513    }
514}
515
516/// Color de resaltado de cada clase de token.
517fn token_color(kind: TokenKind, theme: &Theme) -> Hsla {
518    match kind {
519        TokenKind::Command => gpui::hsla(190.0 / 360.0, 0.65, 0.62, 1.0),
520        TokenKind::Argument => theme.fg_text,
521        TokenKind::Flag => gpui::hsla(38.0 / 360.0, 0.80, 0.62, 1.0),
522        TokenKind::StringLit => gpui::hsla(95.0 / 360.0, 0.42, 0.60, 1.0),
523        TokenKind::Variable => gpui::hsla(280.0 / 360.0, 0.55, 0.72, 1.0),
524        TokenKind::Pipe => gpui::hsla(190.0 / 360.0, 0.90, 0.72, 1.0),
525        TokenKind::Redirect => gpui::hsla(20.0 / 360.0, 0.78, 0.62, 1.0),
526        TokenKind::Operator => gpui::hsla(0.0, 0.66, 0.66, 1.0),
527        TokenKind::Comment => theme.fg_muted,
528        TokenKind::Whitespace => theme.fg_text,
529        TokenKind::Unknown => gpui::hsla(0.0, 0.70, 0.60, 1.0),
530    }
531}
532
533/// Resalta un texto estático (la línea de un comando del historial).
534fn highlight(text: &str, theme: &Theme) -> Vec<gpui::Div> {
535    shuma_line::tokenize(text, shuma_line::Dialect::Bash)
536        .into_iter()
537        .map(|t| {
538            div()
539                .flex_none()
540                .text_color(token_color(t.kind, theme))
541                .child(t.text)
542        })
543        .collect()
544}
545
546/// Acorta el cwd: el `$HOME` se muestra como `~`.
547fn pretty_cwd(cwd: &str) -> String {
548    match std::env::var("HOME") {
549        Ok(home) if cwd == home => "~".to_string(),
550        Ok(home) if cwd.starts_with(&format!("{home}/")) => format!("~{}", &cwd[home.len()..]),
551        _ => cwd.to_string(),
552    }
553}
554
555/// Panel de un comando ejecutado: cabecera resaltada + su salida.
556fn run_panel(r: &CommandRun, theme: &Theme, node_bg: Hsla) -> impl IntoElement {
557    let (glyph, gcolor) = match r.status {
558        RunStatus::Running => ("▷", gpui::hsla(45.0 / 360.0, 0.75, 0.60, 1.0)),
559        RunStatus::Ok => ("✓", gpui::hsla(140.0 / 360.0, 0.48, 0.55, 1.0)),
560        RunStatus::Failed => ("✗", gpui::hsla(2.0 / 360.0, 0.68, 0.60, 1.0)),
561    };
562    let dim = theme.fg_muted;
563
564    let total = r.output.len();
565    let skipped = total.saturating_sub(OUTPUT_LINES);
566    let mut body: Vec<gpui::Div> = Vec::new();
567    if skipped > 0 {
568        body.push(
569            div()
570                .text_size(px(11.))
571                .text_color(dim)
572                .child(SharedString::from(format!("… {skipped} líneas antes"))),
573        );
574    }
575    for line in r.output.iter().skip(skipped) {
576        body.push(
577            div()
578                .text_size(px(12.))
579                .text_color(theme.fg_text)
580                .child(SharedString::from(line.clone())),
581        );
582    }
583
584    let exit_note = match r.exit_code {
585        Some(0) => String::new(),
586        Some(c) => format!("salió {c}"),
587        None => String::new(),
588    };
589
590    div()
591        .flex()
592        .flex_col()
593        .gap(px(3.))
594        .p(px(8.))
595        .bg(node_bg)
596        .border_l_2()
597        .border_color(gcolor)
598        .rounded(px(5.))
599        .child(
600            div()
601                .flex()
602                .flex_row()
603                .items_center()
604                .gap(px(6.))
605                .child(div().flex_none().text_color(gcolor).child(glyph))
606                .children(highlight(&r.line, theme))
607                .child(
608                    div()
609                        .flex_none()
610                        .text_size(px(11.))
611                        .text_color(dim)
612                        .child(SharedString::from(exit_note)),
613                ),
614        )
615        .children(body)
616}
617
618impl Render for Shell {
619    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
620        if !self.focused_once {
621            window.focus(&self.focus);
622            self.focused_once = true;
623        }
624        let theme = Theme::global(cx).clone();
625        let bg = theme.bg_app.clone();
626        let panel = gpui::hsla(220.0 / 360.0, 0.16, 0.11, 1.0);
627        let node_bg = gpui::hsla(220.0 / 360.0, 0.14, 0.16, 1.0);
628        let accent = gpui::hsla(190.0 / 360.0, 0.70, 0.62, 1.0);
629        let text = theme.fg_text;
630        let dim = theme.fg_muted;
631
632        let pipeline = self.line.pipeline();
633        let piped = pipeline.stages.iter().filter(|s| s.command.is_some()).count();
634
635        // --- Barra de estado: cwd + identificador de aislamiento ---
636        let status = div()
637            .h(px(32.))
638            .flex()
639            .flex_row()
640            .items_center()
641            .justify_between()
642            .px(px(14.))
643            .bg(panel)
644            .text_color(text)
645            .child(
646                div()
647                    .flex()
648                    .flex_row()
649                    .gap(px(10.))
650                    .items_baseline()
651                    .child("● shuma")
652                    .child(div().text_color(accent).child(SharedString::from(format!(
653                        "📁 {}",
654                        pretty_cwd(self.session.cwd())
655                    ))))
656                    .child(div().text_color(dim).text_size(px(11.)).child(SharedString::from(
657                        format!("aisl:{}", self.session.isolation_id()),
658                    ))),
659            )
660            .child(
661                div().text_color(dim).text_size(px(12.)).child(SharedString::from(
662                    if piped > 1 {
663                        format!("⇄ {piped} etapas · {} en curso", self.active.len())
664                    } else {
665                        format!("{} en curso", self.active.len())
666                    },
667                )),
668            );
669
670        // --- Panel izquierdo: grupos reutilizables [RUN] ---
671        let left = if self.left_collapsed {
672            div()
673                .id("expand-left")
674                .w(px(26.))
675                .flex()
676                .flex_col()
677                .items_center()
678                .pt(px(8.))
679                .bg(panel)
680                .text_color(dim)
681                .cursor_pointer()
682                .hover(|s| s.bg(node_bg))
683                .child("»")
684                .on_click(cx.listener(|s, _, _, cx| {
685                    s.left_collapsed = false;
686                    cx.notify();
687                }))
688        } else {
689            let groups: Vec<_> = self
690                .session
691                .groups()
692                .iter()
693                .map(|g| {
694                    let joined = g.lines.join(" && ");
695                    let count = g.lines.len();
696                    div()
697                        .id(SharedString::from(format!("group-{}", g.name)))
698                        .px(px(8.))
699                        .py(px(6.))
700                        .bg(node_bg)
701                        .rounded(px(4.))
702                        .text_color(text)
703                        .text_size(px(13.))
704                        .cursor_pointer()
705                        .hover(|s| s.bg(theme.bg_row_hover))
706                        .child(SharedString::from(format!("▸ {}  ·{count}", g.name)))
707                        .on_click(cx.listener(move |shell, _, _, cx| {
708                            shell.run_command(joined.clone());
709                            cx.notify();
710                        }))
711                })
712                .collect();
713            div()
714                .id("run-panel")
715                .w(px(self.left_width))
716                .flex()
717                .flex_col()
718                .gap(px(6.))
719                .p(px(10.))
720                .bg(panel)
721                .child(
722                    div()
723                        .flex()
724                        .flex_row()
725                        .justify_between()
726                        .items_center()
727                        .child(div().text_color(dim).text_size(px(12.)).child("[RUN] grupos"))
728                        .child(
729                            div()
730                                .id("collapse-left")
731                                .px(px(5.))
732                                .text_color(dim)
733                                .cursor_pointer()
734                                .hover(|s| s.text_color(accent))
735                                .child("«")
736                                .on_click(cx.listener(|s, _, _, cx| {
737                                    s.left_collapsed = true;
738                                    cx.notify();
739                                })),
740                        ),
741                )
742                .children(groups)
743                .child(
744                    div()
745                        .text_size(px(10.))
746                        .text_color(dim)
747                        .child("clic para ejecutar el grupo"),
748                )
749        };
750
751        // --- Lienzo central: comandos ejecutados + su salida ---
752        let runs: Vec<_> = self
753            .session
754            .history()
755            .iter()
756            .rev()
757            .take(40)
758            .map(|r| run_panel(r, &theme, node_bg))
759            .collect();
760        let runs_empty = runs.is_empty();
761        let canvas = div()
762            .id("runs")
763            .flex_1()
764            .overflow_y_scroll()
765            .flex()
766            .flex_col()
767            .gap(px(8.))
768            .p(px(10.))
769            .bg(bg.clone())
770            .when(runs_empty, |d| {
771                d.child(div().text_color(dim).child(
772                    "Escribe un comando abajo y presiona Enter — su salida aparece aquí.",
773                ))
774            })
775            .children(runs);
776
777        // --- Panel derecho: monitores [SENS] ---
778        let right = if self.right_collapsed {
779            div()
780                .id("expand-right")
781                .w(px(26.))
782                .flex()
783                .flex_col()
784                .items_center()
785                .pt(px(8.))
786                .bg(panel)
787                .text_color(dim)
788                .cursor_pointer()
789                .hover(|s| s.bg(node_bg))
790                .child("«")
791                .on_click(cx.listener(|s, _, _, cx| {
792                    s.right_collapsed = false;
793                    cx.notify();
794                }))
795        } else {
796            let cpu = self.snapshot.cpu_percent;
797            let cpu_curve = self.sampler.cpu_history().values();
798            let mem_curve = self.sampler.mem_history().values();
799            let cpu_color = gpui::hsla(190.0 / 360.0, 0.72, 0.62, 1.0);
800            let mem_color = gpui::hsla(265.0 / 360.0, 0.55, 0.70, 1.0);
801
802            let monitor = |title: &str, value: String, curve: Vec<f32>, color: Hsla| {
803                div()
804                    .flex()
805                    .flex_col()
806                    .gap(px(4.))
807                    .p(px(8.))
808                    .bg(node_bg)
809                    .rounded(px(5.))
810                    .child(
811                        div()
812                            .flex()
813                            .flex_row()
814                            .justify_between()
815                            .items_baseline()
816                            .child(div().text_color(dim).text_size(px(11.)).child(title.to_string()))
817                            .child(div().text_color(color).child(SharedString::from(value))),
818                    )
819                    .child(div().h(px(44.)).child(CurveElement::new(curve, color)))
820            };
821
822            div()
823                .id("sens-panel")
824                .w(px(self.right_width))
825                .flex()
826                .flex_col()
827                .gap(px(10.))
828                .p(px(10.))
829                .bg(panel)
830                .text_color(text)
831                .child(
832                    div()
833                        .flex()
834                        .flex_row()
835                        .justify_between()
836                        .items_center()
837                        .child(div().text_color(dim).text_size(px(12.)).child("[SENS]"))
838                        .child(
839                            div()
840                                .id("collapse-right")
841                                .px(px(5.))
842                                .text_color(dim)
843                                .cursor_pointer()
844                                .hover(|s| s.text_color(accent))
845                                .child("»")
846                                .on_click(cx.listener(|s, _, _, cx| {
847                                    s.right_collapsed = true;
848                                    cx.notify();
849                                })),
850                        ),
851                )
852                .child(monitor("CPU", format!("{cpu:.0} %"), cpu_curve, cpu_color))
853                .child(monitor(
854                    "MEM",
855                    if self.snapshot.valid {
856                        format!(
857                            "{:.1}/{:.0} GB",
858                            self.snapshot.mem_used_mb as f32 / 1024.0,
859                            self.snapshot.mem_total_mb as f32 / 1024.0
860                        )
861                    } else {
862                        "— GB".to_string()
863                    },
864                    mem_curve,
865                    mem_color,
866                ))
867        };
868
869        // --- Zona prompt: el input inteligente ---
870        let mut input_row: Vec<gpui::Div> = vec![div().flex_none().text_color(accent).child("›  ")];
871        let cursor = self.line.cursor();
872        let tokens = self.line.tokens();
873        let caret = || div().w(px(2.)).h(px(19.)).bg(accent);
874        if tokens.is_empty() {
875            input_row.push(caret());
876            input_row.push(
877                div()
878                    .text_color(dim)
879                    .child("escribe un comando…  (Tab autocompleta · Enter ejecuta)"),
880            );
881        } else {
882            let mut caret_done = false;
883            for t in &tokens {
884                let color = token_color(t.kind, &theme);
885                if !caret_done && cursor >= t.start && cursor < t.end {
886                    let local = cursor - t.start;
887                    let (left_s, right_s) = t.text.split_at(local);
888                    if !left_s.is_empty() {
889                        input_row
890                            .push(div().flex_none().text_color(color).child(left_s.to_string()));
891                    }
892                    input_row.push(caret());
893                    input_row
894                        .push(div().flex_none().text_color(color).child(right_s.to_string()));
895                    caret_done = true;
896                } else {
897                    input_row.push(div().flex_none().text_color(color).child(t.text.clone()));
898                }
899            }
900            if !caret_done {
901                input_row.push(caret());
902            }
903        }
904        let prompt = div()
905            .h(px(46.))
906            .flex()
907            .flex_row()
908            .items_center()
909            .px(px(14.))
910            .bg(panel)
911            .text_color(text)
912            .text_size(px(14.))
913            .children(input_row);
914
915        // --- Popup de autocompletado ---
916        let mut popup_layer: Vec<gpui::Div> = Vec::new();
917        if self.show_completion {
918            if let Some(comp) = &self.completion {
919                if !comp.candidates.is_empty() {
920                    let kind_label = match comp.kind {
921                        CompletionKind::Command => "comando",
922                        CompletionKind::Flag => "flag",
923                        CompletionKind::Path => "ruta",
924                    };
925                    let total = comp.candidates.len();
926                    let start = self
927                        .completion_index
928                        .saturating_sub(3)
929                        .min(total.saturating_sub(8));
930                    let rows: Vec<_> = comp
931                        .candidates
932                        .iter()
933                        .enumerate()
934                        .skip(start)
935                        .take(8)
936                        .map(|(i, cand)| {
937                            let selected = i == self.completion_index;
938                            div()
939                                .px(px(8.))
940                                .py(px(3.))
941                                .when(selected, |d| {
942                                    d.bg(accent).text_color(gpui::hsla(0.0, 0.0, 0.1, 1.0))
943                                })
944                                .when(!selected, |d| d.text_color(text))
945                                .child(SharedString::from(cand.clone()))
946                        })
947                        .collect();
948                    popup_layer.push(
949                        div()
950                            .absolute()
951                            .left(px(28.))
952                            .bottom(px(52.))
953                            .w(px(320.))
954                            .flex()
955                            .flex_col()
956                            .bg(node_bg)
957                            .border_1()
958                            .border_color(accent)
959                            .rounded(px(5.))
960                            .text_size(px(13.))
961                            .child(
962                                div()
963                                    .px(px(8.))
964                                    .py(px(3.))
965                                    .text_color(dim)
966                                    .text_size(px(11.))
967                                    .child(SharedString::from(format!(
968                                        "{kind_label} · {total} · ↑↓ Tab"
969                                    ))),
970                            )
971                            .children(rows),
972                    );
973                }
974            }
975        }
976
977        // --- Divisores arrastrables ---
978        let divider = |side: Side, cx: &mut Context<Self>| {
979            div()
980                .w(px(5.))
981                .bg(node_bg)
982                .cursor(CursorStyle::ResizeLeftRight)
983                .hover(|s| s.bg(accent))
984                .on_mouse_down(
985                    MouseButton::Left,
986                    cx.listener(move |shell, ev: &MouseDownEvent, _w, cx| {
987                        let start_w = match side {
988                            Side::Left => shell.left_width,
989                            Side::Right => shell.right_width,
990                        };
991                        shell.drag = Some(Drag {
992                            side,
993                            start_x: ev.position.x.into(),
994                            start_w,
995                        });
996                        cx.notify();
997                    }),
998                )
999        };
1000
1001        let mut middle = div().flex().flex_row().flex_1().overflow_hidden().child(left);
1002        if !self.left_collapsed {
1003            middle = middle.child(divider(Side::Left, cx));
1004        }
1005        middle = middle.child(canvas);
1006        if !self.right_collapsed {
1007            middle = middle.child(divider(Side::Right, cx));
1008        }
1009        middle = middle.child(right);
1010
1011        // --- Composición ---
1012        div()
1013            .size_full()
1014            .relative()
1015            .flex()
1016            .flex_col()
1017            .bg(bg)
1018            .track_focus(&self.focus)
1019            .key_context("ShumaShell")
1020            .on_key_down(cx.listener(Self::handle_key))
1021            .on_mouse_move(cx.listener(|shell, ev: &MouseMoveEvent, _w, cx| {
1022                if let Some(drag) = &shell.drag {
1023                    let cur: f32 = ev.position.x.into();
1024                    let delta = cur - drag.start_x;
1025                    match drag.side {
1026                        // El panel izquierdo crece al arrastrar a la derecha.
1027                        Side::Left => {
1028                            shell.left_width = (drag.start_w + delta).clamp(130.0, 420.0)
1029                        }
1030                        // El derecho crece al arrastrar a la izquierda.
1031                        Side::Right => {
1032                            shell.right_width = (drag.start_w - delta).clamp(130.0, 420.0)
1033                        }
1034                    }
1035                    cx.notify();
1036                }
1037            }))
1038            .on_mouse_up(
1039                MouseButton::Left,
1040                cx.listener(|shell, _ev: &MouseUpEvent, _w, cx| {
1041                    if shell.drag.take().is_some() {
1042                        cx.notify();
1043                    }
1044                }),
1045            )
1046            .child(status)
1047            .child(middle)
1048            .child(prompt)
1049            .children(popup_layer)
1050    }
1051}
1052
1053fn main() {
1054    launch_app("brahman · shuma shell", (1100., 700.), Shell::new);
1055}