gioser

tawasuyu/brahman

Rust 582TOML 222Markdown 73JSON 198635 Definitions

⎇ fdf820edbbb9d146912ee55920879291db947901 crates/apps/shuma-shell/src/main.rs Rust · 1783 lines · 64.8 KB History Who touched what raw

feat(shuma-shell): matilda embebido — server admin desde la ventana fdf820edb · 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, Exec, RunEvent, RunHandle, StageSpec};
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/// Archivos/directorios que delatan la estructura de un proyecto.
40const PROJECT_MARKERS: &[&str] = &[
41    ".git",
42    "Cargo.toml",
43    "package.json",
44    "go.mod",
45    "Makefile",
46    "pyproject.toml",
47    "pom.xml",
48    "build.gradle",
49];
50
51/// Marcadores de proyecto presentes en `dir`.
52fn markers_in(dir: &str) -> Vec<String> {
53    let base = std::path::Path::new(dir);
54    PROJECT_MARKERS
55        .iter()
56        .filter(|m| base.join(m).exists())
57        .map(|m| m.to_string())
58        .collect()
59}
60
61/// Segundo Unix actual.
62fn unix_now() -> u64 {
63    SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
64}
65
66/// Índice de grupo (0-based) de una tecla de función `f1`..`f8`.
67fn fkey_index(key: &str) -> Option<usize> {
68    let n: usize = key.strip_prefix('f')?.parse().ok()?;
69    (1..=8).contains(&n).then_some(n - 1)
70}
71
72/// Quita las comillas exteriores de un argumento (`"hola"` → `hola`).
73fn unquote(arg: &str) -> String {
74    let b = arg.as_bytes();
75    if b.len() >= 2 && (b[0] == b'"' || b[0] == b'\'') && b[b.len() - 1] == b[0] {
76        let inner = &arg[1..arg.len() - 1];
77        if b[0] == b'"' {
78            inner.replace("\\\"", "\"").replace("\\\\", "\\")
79        } else {
80            inner.to_string()
81        }
82    } else {
83        arg.to_string()
84    }
85}
86
87/// Decide cómo ejecutar una línea. Si es un pipe «simple» —sólo comandos,
88/// argumentos y `|`, sin `$`, redirecciones, operadores ni globs— brahman
89/// la ejecuta **directo**, conectando los procesos él mismo. Si tiene
90/// sintaxis que el modo directo aún no absorbe, cae a `bash -c`: bash
91/// queda como un parser de sintaxis, no como el ejecutor por defecto.
92fn plan_exec(line: &str) -> Exec {
93    use shuma_line::TokenKind::*;
94    let tokens = shuma_line::tokenize(line, shuma_line::Dialect::Bash);
95    let simple = !tokens.is_empty()
96        && tokens.iter().all(|t| {
97            matches!(t.kind, Command | Argument | Flag | StringLit | Pipe | Whitespace)
98                && !t.text.contains(['*', '?', '[', ']', '{', '}'])
99                && !t.text.starts_with('~')
100        });
101    if simple {
102        let pipeline = shuma_line::split_pipeline(&tokens);
103        let mut stages = Vec::new();
104        for st in &pipeline.stages {
105            match &st.command {
106                Some(cmd) => stages.push(StageSpec {
107                    program: cmd.clone(),
108                    args: st.args.iter().map(|a| unquote(a)).collect(),
109                }),
110                // Una etapa sin comando (línea incompleta) → al shell.
111                None => return Exec::Shell { line: line.into(), program: "bash".into() },
112            }
113        }
114        if !stages.is_empty() {
115            return Exec::Direct { stages };
116        }
117    }
118    Exec::Shell { line: line.into(), program: "bash".into() }
119}
120
121// =====================================================================
122// Fuente de autocompletado.
123// =====================================================================
124
125struct ShellCompletionSource {
126    commands: Vec<String>,
127    cwd: String,
128}
129
130impl ShellCompletionSource {
131    fn scan(cwd: String) -> Self {
132        let mut commands = Vec::new();
133        if let Ok(path) = std::env::var("PATH") {
134            for dir in path.split(':') {
135                if let Ok(entries) = std::fs::read_dir(dir) {
136                    for e in entries.flatten() {
137                        if let Some(name) = e.file_name().to_str() {
138                            commands.push(name.to_string());
139                        }
140                    }
141                }
142            }
143        }
144        commands.sort();
145        commands.dedup();
146        Self { commands, cwd }
147    }
148}
149
150impl CompletionSource for ShellCompletionSource {
151    fn commands(&self) -> Vec<String> {
152        self.commands.clone()
153    }
154
155    fn paths(&self, prefix: &str) -> Vec<String> {
156        let (dir, partial) = match prefix.rfind('/') {
157            Some(i) => (&prefix[..=i], &prefix[i + 1..]),
158            None => ("", prefix),
159        };
160        // Una ruta relativa se resuelve contra el cwd de la sesión.
161        let base = if dir.starts_with('/') {
162            dir.to_string()
163        } else if dir.is_empty() {
164            self.cwd.clone()
165        } else {
166            format!("{}/{}", self.cwd, dir)
167        };
168        let mut out = Vec::new();
169        if let Ok(entries) = std::fs::read_dir(&base) {
170            for e in entries.flatten() {
171                if let Some(name) = e.file_name().to_str() {
172                    if name.starts_with(partial) {
173                        let is_dir = e.file_type().map(|t| t.is_dir()).unwrap_or(false);
174                        let slash = if is_dir { "/" } else { "" };
175                        out.push(format!("{dir}{name}{slash}"));
176                    }
177                }
178            }
179        }
180        out.sort();
181        out
182    }
183}
184
185// =====================================================================
186// CurveElement — la curva de un monitor.
187// =====================================================================
188
189struct CurveElement {
190    values: Vec<f32>,
191    color: Hsla,
192}
193
194impl CurveElement {
195    fn new(values: Vec<f32>, color: Hsla) -> Self {
196        Self { values, color }
197    }
198}
199
200impl IntoElement for CurveElement {
201    type Element = Self;
202    fn into_element(self) -> Self::Element {
203        self
204    }
205}
206
207impl Element for CurveElement {
208    type RequestLayoutState = ();
209    type PrepaintState = ();
210
211    fn id(&self) -> Option<ElementId> {
212        None
213    }
214    fn source_location(&self) -> Option<&'static panic::Location<'static>> {
215        None
216    }
217
218    fn request_layout(
219        &mut self,
220        _id: Option<&GlobalElementId>,
221        _inspector: Option<&InspectorElementId>,
222        window: &mut Window,
223        cx: &mut App,
224    ) -> (LayoutId, ()) {
225        let mut style = Style::default();
226        style.size.width = gpui::Length::Definite(gpui::DefiniteLength::Fraction(1.0));
227        style.size.height = gpui::Length::Definite(gpui::DefiniteLength::Fraction(1.0));
228        (window.request_layout(style, [], cx), ())
229    }
230
231    fn prepaint(
232        &mut self,
233        _id: Option<&GlobalElementId>,
234        _inspector: Option<&InspectorElementId>,
235        _bounds: Bounds<Pixels>,
236        _layout: &mut (),
237        _window: &mut Window,
238        _cx: &mut App,
239    ) {
240    }
241
242    fn paint(
243        &mut self,
244        _id: Option<&GlobalElementId>,
245        _inspector: Option<&InspectorElementId>,
246        bounds: Bounds<Pixels>,
247        _layout: &mut (),
248        _prepaint: &mut (),
249        window: &mut Window,
250        _cx: &mut App,
251    ) {
252        let n = self.values.len();
253        if n < 2 {
254            return;
255        }
256        let ox: f32 = bounds.origin.x.into();
257        let oy: f32 = bounds.origin.y.into();
258        let bw: f32 = bounds.size.width.into();
259        let bh: f32 = bounds.size.height.into();
260        let mut pb = PathBuilder::stroke(px(1.6));
261        for (i, v) in self.values.iter().enumerate() {
262            let x = ox + bw * (i as f32 / (n - 1) as f32);
263            let y = oy + bh - (v.clamp(0.0, 100.0) / 100.0) * bh;
264            let p = point(px(x), px(y));
265            if i == 0 {
266                pb.move_to(p);
267            } else {
268                pb.line_to(p);
269            }
270        }
271        if let Ok(path) = pb.build() {
272            window.paint_path(path, self.color);
273        }
274    }
275}
276
277// =====================================================================
278// El shell.
279// =====================================================================
280
281/// Qué panel lateral está redimensionando un drag activo.
282#[derive(Clone, Copy)]
283enum Side {
284    Left,
285    Right,
286}
287
288/// Estado de un arrastre de divisor en curso.
289struct Drag {
290    side: Side,
291    /// Posición X del cursor al iniciar el arrastre.
292    start_x: f32,
293    /// Ancho del panel al iniciar el arrastre.
294    start_w: f32,
295}
296
297/// Estado de presentación de la tarjeta de un comando.
298#[derive(Default, Clone, Copy)]
299struct RunUi {
300    /// Acordeón cerrado — sólo se ve la cabecera.
301    collapsed: bool,
302    /// El filtro muestra stderr en vez de stdout.
303    show_stderr: bool,
304    /// El usuario tocó el acordeón a mano — ya no se autocolapsa.
305    user_touched: bool,
306}
307
308struct Shell {
309    line: LineState,
310    /// La sesión de trabajo: cwd, historial y grupos.
311    session: WorkSession,
312    /// Comandos en curso, con su canal de salida.
313    active: Vec<(RunId, RunHandle)>,
314    completion: Option<shuma_line::Completion>,
315    completion_index: usize,
316    show_completion: bool,
317    source: ShellCompletionSource,
318    sampler: SystemSampler,
319    snapshot: Snapshot,
320    left_collapsed: bool,
321    right_collapsed: bool,
322    /// Anchos de los paneles laterales (los divisores los ajustan).
323    left_width: f32,
324    right_width: f32,
325    /// Arrastre de divisor en curso, si lo hay.
326    drag: Option<Drag>,
327    /// Estado de presentación por comando (acordeón, filtro stderr).
328    run_ui: HashMap<RunId, RunUi>,
329    /// Largo del historial en el último `:save` — define qué comandos
330    /// entran al próximo grupo guardado.
331    group_anchor: usize,
332    /// Patrones detectados por el motor de inferencia (cache).
333    patterns: Vec<shuma_infer::EmergingPattern>,
334    /// Si está activo, el próximo comando reprocesa la salida de este run.
335    reprocess_source: Option<RunId>,
336    /// Scroll del feed central — sigue al comando más reciente.
337    scroll: ScrollHandle,
338    focus: FocusHandle,
339    focused_once: bool,
340}
341
342impl Shell {
343    fn new(cx: &mut Context<Self>) -> Self {
344        let cwd = std::env::current_dir()
345            .map(|p| p.to_string_lossy().into_owned())
346            .unwrap_or_else(|_| "/".to_string());
347
348        let mut session = WorkSession::new("sesión", &cwd);
349        // Grupos de ejemplo — recetas reutilizables.
350        session.save_group("estado git", vec!["git status --short".into()]);
351        session.save_group("build", vec!["cargo build --release".into()]);
352
353        let shell = Self {
354            line: LineState::new(),
355            session,
356            active: Vec::new(),
357            completion: None,
358            completion_index: 0,
359            show_completion: false,
360            source: ShellCompletionSource::scan(cwd),
361            sampler: SystemSampler::new(HISTORY),
362            snapshot: Snapshot {
363                cpu_percent: 0.0,
364                mem_percent: 0.0,
365                mem_used_mb: 0,
366                mem_total_mb: 0,
367                valid: false,
368            },
369            left_collapsed: false,
370            right_collapsed: false,
371            left_width: 176.0,
372            right_width: 188.0,
373            drag: None,
374            run_ui: HashMap::new(),
375            group_anchor: 0,
376            patterns: Vec::new(),
377            reprocess_source: None,
378            scroll: ScrollHandle::new(),
379            focus: cx.focus_handle(),
380            focused_once: false,
381        };
382        shell.start_loop(cx);
383        shell
384    }
385
386    /// Bucle de fondo: drena la salida de los comandos (~9/s) y refresca
387    /// los monitores (~1/s).
388    fn start_loop(&self, cx: &mut Context<Self>) {
389        cx.spawn(async move |this, cx| {
390            let mut tick: u32 = 0;
391            loop {
392                cx.background_executor().timer(Duration::from_millis(110)).await;
393                tick += 1;
394                let sysmon = tick % 10 == 0;
395                let alive = this.update(cx, |shell, cx| {
396                    let mut changed = shell.drain_exec();
397                    if sysmon {
398                        shell.snapshot = shell.sampler.sample();
399                        changed = true;
400                    }
401                    if changed {
402                        cx.notify();
403                    }
404                });
405                if alive.is_err() {
406                    break;
407                }
408            }
409        })
410        .detach();
411    }
412
413    /// Vuelca la salida disponible de los comandos en curso al historial.
414    fn drain_exec(&mut self) -> bool {
415        let now = unix_now();
416        let mut changed = false;
417        for (id, handle) in &mut self.active {
418            for ev in handle.try_events() {
419                changed = true;
420                match ev {
421                    RunEvent::Stdout(l) => {
422                        self.session.append_output(*id, Stream::Stdout, l)
423                    }
424                    RunEvent::Stderr(l) => {
425                        self.session.append_output(*id, Stream::Stderr, l)
426                    }
427                    RunEvent::Truncated => self.session.mark_truncated(*id),
428                    RunEvent::Spilled(path) => {
429                        self.session.mark_truncated(*id);
430                        self.session.append_output(
431                            *id,
432                            Stream::Stdout,
433                            format!("↡ salida excedente volcada a {path}"),
434                        );
435                    }
436                    RunEvent::Exited(code) => self.session.finish_run(*id, code, now),
437                    RunEvent::Failed(msg) => {
438                        self.session.append_output(
439                            *id,
440                            Stream::Stderr,
441                            format!("✗ no se pudo lanzar: {msg}"),
442                        );
443                        self.session.finish_run(*id, -1, now);
444                    }
445                }
446            }
447        }
448        let finished = self.active.iter().any(|(_, h)| h.is_finished());
449        self.active.retain(|(_, h)| !h.is_finished());
450        if finished {
451            // Al cerrarse un comando, el motor de inferencia revisa si
452            // emergió un patrón repetido y lo promueve a un grupo.
453            self.infer_patterns();
454        }
455        if changed {
456            self.scroll.scroll_to_bottom();
457        }
458        changed
459    }
460
461    /// Comandos del historial reducidos a registros de inferencia.
462    fn infer_records(&self) -> Vec<shuma_infer::CommandRecord> {
463        self.session
464            .history()
465            .iter()
466            .map(|r| {
467                shuma_infer::CommandRecord::parse(&r.line, &r.cwd, r.status == RunStatus::Ok)
468            })
469            .collect()
470    }
471
472    /// Corre el motor de inferencia, cachea los patrones y promueve el
473    /// más fuerte a un grupo reutilizable (rehidratación).
474    fn infer_patterns(&mut self) {
475        let records = self.infer_records();
476        self.patterns =
477            shuma_infer::detect_patterns(&records, &shuma_infer::InferConfig::default());
478        if let Some(top) = self.patterns.first() {
479            let name = format!("✨ {}", top.suggested_name());
480            if self.session.group(&name).is_none() {
481                self.session.save_group(name, top.example.clone());
482            }
483        }
484    }
485
486    /// Condición de disparo de un patrón: los marcadores de proyecto
487    /// comunes a todos los directorios donde corrió.
488    fn pattern_trigger(&self, p: &shuma_infer::EmergingPattern) -> Vec<String> {
489        let mut dirs = p.directories.iter();
490        let Some(first) = dirs.next() else {
491            return Vec::new();
492        };
493        let mut common = markers_in(first);
494        for d in dirs {
495            let here = markers_in(d);
496            common.retain(|m| here.contains(m));
497        }
498        common
499    }
500
501    /// La secuencia que el motor predice como continuación, si la hay.
502    fn predicted_sequence(&self) -> Option<String> {
503        if self.patterns.is_empty() {
504            return None;
505        }
506        let records = self.infer_records();
507        let tail = &records[records.len().saturating_sub(6)..];
508        let (pi, next) = shuma_infer::predict_next(tail, &self.patterns)?;
509        if next.is_empty() {
510            return None;
511        }
512        // Disparo por estructura: no anticipar un patrón en un directorio
513        // que no comparte su forma (no sugerir `cargo` sin `Cargo.toml`).
514        let trigger = self.pattern_trigger(&self.patterns[pi]);
515        if !trigger.is_empty() {
516            let here = markers_in(self.session.cwd());
517            if !trigger.iter().all(|m| here.contains(m)) {
518                return None;
519            }
520        }
521        Some(next.join(" && "))
522    }
523
524    /// Calcula el sufijo fantasma del prompt: el resto de la línea que el
525    /// shell predice. Sólo con el cursor al final.
526    fn compute_ghost(&self) -> Option<String> {
527        let line = self.line.text();
528        if line.is_empty() || self.line.cursor() != line.len() {
529            return None;
530        }
531        // Corpus por prioridad: secuencia predicha, luego historial
532        // reciente.
533        let mut corpus: Vec<String> = Vec::new();
534        if let Some(seq) = self.predicted_sequence() {
535            corpus.push(seq);
536        }
537        for r in self.session.history().iter().rev() {
538            corpus.push(r.line.clone());
539        }
540        shuma_line::ghost_suggestion(line, &corpus)
541    }
542
543    /// Resuelve el destino de un `cd` contra el cwd de la sesión.
544    fn resolve_cd(&self, arg: &str) -> Result<String, String> {
545        let home = std::env::var("HOME").unwrap_or_else(|_| "/".into());
546        let target = if arg.is_empty() || arg == "~" {
547            home
548        } else if let Some(rest) = arg.strip_prefix("~/") {
549            format!("{home}/{rest}")
550        } else if arg.starts_with('/') {
551            arg.to_string()
552        } else {
553            format!("{}/{}", self.session.cwd(), arg)
554        };
555        match std::fs::canonicalize(&target) {
556            Ok(p) if p.is_dir() => Ok(p.to_string_lossy().into_owned()),
557            Ok(_) => Err(format!("cd: no es un directorio: {target}")),
558            Err(e) => Err(format!("cd: {target}: {e}")),
559        }
560    }
561
562    /// Ejecuta una línea: `cd` se maneja internamente (cambia el cwd y,
563    /// con él, el aislamiento); el resto se lanza con `shuma-exec` y su
564    /// salida se transmite al panel central.
565    /// Guarda como grupo los comandos ejecutados desde el último `:save`.
566    fn save_group(&mut self, name: &str) {
567        let name = name.trim();
568        if name.is_empty() {
569            return;
570        }
571        let n = self.session.history().len().saturating_sub(self.group_anchor);
572        if n > 0 {
573            self.session.save_recent_as_group(name, n);
574            self.group_anchor = self.session.history().len();
575        }
576    }
577
578    fn run_command(&mut self, line: String) {
579        let line = line.trim().to_string();
580        if line.is_empty() {
581            return;
582        }
583        let now = unix_now();
584
585        // Meta-comandos del shell — configuran la sesión, no se ejecutan.
586        if let Some(name) = line.strip_prefix(":save ") {
587            self.save_group(name);
588            return;
589        }
590        if let Some(arg) = line.strip_prefix(":limit ") {
591            // `:limit <MB>` — tope de captura de la sesión; 0 = sin tope.
592            if let Ok(mb) = arg.trim().parse::<usize>() {
593                self.session.set_capture_limit(mb * 1024 * 1024);
594            }
595            return;
596        }
597        if let Some(arg) = line.strip_prefix(":spill ") {
598            // `:spill on|off` — volcar a disco la salida excedente.
599            self.session
600                .set_spill(matches!(arg.trim(), "on" | "si" | "sí" | "1" | "true"));
601            return;
602        }
603        if let Some(args) = line.strip_prefix(":matilda ") {
604            // Herramienta matilda embebida — administración de servidores.
605            self.matilda_command(args);
606            return;
607        }
608
609        // Los comandos anteriores que el usuario no fijó se autocolapsan
610        // al aparecer uno nuevo abajo — orden de terminal tradicional.
611        for ui in self.run_ui.values_mut() {
612            if !ui.user_touched {
613                ui.collapsed = true;
614            }
615        }
616
617        // `cd` interno — un subproceso no podría cambiar nuestro cwd.
618        if line == "cd" || line.starts_with("cd ") {
619            let arg = line.strip_prefix("cd").unwrap_or("").trim();
620            let id = self.session.begin_run(&line, now);
621            self.run_ui.insert(id, RunUi::default());
622            match self.resolve_cd(arg) {
623                Ok(new_cwd) => {
624                    self.session.set_cwd(new_cwd.clone());
625                    self.source.cwd = new_cwd.clone();
626                    self.session
627                        .append_output(id, Stream::Stdout, format!("→ {new_cwd}"));
628                    self.session.finish_run(id, 0, now);
629                }
630                Err(e) => {
631                    self.session.append_output(id, Stream::Stderr, e);
632                    self.session.finish_run(id, 1, now);
633                }
634            }
635            self.scroll.scroll_to_bottom();
636            return;
637        }
638
639        let id = self.session.begin_run(&line, now);
640        self.run_ui.insert(id, RunUi::default());
641        let spec = self.build_spec(&line, None, id);
642        self.active.push((id, exec_run(&spec)));
643        self.scroll.scroll_to_bottom();
644    }
645
646    /// Arma la `CommandSpec` de una línea: decide directo vs shell y
647    /// aplica la política de captura de la sesión.
648    fn build_spec(&self, line: &str, stdin: Option<String>, run_id: RunId) -> CommandSpec {
649        self.build_spec_exec(plan_exec(line), stdin, run_id)
650    }
651
652    /// `build_spec` con el modo de ejecución ya decidido (lo usa la
653    /// herramienta matilda, que ejecuta un script de shell completo).
654    fn build_spec_exec(&self, exec: Exec, stdin: Option<String>, run_id: RunId) -> CommandSpec {
655        let policy = self.session.capture();
656        let spill_path = (policy.spill && policy.limit_bytes > 0).then(|| {
657            std::env::temp_dir()
658                .join(format!("shuma-spill-{}-{run_id}.log", std::process::id()))
659        });
660        CommandSpec {
661            exec,
662            cwd: self.session.cwd().to_string(),
663            capture_limit: policy.limit_bytes,
664            spill_path,
665            stdin_data: stdin,
666        }
667    }
668
669    /// Registra un comando "sintético" ya terminado — su salida la
670    /// produce el shell mismo, no un proceso (la usa `:matilda plan`).
671    fn synthetic_run(&mut self, label: &str, output: Vec<String>, ok: bool) {
672        for ui in self.run_ui.values_mut() {
673            if !ui.user_touched {
674                ui.collapsed = true;
675            }
676        }
677        let now = unix_now();
678        let id = self.session.begin_run(label, now);
679        self.run_ui.insert(id, RunUi::default());
680        for line in output {
681            self.session.append_output(id, Stream::Stdout, line);
682        }
683        self.session.finish_run(id, if ok { 0 } else { 1 }, now);
684        self.scroll.scroll_to_bottom();
685    }
686
687    /// Ejecuta `exec_line` mostrando `label` en la tarjeta — el comando
688    /// real puede diferir de lo que se ve (matilda corre un script).
689    fn spawn_labeled(&mut self, label: String, exec_line: String) {
690        for ui in self.run_ui.values_mut() {
691            if !ui.user_touched {
692                ui.collapsed = true;
693            }
694        }
695        let now = unix_now();
696        let id = self.session.begin_run(&label, now);
697        self.run_ui.insert(id, RunUi::default());
698        let exec = Exec::Shell { line: exec_line, program: "bash".into() };
699        let spec = self.build_spec_exec(exec, None, id);
700        self.active.push((id, exec_run(&spec)));
701        self.scroll.scroll_to_bottom();
702    }
703
704    /// Carga un inventario JSON, resolviendo la ruta contra el cwd.
705    fn load_inventory(&self, file: &str) -> Result<matilda_core::Inventory, String> {
706        let path = if file.starts_with('/') {
707            file.to_string()
708        } else {
709            format!("{}/{}", self.session.cwd(), file)
710        };
711        let text = std::fs::read_to_string(&path)
712            .map_err(|e| format!("no se pudo leer {path}: {e}"))?;
713        serde_json::from_str(&text).map_err(|e| format!("JSON inválido: {e}"))
714    }
715
716    /// La herramienta matilda, embebida: `:matilda plan|script|apply
717    /// <inventario.json>`. Reconcilia contra el estado real de la
718    /// máquina y vuelca el resultado al feed del shell.
719    fn matilda_command(&mut self, args: &str) {
720        let label = format!(":matilda {args}");
721        let parts: Vec<&str> = args.split_whitespace().collect();
722        let (sub, file) = match parts.as_slice() {
723            [s, f] => (*s, *f),
724            _ => {
725                self.synthetic_run(
726                    &label,
727                    vec!["uso: :matilda plan|script|apply <inventario.json>".into()],
728                    false,
729                );
730                return;
731            }
732        };
733        let desired = match self.load_inventory(file) {
734            Ok(d) => d,
735            Err(e) => {
736                self.synthetic_run(&label, vec![e], false);
737                return;
738            }
739        };
740        // Reconcilia contra el estado observado de esta máquina.
741        let current =
742            matilda_discover::observed_inventory(&matilda_discover::discover_local(), &desired);
743        let p = matilda_plan::plan(&current, &desired);
744
745        match sub {
746            "plan" => {
747                let lines: Vec<String> = if p.is_empty() {
748                    vec!["sin cambios: el servidor ya está al día".into()]
749                } else {
750                    p.actions
751                        .iter()
752                        .enumerate()
753                        .map(|(i, a)| format!("{:>2}. {}", i + 1, a.describe()))
754                        .collect()
755                };
756                self.synthetic_run(&label, lines, true);
757            }
758            "script" => {
759                let script = matilda_apply::steps_to_script(&matilda_apply::plan_to_steps(
760                    &p, &desired,
761                ));
762                self.synthetic_run(&label, script.lines().map(String::from).collect(), true);
763            }
764            "apply" => {
765                let steps = matilda_apply::plan_to_steps(&p, &desired);
766                if steps.is_empty() {
767                    self.synthetic_run(&label, vec!["sin cambios: nada que aplicar".into()], true);
768                } else {
769                    // El script se ejecuta de verdad — fluye al feed.
770                    self.spawn_labeled(label, matilda_apply::steps_to_script(&steps));
771                }
772            }
773            other => {
774                self.synthetic_run(&label, vec![format!("subcomando desconocido: {other}")], false)
775            }
776        }
777    }
778
779    /// Reprocesa la salida capturada del comando `source`: ejecuta `line`
780    /// alimentándole esa salida por stdin, sin volver a correr el
781    /// original. Así un resultado se filtra con distintas herramientas.
782    fn run_reprocess(&mut self, line: String, source: RunId) {
783        let line = line.trim().to_string();
784        if line.is_empty() {
785            return;
786        }
787        let data: String = self
788            .session
789            .run(source)
790            .map(|r| r.lines_of(Stream::Stdout).collect::<Vec<_>>().join("\n"))
791            .unwrap_or_default();
792        for ui in self.run_ui.values_mut() {
793            if !ui.user_touched {
794                ui.collapsed = true;
795            }
796        }
797        let now = unix_now();
798        let id = self.session.begin_run(&line, now);
799        self.run_ui.insert(id, RunUi::default());
800        let spec = self.build_spec(&line, Some(data), id);
801        self.active.push((id, exec_run(&spec)));
802        self.scroll.scroll_to_bottom();
803    }
804
805    /// Mata el proceso de un comando en curso.
806    fn kill_run(&self, id: RunId) {
807        if let Some((_, handle)) = self.active.iter().find(|(rid, _)| *rid == id) {
808            handle.kill();
809        }
810    }
811
812    fn refresh_completion(&mut self) {
813        let comp = self.line.complete(&self.source);
814        self.show_completion =
815            !comp.candidates.is_empty() && comp.replace_end > comp.replace_start;
816        self.completion_index = 0;
817        self.completion = Some(comp);
818    }
819
820    fn on_tab(&mut self) {
821        let comp = self.line.complete(&self.source);
822        if comp.candidates.is_empty() {
823            return;
824        }
825        if self.show_completion {
826            let idx = self.completion_index.min(comp.candidates.len() - 1);
827            let candidate = comp.candidates[idx].clone();
828            self.line.apply_completion(&comp, &candidate);
829            self.show_completion = false;
830            self.completion = None;
831        } else {
832            self.completion_index = 0;
833            self.completion = Some(comp);
834            self.show_completion = true;
835        }
836    }
837
838    fn cycle_completion(&mut self, delta: i32) {
839        if !self.show_completion {
840            return;
841        }
842        if let Some(comp) = &self.completion {
843            let n = comp.candidates.len();
844            if n > 0 {
845                let i = self.completion_index as i32 + delta;
846                self.completion_index = i.rem_euclid(n as i32) as usize;
847            }
848        }
849    }
850
851    /// Enter — ejecuta el contenido del input, o reprocesa una salida
852    /// previa si hay un origen de reproceso activo.
853    fn submit(&mut self) {
854        let line = self.line.text().to_string();
855        self.line.clear();
856        self.completion = None;
857        self.show_completion = false;
858        if let Some(source) = self.reprocess_source.take() {
859            self.run_reprocess(line, source);
860        } else {
861            self.run_command(line);
862        }
863    }
864
865    fn handle_key(&mut self, event: &KeyDownEvent, _w: &mut Window, cx: &mut Context<Self>) {
866        let ks = &event.keystroke;
867        let key = ks.key.as_str();
868        let ctrl = ks.modifiers.control;
869        let mut changed = false;
870
871        match key {
872            "enter" => {
873                self.submit();
874                cx.notify();
875                return;
876            }
877            "escape" => {
878                self.show_completion = false;
879                self.reprocess_source = None;
880                cx.notify();
881                return;
882            }
883            "tab" => {
884                self.on_tab();
885                cx.notify();
886                return;
887            }
888            "up" => {
889                self.cycle_completion(-1);
890                cx.notify();
891                return;
892            }
893            "down" => {
894                self.cycle_completion(1);
895                cx.notify();
896                return;
897            }
898            "backspace" => {
899                self.line.backspace();
900                changed = true;
901            }
902            "delete" => {
903                self.line.delete();
904                changed = true;
905            }
906            "left" => {
907                if ctrl {
908                    self.line.move_word_left();
909                } else {
910                    self.line.move_left();
911                }
912            }
913            "right" => {
914                if ctrl {
915                    self.line.move_word_right();
916                } else if self.line.cursor() == self.line.text().len() {
917                    // En el extremo, la flecha derecha acepta el fantasma.
918                    if let Some(g) = self.compute_ghost() {
919                        self.line.insert(&g);
920                        changed = true;
921                    }
922                } else {
923                    self.line.move_right();
924                }
925            }
926            "home" => self.line.move_home(),
927            "end" => self.line.move_end(),
928            "a" if ctrl => self.line.move_home(),
929            "e" if ctrl => self.line.move_end(),
930            "u" if ctrl => {
931                self.line.clear();
932                changed = true;
933            }
934            "space" if ctrl => {
935                // Ctrl+Space también acepta el fantasma predicho.
936                if let Some(g) = self.compute_ghost() {
937                    self.line.insert(&g);
938                    changed = true;
939                }
940            }
941            _ if fkey_index(key).is_some() => {
942                // F1..F8 ejecutan el grupo de esa posición en [RUN].
943                let idx = fkey_index(key).unwrap();
944                let joined =
945                    self.session.groups().get(idx).map(|g| g.lines.join(" && "));
946                if let Some(j) = joined {
947                    self.run_command(j);
948                }
949                cx.notify();
950                return;
951            }
952            _ => {
953                if !ctrl {
954                    if let Some(ch) = ks.key_char.as_deref() {
955                        if !ch.chars().any(|c| c.is_control()) {
956                            self.line.insert(ch);
957                            changed = true;
958                        }
959                    }
960                }
961            }
962        }
963
964        if changed {
965            self.refresh_completion();
966        }
967        cx.notify();
968    }
969}
970
971/// Color de resaltado de cada clase de token.
972fn token_color(kind: TokenKind, theme: &Theme) -> Hsla {
973    match kind {
974        TokenKind::Command => gpui::hsla(190.0 / 360.0, 0.65, 0.62, 1.0),
975        TokenKind::Argument => theme.fg_text,
976        TokenKind::Flag => gpui::hsla(38.0 / 360.0, 0.80, 0.62, 1.0),
977        TokenKind::StringLit => gpui::hsla(95.0 / 360.0, 0.42, 0.60, 1.0),
978        TokenKind::Variable => gpui::hsla(280.0 / 360.0, 0.55, 0.72, 1.0),
979        TokenKind::Pipe => gpui::hsla(190.0 / 360.0, 0.90, 0.72, 1.0),
980        TokenKind::Redirect => gpui::hsla(20.0 / 360.0, 0.78, 0.62, 1.0),
981        TokenKind::Operator => gpui::hsla(0.0, 0.66, 0.66, 1.0),
982        TokenKind::Comment => theme.fg_muted,
983        TokenKind::Whitespace => theme.fg_text,
984        TokenKind::Unknown => gpui::hsla(0.0, 0.70, 0.60, 1.0),
985    }
986}
987
988/// Resalta un texto estático (la línea de un comando del historial).
989fn highlight(text: &str, theme: &Theme) -> Vec<gpui::Div> {
990    shuma_line::tokenize(text, shuma_line::Dialect::Bash)
991        .into_iter()
992        .map(|t| {
993            div()
994                .flex_none()
995                .text_color(token_color(t.kind, theme))
996                .child(t.text)
997        })
998        .collect()
999}
1000
1001/// Acorta el cwd: el `$HOME` se muestra como `~`.
1002fn pretty_cwd(cwd: &str) -> String {
1003    match std::env::var("HOME") {
1004        Ok(home) if cwd == home => "~".to_string(),
1005        Ok(home) if cwd.starts_with(&format!("{home}/")) => format!("~{}", &cwd[home.len()..]),
1006        _ => cwd.to_string(),
1007    }
1008}
1009
1010/// Renderiza la tarjeta de un comando ejecutado: cabecera-acordeón +
1011/// filtro stdout/stderr + cuerpo de salida.
1012fn render_run(
1013    r: &CommandRun,
1014    ui: RunUi,
1015    theme: &Theme,
1016    node_bg: Hsla,
1017    cx: &mut Context<Shell>,
1018) -> impl IntoElement {
1019    let id = r.id;
1020    let dim = theme.fg_muted;
1021    let (glyph, gcolor) = match r.status {
1022        RunStatus::Running => ("▷", gpui::hsla(45.0 / 360.0, 0.75, 0.60, 1.0)),
1023        RunStatus::Ok => ("✓", gpui::hsla(140.0 / 360.0, 0.48, 0.55, 1.0)),
1024        RunStatus::Failed => ("✗", gpui::hsla(2.0 / 360.0, 0.68, 0.60, 1.0)),
1025    };
1026    let stderr_color = gpui::hsla(8.0 / 360.0, 0.62, 0.66, 1.0);
1027    let accent = gpui::hsla(190.0 / 360.0, 0.60, 0.62, 1.0);
1028
1029    // Nota a la derecha: salida no-cero, truncado, y conteo si colapsada.
1030    let mut parts: Vec<String> = Vec::new();
1031    if let Some(c) = r.exit_code {
1032        if c != 0 {
1033            parts.push(format!("salió {c}"));
1034        }
1035    }
1036    if r.truncated {
1037        parts.push("⚠ truncado".to_string());
1038    }
1039    if ui.collapsed {
1040        let n = r.count_of(Stream::Stdout);
1041        if n > 0 {
1042            parts.push(format!("{n} líneas"));
1043        }
1044    }
1045    let note = parts.join(" · ");
1046
1047    // Cabecera-acordeón: un clic colapsa/expande.
1048    let caret = if ui.collapsed { "▸" } else { "▾" };
1049    let header_left = div()
1050        .id(SharedString::from(format!("hdr-{id}")))
1051        .flex()
1052        .flex_row()
1053        .items_center()
1054        .gap(px(6.))
1055        .flex_1()
1056        .cursor_pointer()
1057        .child(div().flex_none().text_color(dim).child(caret))
1058        .child(div().flex_none().text_color(gcolor).child(glyph))
1059        .children(highlight(&r.line, theme))
1060        .child(
1061            div()
1062                .flex_none()
1063                .text_size(px(11.))
1064                .text_color(dim)
1065                .child(SharedString::from(note)),
1066        )
1067        .on_click(cx.listener(move |shell, _, _, cx| {
1068            let e = shell.run_ui.entry(id).or_default();
1069            e.collapsed = !e.collapsed;
1070            e.user_touched = true;
1071            cx.notify();
1072        }));
1073
1074    // Filtro de stderr — sólo aparece si el comando emitió errores.
1075    let stderr_chip = if r.has_stderr() {
1076        let n = r.count_of(Stream::Stderr);
1077        Some(
1078            div()
1079                .id(SharedString::from(format!("err-{id}")))
1080                .flex_none()
1081                .px(px(6.))
1082                .py(px(1.))
1083                .rounded(px(3.))
1084                .text_size(px(11.))
1085                .cursor_pointer()
1086                .when(ui.show_stderr, |d| {
1087                    d.bg(stderr_color).text_color(gpui::hsla(0.0, 0.0, 0.12, 1.0))
1088                })
1089                .when(!ui.show_stderr, |d| d.text_color(stderr_color))
1090                .child(SharedString::from(format!("⚠ {n}")))
1091                .on_click(cx.listener(move |shell, _, _, cx| {
1092                    let e = shell.run_ui.entry(id).or_default();
1093                    e.show_stderr = !e.show_stderr;
1094                    cx.notify();
1095                })),
1096        )
1097    } else {
1098        None
1099    };
1100
1101    // Botón de matar — sólo mientras el comando sigue corriendo.
1102    let kill_chip = if r.status == RunStatus::Running {
1103        Some(
1104            div()
1105                .id(SharedString::from(format!("kill-{id}")))
1106                .flex_none()
1107                .px(px(6.))
1108                .py(px(1.))
1109                .rounded(px(3.))
1110                .text_size(px(11.))
1111                .text_color(gpui::hsla(2.0 / 360.0, 0.66, 0.64, 1.0))
1112                .cursor_pointer()
1113                .hover(|s| s.bg(gpui::hsla(2.0 / 360.0, 0.55, 0.28, 1.0)))
1114                .child("✕ matar")
1115                .on_click(cx.listener(move |shell, _, _, cx| {
1116                    shell.kill_run(id);
1117                    cx.notify();
1118                })),
1119        )
1120    } else {
1121        None
1122    };
1123
1124    // Reprocesar — sólo si el comando dejó algo en stdout que filtrar.
1125    let reprocess_chip = if r.count_of(Stream::Stdout) > 0 {
1126        Some(
1127            div()
1128                .id(SharedString::from(format!("repro-{id}")))
1129                .flex_none()
1130                .px(px(6.))
1131                .py(px(1.))
1132                .rounded(px(3.))
1133                .text_size(px(11.))
1134                .text_color(accent)
1135                .cursor_pointer()
1136                .hover(|s| s.text_color(gpui::hsla(0.0, 0.0, 0.95, 1.0)))
1137                .child("⤳ reprocesar")
1138                .on_click(cx.listener(move |shell, _, _, cx| {
1139                    shell.reprocess_source = Some(id);
1140                    cx.notify();
1141                })),
1142        )
1143    } else {
1144        None
1145    };
1146
1147    let header = div()
1148        .flex()
1149        .flex_row()
1150        .items_center()
1151        .gap(px(6.))
1152        .child(header_left)
1153        .children(stderr_chip)
1154        .children(reprocess_chip)
1155        .children(kill_chip);
1156
1157    // Cuerpo: sólo con el acordeón abierto. El filtro elige el flujo.
1158    let mut body: Vec<gpui::Div> = Vec::new();
1159    if !ui.collapsed {
1160        // Etapas del pipe: un clic re-ejecuta la línea hasta esa etapa,
1161        // como un comando nuevo — así se inspeccionan los intermedios.
1162        if r.line.contains('|') {
1163            let toks = shuma_line::tokenize(&r.line, shuma_line::Dialect::Bash);
1164            let pipe = shuma_line::split_pipeline(&toks);
1165            if pipe.stages.len() >= 2 {
1166                let chip_bg = gpui::hsla(220.0 / 360.0, 0.18, 0.24, 1.0);
1167                let accent = gpui::hsla(190.0 / 360.0, 0.62, 0.62, 1.0);
1168                let mut chips: Vec<gpui::AnyElement> = vec![div()
1169                    .flex_none()
1170                    .text_size(px(10.))
1171                    .text_color(dim)
1172                    .child("⇢ etapas")
1173                    .into_any_element()];
1174                for (i, st) in pipe.stages.iter().enumerate() {
1175                    let end = st.tokens.last().map(|t| t.end).unwrap_or(r.line.len());
1176                    let prefix = r.line[..end].trim().to_string();
1177                    let name =
1178                        st.command.clone().unwrap_or_else(|| format!("{}", i + 1));
1179                    chips.push(
1180                        div()
1181                            .id(SharedString::from(format!("stage-{id}-{i}")))
1182                            .flex_none()
1183                            .px(px(6.))
1184                            .py(px(1.))
1185                            .rounded(px(3.))
1186                            .bg(chip_bg)
1187                            .text_size(px(11.))
1188                            .text_color(accent)
1189                            .cursor_pointer()
1190                            .hover(|s| s.text_color(gpui::hsla(0.0, 0.0, 0.95, 1.0)))
1191                            .child(SharedString::from(name))
1192                            .on_click(cx.listener(move |shell, _, _, cx| {
1193                                shell.run_command(prefix.clone());
1194                                cx.notify();
1195                            }))
1196                            .into_any_element(),
1197                    );
1198                }
1199                body.push(
1200                    div()
1201                        .flex()
1202                        .flex_row()
1203                        .flex_wrap()
1204                        .gap(px(4.))
1205                        .items_center()
1206                        .children(chips),
1207                );
1208            }
1209        }
1210
1211        let stream = if ui.show_stderr { Stream::Stderr } else { Stream::Stdout };
1212        let lines: Vec<&str> = r.lines_of(stream).collect();
1213        let color = if ui.show_stderr { stderr_color } else { theme.fg_text };
1214        if lines.is_empty() {
1215            body.push(div().text_size(px(11.)).text_color(dim).child(
1216                if ui.show_stderr { "sin errores" } else { "sin salida" },
1217            ));
1218        } else {
1219            // Sin truncar: si hay contenido, se muestra entero.
1220            for l in &lines {
1221                body.push(
1222                    div()
1223                        .text_size(px(12.))
1224                        .text_color(color)
1225                        .child(SharedString::from(l.to_string())),
1226                );
1227            }
1228        }
1229    }
1230
1231    div()
1232        .flex()
1233        .flex_col()
1234        .gap(px(3.))
1235        .p(px(8.))
1236        .bg(node_bg)
1237        .border_l_2()
1238        .border_color(gcolor)
1239        .rounded(px(5.))
1240        .child(header)
1241        .children(body)
1242}
1243
1244impl Render for Shell {
1245    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1246        if !self.focused_once {
1247            window.focus(&self.focus);
1248            self.focused_once = true;
1249        }
1250        let theme = Theme::global(cx).clone();
1251        let bg = theme.bg_app.clone();
1252        let panel = gpui::hsla(220.0 / 360.0, 0.16, 0.11, 1.0);
1253        let node_bg = gpui::hsla(220.0 / 360.0, 0.14, 0.16, 1.0);
1254        let accent = gpui::hsla(190.0 / 360.0, 0.70, 0.62, 1.0);
1255        let text = theme.fg_text;
1256        let dim = theme.fg_muted;
1257
1258        let pipeline = self.line.pipeline();
1259        let piped = pipeline.stages.iter().filter(|s| s.command.is_some()).count();
1260
1261        // --- Barra de estado: cwd + identificador de aislamiento ---
1262        let status = div()
1263            .h(px(32.))
1264            .flex()
1265            .flex_row()
1266            .items_center()
1267            .justify_between()
1268            .px(px(14.))
1269            .bg(panel)
1270            .text_color(text)
1271            .child(
1272                div()
1273                    .flex()
1274                    .flex_row()
1275                    .gap(px(10.))
1276                    .items_baseline()
1277                    .child("● shuma")
1278                    .child(div().text_color(accent).child(SharedString::from(format!(
1279                        "📁 {}",
1280                        pretty_cwd(self.session.cwd())
1281                    ))))
1282                    .child(div().text_color(dim).text_size(px(11.)).child(SharedString::from(
1283                        format!("aisl:{}", self.session.isolation_id()),
1284                    ))),
1285            )
1286            .child(
1287                div().text_color(dim).text_size(px(12.)).child(SharedString::from({
1288                    // Política de captura de la sesión: tope + volcado.
1289                    let pol = self.session.capture();
1290                    let cap = if pol.limit_bytes == 0 {
1291                        "cap ∞".to_string()
1292                    } else {
1293                        format!(
1294                            "cap {}M{}",
1295                            pol.limit_bytes / (1024 * 1024),
1296                            if pol.spill { "↡" } else { "" }
1297                        )
1298                    };
1299                    let running = if piped > 1 {
1300                        format!("⇄ {piped} etapas · {} en curso", self.active.len())
1301                    } else {
1302                        format!("{} en curso", self.active.len())
1303                    };
1304                    format!("{cap}  ·  {running}")
1305                })),
1306            );
1307
1308        // --- Panel izquierdo: grupos reutilizables [RUN] ---
1309        let left = if self.left_collapsed {
1310            div()
1311                .id("expand-left")
1312                .w(px(26.))
1313                .flex()
1314                .flex_col()
1315                .items_center()
1316                .pt(px(8.))
1317                .bg(panel)
1318                .text_color(dim)
1319                .cursor_pointer()
1320                .hover(|s| s.bg(node_bg))
1321                .child("»")
1322                .on_click(cx.listener(|s, _, _, cx| {
1323                    s.left_collapsed = false;
1324                    cx.notify();
1325                }))
1326        } else {
1327            let groups: Vec<_> = self
1328                .session
1329                .groups()
1330                .iter()
1331                .enumerate()
1332                .map(|(idx, g)| {
1333                    let joined = g.lines.join(" && ");
1334                    let count = g.lines.len();
1335                    // Los 8 primeros grupos llevan atajo dinámico F1..F8.
1336                    let label = if idx < 8 {
1337                        format!("F{}  ▸ {}  ·{count}", idx + 1, g.name)
1338                    } else {
1339                        format!("▸ {}  ·{count}", g.name)
1340                    };
1341                    div()
1342                        .id(SharedString::from(format!("group-{}", g.name)))
1343                        .px(px(8.))
1344                        .py(px(6.))
1345                        .bg(node_bg)
1346                        .rounded(px(4.))
1347                        .text_color(text)
1348                        .text_size(px(13.))
1349                        .cursor_pointer()
1350                        .hover(|s| s.bg(theme.bg_row_hover))
1351                        .child(SharedString::from(label))
1352                        .on_click(cx.listener(move |shell, _, _, cx| {
1353                            shell.run_command(joined.clone());
1354                            cx.notify();
1355                        }))
1356                })
1357                .collect();
1358            div()
1359                .id("run-panel")
1360                .w(px(self.left_width))
1361                .flex()
1362                .flex_col()
1363                .gap(px(6.))
1364                .p(px(10.))
1365                .bg(panel)
1366                .child(
1367                    div()
1368                        .flex()
1369                        .flex_row()
1370                        .justify_between()
1371                        .items_center()
1372                        .child(div().text_color(dim).text_size(px(12.)).child("[RUN] grupos"))
1373                        .child(
1374                            div()
1375                                .flex()
1376                                .flex_row()
1377                                .gap(px(4.))
1378                                .items_center()
1379                                .child(
1380                                    div()
1381                                        .id("save-group")
1382                                        .px(px(5.))
1383                                        .text_color(dim)
1384                                        .cursor_pointer()
1385                                        .hover(|s| s.text_color(accent))
1386                                        .child("+")
1387                                        .on_click(cx.listener(|s, _, _, cx| {
1388                                            s.line.set_text(":save ");
1389                                            cx.notify();
1390                                        })),
1391                                )
1392                                .child(
1393                                    div()
1394                                        .id("collapse-left")
1395                                        .px(px(5.))
1396                                        .text_color(dim)
1397                                        .cursor_pointer()
1398                                        .hover(|s| s.text_color(accent))
1399                                        .child("«")
1400                                        .on_click(cx.listener(|s, _, _, cx| {
1401                                            s.left_collapsed = true;
1402                                            cx.notify();
1403                                        })),
1404                                ),
1405                        ),
1406                )
1407                .children(groups)
1408                .child(
1409                    div()
1410                        .text_size(px(10.))
1411                        .text_color(dim)
1412                        .child("clic ejecuta · + guarda lo último"),
1413                )
1414                .child(div().h(px(1.)).bg(theme.border))
1415                .child(div().text_color(dim).text_size(px(12.)).child("[tools]"))
1416                .child(
1417                    div()
1418                        .id("tool-matilda")
1419                        .px(px(8.))
1420                        .py(px(6.))
1421                        .bg(node_bg)
1422                        .rounded(px(4.))
1423                        .text_color(text)
1424                        .text_size(px(13.))
1425                        .cursor_pointer()
1426                        .hover(|s| s.bg(theme.bg_row_hover))
1427                        .child("⚙ matilda")
1428                        .on_click(cx.listener(|shell, _, _, cx| {
1429                            // Precarga el comando para que el usuario nombre el inventario.
1430                            shell.line.set_text(":matilda plan ");
1431                            cx.notify();
1432                        })),
1433                )
1434        };
1435
1436        // --- Lienzo central: comandos ejecutados + su salida ---
1437        // Orden de terminal: los más viejos arriba, los nuevos abajo.
1438        let hist = self.session.history();
1439        let start = hist.len().saturating_sub(40);
1440        let runs: Vec<_> = hist[start..]
1441            .iter()
1442            .map(|r| {
1443                let ui = self.run_ui.get(&r.id).copied().unwrap_or_default();
1444                render_run(r, ui, &theme, node_bg, cx)
1445            })
1446            .collect();
1447        let runs_empty = runs.is_empty();
1448        let canvas = div()
1449            .id("runs")
1450            .flex_1()
1451            .overflow_y_scroll()
1452            .track_scroll(&self.scroll)
1453            .flex()
1454            .flex_col()
1455            .gap(px(8.))
1456            .p(px(10.))
1457            .bg(bg.clone())
1458            .when(runs_empty, |d| {
1459                d.child(div().text_color(dim).child(
1460                    "Escribe un comando abajo y presiona Enter — su salida aparece aquí.",
1461                ))
1462            })
1463            .children(runs);
1464
1465        // --- Panel derecho: monitores [SENS] ---
1466        let right = if self.right_collapsed {
1467            div()
1468                .id("expand-right")
1469                .w(px(26.))
1470                .flex()
1471                .flex_col()
1472                .items_center()
1473                .pt(px(8.))
1474                .bg(panel)
1475                .text_color(dim)
1476                .cursor_pointer()
1477                .hover(|s| s.bg(node_bg))
1478                .child("«")
1479                .on_click(cx.listener(|s, _, _, cx| {
1480                    s.right_collapsed = false;
1481                    cx.notify();
1482                }))
1483        } else {
1484            let cpu = self.snapshot.cpu_percent;
1485            let cpu_curve = self.sampler.cpu_history().values();
1486            let mem_curve = self.sampler.mem_history().values();
1487            let cpu_color = gpui::hsla(190.0 / 360.0, 0.72, 0.62, 1.0);
1488            let mem_color = gpui::hsla(265.0 / 360.0, 0.55, 0.70, 1.0);
1489
1490            let monitor = |title: &str, value: String, curve: Vec<f32>, color: Hsla| {
1491                div()
1492                    .flex()
1493                    .flex_col()
1494                    .gap(px(4.))
1495                    .p(px(8.))
1496                    .bg(node_bg)
1497                    .rounded(px(5.))
1498                    .child(
1499                        div()
1500                            .flex()
1501                            .flex_row()
1502                            .justify_between()
1503                            .items_baseline()
1504                            .child(div().text_color(dim).text_size(px(11.)).child(title.to_string()))
1505                            .child(div().text_color(color).child(SharedString::from(value))),
1506                    )
1507                    .child(div().h(px(44.)).child(CurveElement::new(curve, color)))
1508            };
1509
1510            div()
1511                .id("sens-panel")
1512                .w(px(self.right_width))
1513                .flex()
1514                .flex_col()
1515                .gap(px(10.))
1516                .p(px(10.))
1517                .bg(panel)
1518                .text_color(text)
1519                .child(
1520                    div()
1521                        .flex()
1522                        .flex_row()
1523                        .justify_between()
1524                        .items_center()
1525                        .child(div().text_color(dim).text_size(px(12.)).child("[SENS]"))
1526                        .child(
1527                            div()
1528                                .id("collapse-right")
1529                                .px(px(5.))
1530                                .text_color(dim)
1531                                .cursor_pointer()
1532                                .hover(|s| s.text_color(accent))
1533                                .child("»")
1534                                .on_click(cx.listener(|s, _, _, cx| {
1535                                    s.right_collapsed = true;
1536                                    cx.notify();
1537                                })),
1538                        ),
1539                )
1540                .child(monitor("CPU", format!("{cpu:.0} %"), cpu_curve, cpu_color))
1541                .child(monitor(
1542                    "MEM",
1543                    if self.snapshot.valid {
1544                        format!(
1545                            "{:.1}/{:.0} GB",
1546                            self.snapshot.mem_used_mb as f32 / 1024.0,
1547                            self.snapshot.mem_total_mb as f32 / 1024.0
1548                        )
1549                    } else {
1550                        "— GB".to_string()
1551                    },
1552                    mem_curve,
1553                    mem_color,
1554                ))
1555        };
1556
1557        // --- Zona prompt: el input inteligente ---
1558        let mut input_row: Vec<gpui::Div> = vec![div().flex_none().text_color(accent).child("›  ")];
1559        let cursor = self.line.cursor();
1560        let tokens = self.line.tokens();
1561        let caret = || div().w(px(2.)).h(px(19.)).bg(accent);
1562        if tokens.is_empty() {
1563            input_row.push(caret());
1564            input_row.push(
1565                div()
1566                    .text_color(dim)
1567                    .child("escribe un comando…  (Tab autocompleta · Enter ejecuta)"),
1568            );
1569        } else {
1570            let mut caret_done = false;
1571            for t in &tokens {
1572                let color = token_color(t.kind, &theme);
1573                if !caret_done && cursor >= t.start && cursor < t.end {
1574                    let local = cursor - t.start;
1575                    let (left_s, right_s) = t.text.split_at(local);
1576                    if !left_s.is_empty() {
1577                        input_row
1578                            .push(div().flex_none().text_color(color).child(left_s.to_string()));
1579                    }
1580                    input_row.push(caret());
1581                    input_row
1582                        .push(div().flex_none().text_color(color).child(right_s.to_string()));
1583                    caret_done = true;
1584                } else {
1585                    input_row.push(div().flex_none().text_color(color).child(t.text.clone()));
1586                }
1587            }
1588            if !caret_done {
1589                input_row.push(caret());
1590            }
1591        }
1592        // Sugerencia fantasma — el resto que el shell predice, en gris.
1593        if let Some(ghost) = self.compute_ghost() {
1594            input_row.push(
1595                div()
1596                    .flex_none()
1597                    .text_color(theme.fg_disabled)
1598                    .child(SharedString::from(ghost)),
1599            );
1600        }
1601        let input_bar = div()
1602            .h(px(46.))
1603            .flex()
1604            .flex_row()
1605            .items_center()
1606            .px(px(14.))
1607            .text_color(text)
1608            .text_size(px(14.))
1609            .children(input_row);
1610        // Banner del modo reproceso — escribí un filtro para la salida.
1611        let banner = self.reprocess_source.map(|src| {
1612            div()
1613                .px(px(14.))
1614                .py(px(3.))
1615                .bg(gpui::hsla(190.0 / 360.0, 0.30, 0.22, 1.0))
1616                .text_size(px(11.))
1617                .text_color(accent)
1618                .child(SharedString::from(format!(
1619                    "⤳ reprocesando la salida de #{src} — escribí un filtro · Esc cancela"
1620                )))
1621        });
1622        let prompt = div()
1623            .flex()
1624            .flex_col()
1625            .bg(panel)
1626            .children(banner)
1627            .child(input_bar);
1628
1629        // --- Popup de autocompletado ---
1630        let mut popup_layer: Vec<gpui::Div> = Vec::new();
1631        if self.show_completion {
1632            if let Some(comp) = &self.completion {
1633                if !comp.candidates.is_empty() {
1634                    let kind_label = match comp.kind {
1635                        CompletionKind::Command => "comando",
1636                        CompletionKind::Flag => "flag",
1637                        CompletionKind::Path => "ruta",
1638                    };
1639                    let total = comp.candidates.len();
1640                    let start = self
1641                        .completion_index
1642                        .saturating_sub(3)
1643                        .min(total.saturating_sub(8));
1644                    let rows: Vec<_> = comp
1645                        .candidates
1646                        .iter()
1647                        .enumerate()
1648                        .skip(start)
1649                        .take(8)
1650                        .map(|(i, cand)| {
1651                            let selected = i == self.completion_index;
1652                            div()
1653                                .px(px(8.))
1654                                .py(px(3.))
1655                                .when(selected, |d| {
1656                                    d.bg(accent).text_color(gpui::hsla(0.0, 0.0, 0.1, 1.0))
1657                                })
1658                                .when(!selected, |d| d.text_color(text))
1659                                .child(SharedString::from(cand.clone()))
1660                        })
1661                        .collect();
1662                    popup_layer.push(
1663                        div()
1664                            .absolute()
1665                            .left(px(28.))
1666                            .bottom(px(52.))
1667                            .w(px(320.))
1668                            .flex()
1669                            .flex_col()
1670                            .bg(node_bg)
1671                            .border_1()
1672                            .border_color(accent)
1673                            .rounded(px(5.))
1674                            .text_size(px(13.))
1675                            .child(
1676                                div()
1677                                    .px(px(8.))
1678                                    .py(px(3.))
1679                                    .text_color(dim)
1680                                    .text_size(px(11.))
1681                                    .child(SharedString::from(format!(
1682                                        "{kind_label} · {total} · ↑↓ Tab"
1683                                    ))),
1684                            )
1685                            .children(rows),
1686                    );
1687                }
1688            }
1689        }
1690
1691        // --- Divisores arrastrables ---
1692        let divider = |side: Side, cx: &mut Context<Self>| {
1693            div()
1694                .w(px(5.))
1695                .bg(node_bg)
1696                .cursor(CursorStyle::ResizeLeftRight)
1697                .hover(|s| s.bg(accent))
1698                .on_mouse_down(
1699                    MouseButton::Left,
1700                    cx.listener(move |shell, ev: &MouseDownEvent, _w, cx| {
1701                        let start_w = match side {
1702                            Side::Left => shell.left_width,
1703                            Side::Right => shell.right_width,
1704                        };
1705                        shell.drag = Some(Drag {
1706                            side,
1707                            start_x: ev.position.x.into(),
1708                            start_w,
1709                        });
1710                        cx.notify();
1711                    }),
1712                )
1713        };
1714
1715        let mut middle = div().flex().flex_row().flex_1().overflow_hidden().child(left);
1716        if !self.left_collapsed {
1717            middle = middle.child(divider(Side::Left, cx));
1718        }
1719        middle = middle.child(canvas);
1720        if !self.right_collapsed {
1721            middle = middle.child(divider(Side::Right, cx));
1722        }
1723        middle = middle.child(right);
1724
1725        // --- Composición ---
1726        div()
1727            .size_full()
1728            .relative()
1729            .flex()
1730            .flex_col()
1731            .bg(bg)
1732            .track_focus(&self.focus)
1733            .key_context("ShumaShell")
1734            .on_key_down(cx.listener(Self::handle_key))
1735            .on_mouse_move(cx.listener(|shell, ev: &MouseMoveEvent, _w, cx| {
1736                if let Some(drag) = &shell.drag {
1737                    let cur: f32 = ev.position.x.into();
1738                    let delta = cur - drag.start_x;
1739                    match drag.side {
1740                        // El panel izquierdo crece al arrastrar a la derecha.
1741                        Side::Left => {
1742                            shell.left_width = (drag.start_w + delta).clamp(130.0, 420.0)
1743                        }
1744                        // El derecho crece al arrastrar a la izquierda.
1745                        Side::Right => {
1746                            shell.right_width = (drag.start_w - delta).clamp(130.0, 420.0)
1747                        }
1748                    }
1749                    cx.notify();
1750                }
1751            }))
1752            .on_mouse_up(
1753                MouseButton::Left,
1754                cx.listener(|shell, _ev: &MouseUpEvent, _w, cx| {
1755                    if shell.drag.take().is_some() {
1756                        cx.notify();
1757                    }
1758                }),
1759            )
1760            .child(status)
1761            .child(middle)
1762            .child(prompt)
1763            .children(popup_layer)
1764    }
1765}
1766
1767impl Drop for Shell {
1768    /// Al cerrar la sesión, limpia sus archivos de volcado temporales.
1769    fn drop(&mut self) {
1770        let prefix = format!("shuma-spill-{}-", std::process::id());
1771        if let Ok(entries) = std::fs::read_dir(std::env::temp_dir()) {
1772            for e in entries.flatten() {
1773                if e.file_name().to_string_lossy().starts_with(&prefix) {
1774                    let _ = std::fs::remove_file(e.path());
1775                }
1776            }
1777        }
1778    }
1779}
1780
1781fn main() {
1782    launch_app("brahman · shuma shell", (1100., 700.), Shell::new);
1783}