gioser

tawasuyu/brahman

Rust 476TOML 190Markdown 50JSON 176602 Definitions

⎇ 37ea535cb7ea2ea6b15f11684c48f55462d87c24 crates/apps/shuma-shell/src/main.rs Rust · 1277 lines · 44.8 KB History Who touched what raw

feat(shuma): shuma-infer — motor de inferencia de intenciones 37ea535cb · 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    /// Largo del historial en el último `:save` — define qué comandos
256    /// entran al próximo grupo guardado.
257    group_anchor: usize,
258    /// Scroll del feed central — sigue al comando más reciente.
259    scroll: ScrollHandle,
260    focus: FocusHandle,
261    focused_once: bool,
262}
263
264impl Shell {
265    fn new(cx: &mut Context<Self>) -> Self {
266        let cwd = std::env::current_dir()
267            .map(|p| p.to_string_lossy().into_owned())
268            .unwrap_or_else(|_| "/".to_string());
269
270        let mut session = WorkSession::new("sesión", &cwd);
271        // Grupos de ejemplo — recetas reutilizables.
272        session.save_group("estado git", vec!["git status --short".into()]);
273        session.save_group("build", vec!["cargo build --release".into()]);
274
275        let shell = Self {
276            line: LineState::new(),
277            session,
278            active: Vec::new(),
279            completion: None,
280            completion_index: 0,
281            show_completion: false,
282            source: ShellCompletionSource::scan(cwd),
283            sampler: SystemSampler::new(HISTORY),
284            snapshot: Snapshot {
285                cpu_percent: 0.0,
286                mem_percent: 0.0,
287                mem_used_mb: 0,
288                mem_total_mb: 0,
289                valid: false,
290            },
291            left_collapsed: false,
292            right_collapsed: false,
293            left_width: 176.0,
294            right_width: 188.0,
295            drag: None,
296            run_ui: HashMap::new(),
297            group_anchor: 0,
298            scroll: ScrollHandle::new(),
299            focus: cx.focus_handle(),
300            focused_once: false,
301        };
302        shell.start_loop(cx);
303        shell
304    }
305
306    /// Bucle de fondo: drena la salida de los comandos (~9/s) y refresca
307    /// los monitores (~1/s).
308    fn start_loop(&self, cx: &mut Context<Self>) {
309        cx.spawn(async move |this, cx| {
310            let mut tick: u32 = 0;
311            loop {
312                cx.background_executor().timer(Duration::from_millis(110)).await;
313                tick += 1;
314                let sysmon = tick % 10 == 0;
315                let alive = this.update(cx, |shell, cx| {
316                    let mut changed = shell.drain_exec();
317                    if sysmon {
318                        shell.snapshot = shell.sampler.sample();
319                        changed = true;
320                    }
321                    if changed {
322                        cx.notify();
323                    }
324                });
325                if alive.is_err() {
326                    break;
327                }
328            }
329        })
330        .detach();
331    }
332
333    /// Vuelca la salida disponible de los comandos en curso al historial.
334    fn drain_exec(&mut self) -> bool {
335        let now = unix_now();
336        let mut changed = false;
337        for (id, handle) in &mut self.active {
338            for ev in handle.try_events() {
339                changed = true;
340                match ev {
341                    RunEvent::Stdout(l) => {
342                        self.session.append_output(*id, Stream::Stdout, l)
343                    }
344                    RunEvent::Stderr(l) => {
345                        self.session.append_output(*id, Stream::Stderr, l)
346                    }
347                    RunEvent::Exited(code) => self.session.finish_run(*id, code, now),
348                    RunEvent::Failed(msg) => {
349                        self.session.append_output(
350                            *id,
351                            Stream::Stderr,
352                            format!("✗ no se pudo lanzar: {msg}"),
353                        );
354                        self.session.finish_run(*id, -1, now);
355                    }
356                }
357            }
358        }
359        let finished = self.active.iter().any(|(_, h)| h.is_finished());
360        self.active.retain(|(_, h)| !h.is_finished());
361        if finished {
362            // Al cerrarse un comando, el motor de inferencia revisa si
363            // emergió un patrón repetido y lo promueve a un grupo.
364            self.infer_patterns();
365        }
366        if changed {
367            self.scroll.scroll_to_bottom();
368        }
369        changed
370    }
371
372    /// Corre el motor de inferencia sobre el historial y promueve el
373    /// patrón más fuerte a un grupo reutilizable (rehidratación).
374    fn infer_patterns(&mut self) {
375        let records: Vec<shuma_infer::CommandRecord> = self
376            .session
377            .history()
378            .iter()
379            .map(|r| {
380                shuma_infer::CommandRecord::parse(
381                    &r.line,
382                    &r.cwd,
383                    r.status == RunStatus::Ok,
384                )
385            })
386            .collect();
387        let patterns =
388            shuma_infer::detect_patterns(&records, &shuma_infer::InferConfig::default());
389        if let Some(top) = patterns.first() {
390            let name = format!("✨ {}", top.suggested_name());
391            if self.session.group(&name).is_none() {
392                self.session.save_group(name, top.example.clone());
393            }
394        }
395    }
396
397    /// Resuelve el destino de un `cd` contra el cwd de la sesión.
398    fn resolve_cd(&self, arg: &str) -> Result<String, String> {
399        let home = std::env::var("HOME").unwrap_or_else(|_| "/".into());
400        let target = if arg.is_empty() || arg == "~" {
401            home
402        } else if let Some(rest) = arg.strip_prefix("~/") {
403            format!("{home}/{rest}")
404        } else if arg.starts_with('/') {
405            arg.to_string()
406        } else {
407            format!("{}/{}", self.session.cwd(), arg)
408        };
409        match std::fs::canonicalize(&target) {
410            Ok(p) if p.is_dir() => Ok(p.to_string_lossy().into_owned()),
411            Ok(_) => Err(format!("cd: no es un directorio: {target}")),
412            Err(e) => Err(format!("cd: {target}: {e}")),
413        }
414    }
415
416    /// Ejecuta una línea: `cd` se maneja internamente (cambia el cwd y,
417    /// con él, el aislamiento); el resto se lanza con `shuma-exec` y su
418    /// salida se transmite al panel central.
419    /// Guarda como grupo los comandos ejecutados desde el último `:save`.
420    fn save_group(&mut self, name: &str) {
421        let name = name.trim();
422        if name.is_empty() {
423            return;
424        }
425        let n = self.session.history().len().saturating_sub(self.group_anchor);
426        if n > 0 {
427            self.session.save_recent_as_group(name, n);
428            self.group_anchor = self.session.history().len();
429        }
430    }
431
432    fn run_command(&mut self, line: String) {
433        let line = line.trim().to_string();
434        if line.is_empty() {
435            return;
436        }
437        let now = unix_now();
438
439        // Meta-comando `:save <nombre>` — guarda un grupo, no se ejecuta.
440        if let Some(name) = line.strip_prefix(":save ") {
441            self.save_group(name);
442            return;
443        }
444
445        // Los comandos anteriores que el usuario no fijó se autocolapsan
446        // al aparecer uno nuevo abajo — orden de terminal tradicional.
447        for ui in self.run_ui.values_mut() {
448            if !ui.user_touched {
449                ui.collapsed = true;
450            }
451        }
452
453        // `cd` interno — un subproceso no podría cambiar nuestro cwd.
454        if line == "cd" || line.starts_with("cd ") {
455            let arg = line.strip_prefix("cd").unwrap_or("").trim();
456            let id = self.session.begin_run(&line, now);
457            self.run_ui.insert(id, RunUi::default());
458            match self.resolve_cd(arg) {
459                Ok(new_cwd) => {
460                    self.session.set_cwd(new_cwd.clone());
461                    self.source.cwd = new_cwd.clone();
462                    self.session
463                        .append_output(id, Stream::Stdout, format!("→ {new_cwd}"));
464                    self.session.finish_run(id, 0, now);
465                }
466                Err(e) => {
467                    self.session.append_output(id, Stream::Stderr, e);
468                    self.session.finish_run(id, 1, now);
469                }
470            }
471            self.scroll.scroll_to_bottom();
472            return;
473        }
474
475        let id = self.session.begin_run(&line, now);
476        self.run_ui.insert(id, RunUi::default());
477        let spec = CommandSpec::bash(&line, self.session.cwd());
478        self.active.push((id, exec_run(&spec)));
479        self.scroll.scroll_to_bottom();
480    }
481
482    /// Mata el proceso de un comando en curso.
483    fn kill_run(&self, id: RunId) {
484        if let Some((_, handle)) = self.active.iter().find(|(rid, _)| *rid == id) {
485            handle.kill();
486        }
487    }
488
489    fn refresh_completion(&mut self) {
490        let comp = self.line.complete(&self.source);
491        self.show_completion =
492            !comp.candidates.is_empty() && comp.replace_end > comp.replace_start;
493        self.completion_index = 0;
494        self.completion = Some(comp);
495    }
496
497    fn on_tab(&mut self) {
498        let comp = self.line.complete(&self.source);
499        if comp.candidates.is_empty() {
500            return;
501        }
502        if self.show_completion {
503            let idx = self.completion_index.min(comp.candidates.len() - 1);
504            let candidate = comp.candidates[idx].clone();
505            self.line.apply_completion(&comp, &candidate);
506            self.show_completion = false;
507            self.completion = None;
508        } else {
509            self.completion_index = 0;
510            self.completion = Some(comp);
511            self.show_completion = true;
512        }
513    }
514
515    fn cycle_completion(&mut self, delta: i32) {
516        if !self.show_completion {
517            return;
518        }
519        if let Some(comp) = &self.completion {
520            let n = comp.candidates.len();
521            if n > 0 {
522                let i = self.completion_index as i32 + delta;
523                self.completion_index = i.rem_euclid(n as i32) as usize;
524            }
525        }
526    }
527
528    /// Enter — ejecuta el contenido del input.
529    fn submit(&mut self) {
530        let line = self.line.text().to_string();
531        self.line.clear();
532        self.completion = None;
533        self.show_completion = false;
534        self.run_command(line);
535    }
536
537    fn handle_key(&mut self, event: &KeyDownEvent, _w: &mut Window, cx: &mut Context<Self>) {
538        let ks = &event.keystroke;
539        let key = ks.key.as_str();
540        let ctrl = ks.modifiers.control;
541        let mut changed = false;
542
543        match key {
544            "enter" => {
545                self.submit();
546                cx.notify();
547                return;
548            }
549            "escape" => {
550                self.show_completion = false;
551                cx.notify();
552                return;
553            }
554            "tab" => {
555                self.on_tab();
556                cx.notify();
557                return;
558            }
559            "up" => {
560                self.cycle_completion(-1);
561                cx.notify();
562                return;
563            }
564            "down" => {
565                self.cycle_completion(1);
566                cx.notify();
567                return;
568            }
569            "backspace" => {
570                self.line.backspace();
571                changed = true;
572            }
573            "delete" => {
574                self.line.delete();
575                changed = true;
576            }
577            "left" => {
578                if ctrl {
579                    self.line.move_word_left();
580                } else {
581                    self.line.move_left();
582                }
583            }
584            "right" => {
585                if ctrl {
586                    self.line.move_word_right();
587                } else {
588                    self.line.move_right();
589                }
590            }
591            "home" => self.line.move_home(),
592            "end" => self.line.move_end(),
593            "a" if ctrl => self.line.move_home(),
594            "e" if ctrl => self.line.move_end(),
595            "u" if ctrl => {
596                self.line.clear();
597                changed = true;
598            }
599            _ => {
600                if !ctrl {
601                    if let Some(ch) = ks.key_char.as_deref() {
602                        if !ch.chars().any(|c| c.is_control()) {
603                            self.line.insert(ch);
604                            changed = true;
605                        }
606                    }
607                }
608            }
609        }
610
611        if changed {
612            self.refresh_completion();
613        }
614        cx.notify();
615    }
616}
617
618/// Color de resaltado de cada clase de token.
619fn token_color(kind: TokenKind, theme: &Theme) -> Hsla {
620    match kind {
621        TokenKind::Command => gpui::hsla(190.0 / 360.0, 0.65, 0.62, 1.0),
622        TokenKind::Argument => theme.fg_text,
623        TokenKind::Flag => gpui::hsla(38.0 / 360.0, 0.80, 0.62, 1.0),
624        TokenKind::StringLit => gpui::hsla(95.0 / 360.0, 0.42, 0.60, 1.0),
625        TokenKind::Variable => gpui::hsla(280.0 / 360.0, 0.55, 0.72, 1.0),
626        TokenKind::Pipe => gpui::hsla(190.0 / 360.0, 0.90, 0.72, 1.0),
627        TokenKind::Redirect => gpui::hsla(20.0 / 360.0, 0.78, 0.62, 1.0),
628        TokenKind::Operator => gpui::hsla(0.0, 0.66, 0.66, 1.0),
629        TokenKind::Comment => theme.fg_muted,
630        TokenKind::Whitespace => theme.fg_text,
631        TokenKind::Unknown => gpui::hsla(0.0, 0.70, 0.60, 1.0),
632    }
633}
634
635/// Resalta un texto estático (la línea de un comando del historial).
636fn highlight(text: &str, theme: &Theme) -> Vec<gpui::Div> {
637    shuma_line::tokenize(text, shuma_line::Dialect::Bash)
638        .into_iter()
639        .map(|t| {
640            div()
641                .flex_none()
642                .text_color(token_color(t.kind, theme))
643                .child(t.text)
644        })
645        .collect()
646}
647
648/// Acorta el cwd: el `$HOME` se muestra como `~`.
649fn pretty_cwd(cwd: &str) -> String {
650    match std::env::var("HOME") {
651        Ok(home) if cwd == home => "~".to_string(),
652        Ok(home) if cwd.starts_with(&format!("{home}/")) => format!("~{}", &cwd[home.len()..]),
653        _ => cwd.to_string(),
654    }
655}
656
657/// Renderiza la tarjeta de un comando ejecutado: cabecera-acordeón +
658/// filtro stdout/stderr + cuerpo de salida.
659fn render_run(
660    r: &CommandRun,
661    ui: RunUi,
662    theme: &Theme,
663    node_bg: Hsla,
664    cx: &mut Context<Shell>,
665) -> impl IntoElement {
666    let id = r.id;
667    let dim = theme.fg_muted;
668    let (glyph, gcolor) = match r.status {
669        RunStatus::Running => ("▷", gpui::hsla(45.0 / 360.0, 0.75, 0.60, 1.0)),
670        RunStatus::Ok => ("✓", gpui::hsla(140.0 / 360.0, 0.48, 0.55, 1.0)),
671        RunStatus::Failed => ("✗", gpui::hsla(2.0 / 360.0, 0.68, 0.60, 1.0)),
672    };
673    let stderr_color = gpui::hsla(8.0 / 360.0, 0.62, 0.66, 1.0);
674
675    // Nota a la derecha: código de salida, y al colapsar, conteo de líneas.
676    let mut note = match r.exit_code {
677        Some(0) | None => String::new(),
678        Some(c) => format!("salió {c}"),
679    };
680    if ui.collapsed {
681        let n = r.count_of(Stream::Stdout);
682        if n > 0 {
683            note = format!("{note} · {n} líneas").trim_start().to_string();
684        }
685    }
686
687    // Cabecera-acordeón: un clic colapsa/expande.
688    let caret = if ui.collapsed { "▸" } else { "▾" };
689    let header_left = div()
690        .id(SharedString::from(format!("hdr-{id}")))
691        .flex()
692        .flex_row()
693        .items_center()
694        .gap(px(6.))
695        .flex_1()
696        .cursor_pointer()
697        .child(div().flex_none().text_color(dim).child(caret))
698        .child(div().flex_none().text_color(gcolor).child(glyph))
699        .children(highlight(&r.line, theme))
700        .child(
701            div()
702                .flex_none()
703                .text_size(px(11.))
704                .text_color(dim)
705                .child(SharedString::from(note)),
706        )
707        .on_click(cx.listener(move |shell, _, _, cx| {
708            let e = shell.run_ui.entry(id).or_default();
709            e.collapsed = !e.collapsed;
710            e.user_touched = true;
711            cx.notify();
712        }));
713
714    // Filtro de stderr — sólo aparece si el comando emitió errores.
715    let stderr_chip = if r.has_stderr() {
716        let n = r.count_of(Stream::Stderr);
717        Some(
718            div()
719                .id(SharedString::from(format!("err-{id}")))
720                .flex_none()
721                .px(px(6.))
722                .py(px(1.))
723                .rounded(px(3.))
724                .text_size(px(11.))
725                .cursor_pointer()
726                .when(ui.show_stderr, |d| {
727                    d.bg(stderr_color).text_color(gpui::hsla(0.0, 0.0, 0.12, 1.0))
728                })
729                .when(!ui.show_stderr, |d| d.text_color(stderr_color))
730                .child(SharedString::from(format!("⚠ {n}")))
731                .on_click(cx.listener(move |shell, _, _, cx| {
732                    let e = shell.run_ui.entry(id).or_default();
733                    e.show_stderr = !e.show_stderr;
734                    cx.notify();
735                })),
736        )
737    } else {
738        None
739    };
740
741    // Botón de matar — sólo mientras el comando sigue corriendo.
742    let kill_chip = if r.status == RunStatus::Running {
743        Some(
744            div()
745                .id(SharedString::from(format!("kill-{id}")))
746                .flex_none()
747                .px(px(6.))
748                .py(px(1.))
749                .rounded(px(3.))
750                .text_size(px(11.))
751                .text_color(gpui::hsla(2.0 / 360.0, 0.66, 0.64, 1.0))
752                .cursor_pointer()
753                .hover(|s| s.bg(gpui::hsla(2.0 / 360.0, 0.55, 0.28, 1.0)))
754                .child("✕ matar")
755                .on_click(cx.listener(move |shell, _, _, cx| {
756                    shell.kill_run(id);
757                    cx.notify();
758                })),
759        )
760    } else {
761        None
762    };
763
764    let header = div()
765        .flex()
766        .flex_row()
767        .items_center()
768        .gap(px(6.))
769        .child(header_left)
770        .children(stderr_chip)
771        .children(kill_chip);
772
773    // Cuerpo: sólo con el acordeón abierto. El filtro elige el flujo.
774    let mut body: Vec<gpui::Div> = Vec::new();
775    if !ui.collapsed {
776        let stream = if ui.show_stderr { Stream::Stderr } else { Stream::Stdout };
777        let lines: Vec<&str> = r.lines_of(stream).collect();
778        let color = if ui.show_stderr { stderr_color } else { theme.fg_text };
779        if lines.is_empty() {
780            body.push(div().text_size(px(11.)).text_color(dim).child(
781                if ui.show_stderr { "sin errores" } else { "sin salida" },
782            ));
783        } else {
784            let skipped = lines.len().saturating_sub(OUTPUT_LINES);
785            if skipped > 0 {
786                body.push(
787                    div()
788                        .text_size(px(11.))
789                        .text_color(dim)
790                        .child(SharedString::from(format!("… {skipped} líneas antes"))),
791                );
792            }
793            for l in lines.iter().skip(skipped) {
794                body.push(
795                    div()
796                        .text_size(px(12.))
797                        .text_color(color)
798                        .child(SharedString::from(l.to_string())),
799                );
800            }
801        }
802    }
803
804    div()
805        .flex()
806        .flex_col()
807        .gap(px(3.))
808        .p(px(8.))
809        .bg(node_bg)
810        .border_l_2()
811        .border_color(gcolor)
812        .rounded(px(5.))
813        .child(header)
814        .children(body)
815}
816
817impl Render for Shell {
818    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
819        if !self.focused_once {
820            window.focus(&self.focus);
821            self.focused_once = true;
822        }
823        let theme = Theme::global(cx).clone();
824        let bg = theme.bg_app.clone();
825        let panel = gpui::hsla(220.0 / 360.0, 0.16, 0.11, 1.0);
826        let node_bg = gpui::hsla(220.0 / 360.0, 0.14, 0.16, 1.0);
827        let accent = gpui::hsla(190.0 / 360.0, 0.70, 0.62, 1.0);
828        let text = theme.fg_text;
829        let dim = theme.fg_muted;
830
831        let pipeline = self.line.pipeline();
832        let piped = pipeline.stages.iter().filter(|s| s.command.is_some()).count();
833
834        // --- Barra de estado: cwd + identificador de aislamiento ---
835        let status = div()
836            .h(px(32.))
837            .flex()
838            .flex_row()
839            .items_center()
840            .justify_between()
841            .px(px(14.))
842            .bg(panel)
843            .text_color(text)
844            .child(
845                div()
846                    .flex()
847                    .flex_row()
848                    .gap(px(10.))
849                    .items_baseline()
850                    .child("● shuma")
851                    .child(div().text_color(accent).child(SharedString::from(format!(
852                        "📁 {}",
853                        pretty_cwd(self.session.cwd())
854                    ))))
855                    .child(div().text_color(dim).text_size(px(11.)).child(SharedString::from(
856                        format!("aisl:{}", self.session.isolation_id()),
857                    ))),
858            )
859            .child(
860                div().text_color(dim).text_size(px(12.)).child(SharedString::from(
861                    if piped > 1 {
862                        format!("⇄ {piped} etapas · {} en curso", self.active.len())
863                    } else {
864                        format!("{} en curso", self.active.len())
865                    },
866                )),
867            );
868
869        // --- Panel izquierdo: grupos reutilizables [RUN] ---
870        let left = if self.left_collapsed {
871            div()
872                .id("expand-left")
873                .w(px(26.))
874                .flex()
875                .flex_col()
876                .items_center()
877                .pt(px(8.))
878                .bg(panel)
879                .text_color(dim)
880                .cursor_pointer()
881                .hover(|s| s.bg(node_bg))
882                .child("»")
883                .on_click(cx.listener(|s, _, _, cx| {
884                    s.left_collapsed = false;
885                    cx.notify();
886                }))
887        } else {
888            let groups: Vec<_> = self
889                .session
890                .groups()
891                .iter()
892                .map(|g| {
893                    let joined = g.lines.join(" && ");
894                    let count = g.lines.len();
895                    div()
896                        .id(SharedString::from(format!("group-{}", g.name)))
897                        .px(px(8.))
898                        .py(px(6.))
899                        .bg(node_bg)
900                        .rounded(px(4.))
901                        .text_color(text)
902                        .text_size(px(13.))
903                        .cursor_pointer()
904                        .hover(|s| s.bg(theme.bg_row_hover))
905                        .child(SharedString::from(format!("▸ {}  ·{count}", g.name)))
906                        .on_click(cx.listener(move |shell, _, _, cx| {
907                            shell.run_command(joined.clone());
908                            cx.notify();
909                        }))
910                })
911                .collect();
912            div()
913                .id("run-panel")
914                .w(px(self.left_width))
915                .flex()
916                .flex_col()
917                .gap(px(6.))
918                .p(px(10.))
919                .bg(panel)
920                .child(
921                    div()
922                        .flex()
923                        .flex_row()
924                        .justify_between()
925                        .items_center()
926                        .child(div().text_color(dim).text_size(px(12.)).child("[RUN] grupos"))
927                        .child(
928                            div()
929                                .flex()
930                                .flex_row()
931                                .gap(px(4.))
932                                .items_center()
933                                .child(
934                                    div()
935                                        .id("save-group")
936                                        .px(px(5.))
937                                        .text_color(dim)
938                                        .cursor_pointer()
939                                        .hover(|s| s.text_color(accent))
940                                        .child("+")
941                                        .on_click(cx.listener(|s, _, _, cx| {
942                                            s.line.set_text(":save ");
943                                            cx.notify();
944                                        })),
945                                )
946                                .child(
947                                    div()
948                                        .id("collapse-left")
949                                        .px(px(5.))
950                                        .text_color(dim)
951                                        .cursor_pointer()
952                                        .hover(|s| s.text_color(accent))
953                                        .child("«")
954                                        .on_click(cx.listener(|s, _, _, cx| {
955                                            s.left_collapsed = true;
956                                            cx.notify();
957                                        })),
958                                ),
959                        ),
960                )
961                .children(groups)
962                .child(
963                    div()
964                        .text_size(px(10.))
965                        .text_color(dim)
966                        .child("clic ejecuta · + guarda lo último"),
967                )
968        };
969
970        // --- Lienzo central: comandos ejecutados + su salida ---
971        // Orden de terminal: los más viejos arriba, los nuevos abajo.
972        let hist = self.session.history();
973        let start = hist.len().saturating_sub(40);
974        let runs: Vec<_> = hist[start..]
975            .iter()
976            .map(|r| {
977                let ui = self.run_ui.get(&r.id).copied().unwrap_or_default();
978                render_run(r, ui, &theme, node_bg, cx)
979            })
980            .collect();
981        let runs_empty = runs.is_empty();
982        let canvas = div()
983            .id("runs")
984            .flex_1()
985            .overflow_y_scroll()
986            .track_scroll(&self.scroll)
987            .flex()
988            .flex_col()
989            .gap(px(8.))
990            .p(px(10.))
991            .bg(bg.clone())
992            .when(runs_empty, |d| {
993                d.child(div().text_color(dim).child(
994                    "Escribe un comando abajo y presiona Enter — su salida aparece aquí.",
995                ))
996            })
997            .children(runs);
998
999        // --- Panel derecho: monitores [SENS] ---
1000        let right = if self.right_collapsed {
1001            div()
1002                .id("expand-right")
1003                .w(px(26.))
1004                .flex()
1005                .flex_col()
1006                .items_center()
1007                .pt(px(8.))
1008                .bg(panel)
1009                .text_color(dim)
1010                .cursor_pointer()
1011                .hover(|s| s.bg(node_bg))
1012                .child("«")
1013                .on_click(cx.listener(|s, _, _, cx| {
1014                    s.right_collapsed = false;
1015                    cx.notify();
1016                }))
1017        } else {
1018            let cpu = self.snapshot.cpu_percent;
1019            let cpu_curve = self.sampler.cpu_history().values();
1020            let mem_curve = self.sampler.mem_history().values();
1021            let cpu_color = gpui::hsla(190.0 / 360.0, 0.72, 0.62, 1.0);
1022            let mem_color = gpui::hsla(265.0 / 360.0, 0.55, 0.70, 1.0);
1023
1024            let monitor = |title: &str, value: String, curve: Vec<f32>, color: Hsla| {
1025                div()
1026                    .flex()
1027                    .flex_col()
1028                    .gap(px(4.))
1029                    .p(px(8.))
1030                    .bg(node_bg)
1031                    .rounded(px(5.))
1032                    .child(
1033                        div()
1034                            .flex()
1035                            .flex_row()
1036                            .justify_between()
1037                            .items_baseline()
1038                            .child(div().text_color(dim).text_size(px(11.)).child(title.to_string()))
1039                            .child(div().text_color(color).child(SharedString::from(value))),
1040                    )
1041                    .child(div().h(px(44.)).child(CurveElement::new(curve, color)))
1042            };
1043
1044            div()
1045                .id("sens-panel")
1046                .w(px(self.right_width))
1047                .flex()
1048                .flex_col()
1049                .gap(px(10.))
1050                .p(px(10.))
1051                .bg(panel)
1052                .text_color(text)
1053                .child(
1054                    div()
1055                        .flex()
1056                        .flex_row()
1057                        .justify_between()
1058                        .items_center()
1059                        .child(div().text_color(dim).text_size(px(12.)).child("[SENS]"))
1060                        .child(
1061                            div()
1062                                .id("collapse-right")
1063                                .px(px(5.))
1064                                .text_color(dim)
1065                                .cursor_pointer()
1066                                .hover(|s| s.text_color(accent))
1067                                .child("»")
1068                                .on_click(cx.listener(|s, _, _, cx| {
1069                                    s.right_collapsed = true;
1070                                    cx.notify();
1071                                })),
1072                        ),
1073                )
1074                .child(monitor("CPU", format!("{cpu:.0} %"), cpu_curve, cpu_color))
1075                .child(monitor(
1076                    "MEM",
1077                    if self.snapshot.valid {
1078                        format!(
1079                            "{:.1}/{:.0} GB",
1080                            self.snapshot.mem_used_mb as f32 / 1024.0,
1081                            self.snapshot.mem_total_mb as f32 / 1024.0
1082                        )
1083                    } else {
1084                        "— GB".to_string()
1085                    },
1086                    mem_curve,
1087                    mem_color,
1088                ))
1089        };
1090
1091        // --- Zona prompt: el input inteligente ---
1092        let mut input_row: Vec<gpui::Div> = vec![div().flex_none().text_color(accent).child("›  ")];
1093        let cursor = self.line.cursor();
1094        let tokens = self.line.tokens();
1095        let caret = || div().w(px(2.)).h(px(19.)).bg(accent);
1096        if tokens.is_empty() {
1097            input_row.push(caret());
1098            input_row.push(
1099                div()
1100                    .text_color(dim)
1101                    .child("escribe un comando…  (Tab autocompleta · Enter ejecuta)"),
1102            );
1103        } else {
1104            let mut caret_done = false;
1105            for t in &tokens {
1106                let color = token_color(t.kind, &theme);
1107                if !caret_done && cursor >= t.start && cursor < t.end {
1108                    let local = cursor - t.start;
1109                    let (left_s, right_s) = t.text.split_at(local);
1110                    if !left_s.is_empty() {
1111                        input_row
1112                            .push(div().flex_none().text_color(color).child(left_s.to_string()));
1113                    }
1114                    input_row.push(caret());
1115                    input_row
1116                        .push(div().flex_none().text_color(color).child(right_s.to_string()));
1117                    caret_done = true;
1118                } else {
1119                    input_row.push(div().flex_none().text_color(color).child(t.text.clone()));
1120                }
1121            }
1122            if !caret_done {
1123                input_row.push(caret());
1124            }
1125        }
1126        let prompt = div()
1127            .h(px(46.))
1128            .flex()
1129            .flex_row()
1130            .items_center()
1131            .px(px(14.))
1132            .bg(panel)
1133            .text_color(text)
1134            .text_size(px(14.))
1135            .children(input_row);
1136
1137        // --- Popup de autocompletado ---
1138        let mut popup_layer: Vec<gpui::Div> = Vec::new();
1139        if self.show_completion {
1140            if let Some(comp) = &self.completion {
1141                if !comp.candidates.is_empty() {
1142                    let kind_label = match comp.kind {
1143                        CompletionKind::Command => "comando",
1144                        CompletionKind::Flag => "flag",
1145                        CompletionKind::Path => "ruta",
1146                    };
1147                    let total = comp.candidates.len();
1148                    let start = self
1149                        .completion_index
1150                        .saturating_sub(3)
1151                        .min(total.saturating_sub(8));
1152                    let rows: Vec<_> = comp
1153                        .candidates
1154                        .iter()
1155                        .enumerate()
1156                        .skip(start)
1157                        .take(8)
1158                        .map(|(i, cand)| {
1159                            let selected = i == self.completion_index;
1160                            div()
1161                                .px(px(8.))
1162                                .py(px(3.))
1163                                .when(selected, |d| {
1164                                    d.bg(accent).text_color(gpui::hsla(0.0, 0.0, 0.1, 1.0))
1165                                })
1166                                .when(!selected, |d| d.text_color(text))
1167                                .child(SharedString::from(cand.clone()))
1168                        })
1169                        .collect();
1170                    popup_layer.push(
1171                        div()
1172                            .absolute()
1173                            .left(px(28.))
1174                            .bottom(px(52.))
1175                            .w(px(320.))
1176                            .flex()
1177                            .flex_col()
1178                            .bg(node_bg)
1179                            .border_1()
1180                            .border_color(accent)
1181                            .rounded(px(5.))
1182                            .text_size(px(13.))
1183                            .child(
1184                                div()
1185                                    .px(px(8.))
1186                                    .py(px(3.))
1187                                    .text_color(dim)
1188                                    .text_size(px(11.))
1189                                    .child(SharedString::from(format!(
1190                                        "{kind_label} · {total} · ↑↓ Tab"
1191                                    ))),
1192                            )
1193                            .children(rows),
1194                    );
1195                }
1196            }
1197        }
1198
1199        // --- Divisores arrastrables ---
1200        let divider = |side: Side, cx: &mut Context<Self>| {
1201            div()
1202                .w(px(5.))
1203                .bg(node_bg)
1204                .cursor(CursorStyle::ResizeLeftRight)
1205                .hover(|s| s.bg(accent))
1206                .on_mouse_down(
1207                    MouseButton::Left,
1208                    cx.listener(move |shell, ev: &MouseDownEvent, _w, cx| {
1209                        let start_w = match side {
1210                            Side::Left => shell.left_width,
1211                            Side::Right => shell.right_width,
1212                        };
1213                        shell.drag = Some(Drag {
1214                            side,
1215                            start_x: ev.position.x.into(),
1216                            start_w,
1217                        });
1218                        cx.notify();
1219                    }),
1220                )
1221        };
1222
1223        let mut middle = div().flex().flex_row().flex_1().overflow_hidden().child(left);
1224        if !self.left_collapsed {
1225            middle = middle.child(divider(Side::Left, cx));
1226        }
1227        middle = middle.child(canvas);
1228        if !self.right_collapsed {
1229            middle = middle.child(divider(Side::Right, cx));
1230        }
1231        middle = middle.child(right);
1232
1233        // --- Composición ---
1234        div()
1235            .size_full()
1236            .relative()
1237            .flex()
1238            .flex_col()
1239            .bg(bg)
1240            .track_focus(&self.focus)
1241            .key_context("ShumaShell")
1242            .on_key_down(cx.listener(Self::handle_key))
1243            .on_mouse_move(cx.listener(|shell, ev: &MouseMoveEvent, _w, cx| {
1244                if let Some(drag) = &shell.drag {
1245                    let cur: f32 = ev.position.x.into();
1246                    let delta = cur - drag.start_x;
1247                    match drag.side {
1248                        // El panel izquierdo crece al arrastrar a la derecha.
1249                        Side::Left => {
1250                            shell.left_width = (drag.start_w + delta).clamp(130.0, 420.0)
1251                        }
1252                        // El derecho crece al arrastrar a la izquierda.
1253                        Side::Right => {
1254                            shell.right_width = (drag.start_w - delta).clamp(130.0, 420.0)
1255                        }
1256                    }
1257                    cx.notify();
1258                }
1259            }))
1260            .on_mouse_up(
1261                MouseButton::Left,
1262                cx.listener(|shell, _ev: &MouseUpEvent, _w, cx| {
1263                    if shell.drag.take().is_some() {
1264                        cx.notify();
1265                    }
1266                }),
1267            )
1268            .child(status)
1269            .child(middle)
1270            .child(prompt)
1271            .children(popup_layer)
1272    }
1273}
1274
1275fn main() {
1276    launch_app("brahman · shuma shell", (1100., 700.), Shell::new);
1277}