gioser

tawasuyu/brahman

Rust 478TOML 190Markdown 50JSON 176650 Definiciones

⎇ 9d8f45a9f8721506156a2810f49bb80ab35c659d crates/apps/shuma-shell/src/main.rs Rust · 1405 líneas · 49.5 KB Historia Quién tocó qué crudo

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