gioser

tawasuyu/brahman

Rust 296TOML 105JSON 14Markdown 73751 Definiciones

⎇ 0fde2aa2732815edaf12c2715bb154ec944d92fe crates/apps/shuma-shell/src/main.rs Rust · 1172 líneas · 40.8 KB Historia Quién tocó qué crudo

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