gioser

tawasuyu/brahman

Rust 570TOML 215Markdown 68JSON 198261 Definitions

⎇ 2b2a92a72b8aa6d4e006189cbeda5a53e74a80c0 crates/apps/shuma-shell/src/main.rs Rust · 980 lines · 33.7 KB History Who touched what raw

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