gioser

tawasuyu/brahman

Rust 476TOML 190Markdown 50JSON 176602 Definiciones

⎇ 22a0ae8c58014af5cb12dbdd13408f9cd2745e31 crates/apps/shuma-shell/src/main.rs Rust · 1642 líneas · 59.0 KB Historia Quién tocó qué crudo

feat(shuma-exec): spill con splice (copia cero) + limpieza de temporales 22a0ae8c5 · 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
604        // Los comandos anteriores que el usuario no fijó se autocolapsan
605        // al aparecer uno nuevo abajo — orden de terminal tradicional.
606        for ui in self.run_ui.values_mut() {
607            if !ui.user_touched {
608                ui.collapsed = true;
609            }
610        }
611
612        // `cd` interno — un subproceso no podría cambiar nuestro cwd.
613        if line == "cd" || line.starts_with("cd ") {
614            let arg = line.strip_prefix("cd").unwrap_or("").trim();
615            let id = self.session.begin_run(&line, now);
616            self.run_ui.insert(id, RunUi::default());
617            match self.resolve_cd(arg) {
618                Ok(new_cwd) => {
619                    self.session.set_cwd(new_cwd.clone());
620                    self.source.cwd = new_cwd.clone();
621                    self.session
622                        .append_output(id, Stream::Stdout, format!("→ {new_cwd}"));
623                    self.session.finish_run(id, 0, now);
624                }
625                Err(e) => {
626                    self.session.append_output(id, Stream::Stderr, e);
627                    self.session.finish_run(id, 1, now);
628                }
629            }
630            self.scroll.scroll_to_bottom();
631            return;
632        }
633
634        let id = self.session.begin_run(&line, now);
635        self.run_ui.insert(id, RunUi::default());
636        let spec = self.build_spec(&line, None, id);
637        self.active.push((id, exec_run(&spec)));
638        self.scroll.scroll_to_bottom();
639    }
640
641    /// Arma la `CommandSpec` de una línea: decide directo vs shell y
642    /// aplica la política de captura de la sesión.
643    fn build_spec(&self, line: &str, stdin: Option<String>, run_id: RunId) -> CommandSpec {
644        let policy = self.session.capture();
645        let spill_path = (policy.spill && policy.limit_bytes > 0).then(|| {
646            std::env::temp_dir()
647                .join(format!("shuma-spill-{}-{run_id}.log", std::process::id()))
648        });
649        CommandSpec {
650            exec: plan_exec(line),
651            cwd: self.session.cwd().to_string(),
652            capture_limit: policy.limit_bytes,
653            spill_path,
654            stdin_data: stdin,
655        }
656    }
657
658    /// Reprocesa la salida capturada del comando `source`: ejecuta `line`
659    /// alimentándole esa salida por stdin, sin volver a correr el
660    /// original. Así un resultado se filtra con distintas herramientas.
661    fn run_reprocess(&mut self, line: String, source: RunId) {
662        let line = line.trim().to_string();
663        if line.is_empty() {
664            return;
665        }
666        let data: String = self
667            .session
668            .run(source)
669            .map(|r| r.lines_of(Stream::Stdout).collect::<Vec<_>>().join("\n"))
670            .unwrap_or_default();
671        for ui in self.run_ui.values_mut() {
672            if !ui.user_touched {
673                ui.collapsed = true;
674            }
675        }
676        let now = unix_now();
677        let id = self.session.begin_run(&line, now);
678        self.run_ui.insert(id, RunUi::default());
679        let spec = self.build_spec(&line, Some(data), id);
680        self.active.push((id, exec_run(&spec)));
681        self.scroll.scroll_to_bottom();
682    }
683
684    /// Mata el proceso de un comando en curso.
685    fn kill_run(&self, id: RunId) {
686        if let Some((_, handle)) = self.active.iter().find(|(rid, _)| *rid == id) {
687            handle.kill();
688        }
689    }
690
691    fn refresh_completion(&mut self) {
692        let comp = self.line.complete(&self.source);
693        self.show_completion =
694            !comp.candidates.is_empty() && comp.replace_end > comp.replace_start;
695        self.completion_index = 0;
696        self.completion = Some(comp);
697    }
698
699    fn on_tab(&mut self) {
700        let comp = self.line.complete(&self.source);
701        if comp.candidates.is_empty() {
702            return;
703        }
704        if self.show_completion {
705            let idx = self.completion_index.min(comp.candidates.len() - 1);
706            let candidate = comp.candidates[idx].clone();
707            self.line.apply_completion(&comp, &candidate);
708            self.show_completion = false;
709            self.completion = None;
710        } else {
711            self.completion_index = 0;
712            self.completion = Some(comp);
713            self.show_completion = true;
714        }
715    }
716
717    fn cycle_completion(&mut self, delta: i32) {
718        if !self.show_completion {
719            return;
720        }
721        if let Some(comp) = &self.completion {
722            let n = comp.candidates.len();
723            if n > 0 {
724                let i = self.completion_index as i32 + delta;
725                self.completion_index = i.rem_euclid(n as i32) as usize;
726            }
727        }
728    }
729
730    /// Enter — ejecuta el contenido del input, o reprocesa una salida
731    /// previa si hay un origen de reproceso activo.
732    fn submit(&mut self) {
733        let line = self.line.text().to_string();
734        self.line.clear();
735        self.completion = None;
736        self.show_completion = false;
737        if let Some(source) = self.reprocess_source.take() {
738            self.run_reprocess(line, source);
739        } else {
740            self.run_command(line);
741        }
742    }
743
744    fn handle_key(&mut self, event: &KeyDownEvent, _w: &mut Window, cx: &mut Context<Self>) {
745        let ks = &event.keystroke;
746        let key = ks.key.as_str();
747        let ctrl = ks.modifiers.control;
748        let mut changed = false;
749
750        match key {
751            "enter" => {
752                self.submit();
753                cx.notify();
754                return;
755            }
756            "escape" => {
757                self.show_completion = false;
758                self.reprocess_source = None;
759                cx.notify();
760                return;
761            }
762            "tab" => {
763                self.on_tab();
764                cx.notify();
765                return;
766            }
767            "up" => {
768                self.cycle_completion(-1);
769                cx.notify();
770                return;
771            }
772            "down" => {
773                self.cycle_completion(1);
774                cx.notify();
775                return;
776            }
777            "backspace" => {
778                self.line.backspace();
779                changed = true;
780            }
781            "delete" => {
782                self.line.delete();
783                changed = true;
784            }
785            "left" => {
786                if ctrl {
787                    self.line.move_word_left();
788                } else {
789                    self.line.move_left();
790                }
791            }
792            "right" => {
793                if ctrl {
794                    self.line.move_word_right();
795                } else if self.line.cursor() == self.line.text().len() {
796                    // En el extremo, la flecha derecha acepta el fantasma.
797                    if let Some(g) = self.compute_ghost() {
798                        self.line.insert(&g);
799                        changed = true;
800                    }
801                } else {
802                    self.line.move_right();
803                }
804            }
805            "home" => self.line.move_home(),
806            "end" => self.line.move_end(),
807            "a" if ctrl => self.line.move_home(),
808            "e" if ctrl => self.line.move_end(),
809            "u" if ctrl => {
810                self.line.clear();
811                changed = true;
812            }
813            "space" if ctrl => {
814                // Ctrl+Space también acepta el fantasma predicho.
815                if let Some(g) = self.compute_ghost() {
816                    self.line.insert(&g);
817                    changed = true;
818                }
819            }
820            _ if fkey_index(key).is_some() => {
821                // F1..F8 ejecutan el grupo de esa posición en [RUN].
822                let idx = fkey_index(key).unwrap();
823                let joined =
824                    self.session.groups().get(idx).map(|g| g.lines.join(" && "));
825                if let Some(j) = joined {
826                    self.run_command(j);
827                }
828                cx.notify();
829                return;
830            }
831            _ => {
832                if !ctrl {
833                    if let Some(ch) = ks.key_char.as_deref() {
834                        if !ch.chars().any(|c| c.is_control()) {
835                            self.line.insert(ch);
836                            changed = true;
837                        }
838                    }
839                }
840            }
841        }
842
843        if changed {
844            self.refresh_completion();
845        }
846        cx.notify();
847    }
848}
849
850/// Color de resaltado de cada clase de token.
851fn token_color(kind: TokenKind, theme: &Theme) -> Hsla {
852    match kind {
853        TokenKind::Command => gpui::hsla(190.0 / 360.0, 0.65, 0.62, 1.0),
854        TokenKind::Argument => theme.fg_text,
855        TokenKind::Flag => gpui::hsla(38.0 / 360.0, 0.80, 0.62, 1.0),
856        TokenKind::StringLit => gpui::hsla(95.0 / 360.0, 0.42, 0.60, 1.0),
857        TokenKind::Variable => gpui::hsla(280.0 / 360.0, 0.55, 0.72, 1.0),
858        TokenKind::Pipe => gpui::hsla(190.0 / 360.0, 0.90, 0.72, 1.0),
859        TokenKind::Redirect => gpui::hsla(20.0 / 360.0, 0.78, 0.62, 1.0),
860        TokenKind::Operator => gpui::hsla(0.0, 0.66, 0.66, 1.0),
861        TokenKind::Comment => theme.fg_muted,
862        TokenKind::Whitespace => theme.fg_text,
863        TokenKind::Unknown => gpui::hsla(0.0, 0.70, 0.60, 1.0),
864    }
865}
866
867/// Resalta un texto estático (la línea de un comando del historial).
868fn highlight(text: &str, theme: &Theme) -> Vec<gpui::Div> {
869    shuma_line::tokenize(text, shuma_line::Dialect::Bash)
870        .into_iter()
871        .map(|t| {
872            div()
873                .flex_none()
874                .text_color(token_color(t.kind, theme))
875                .child(t.text)
876        })
877        .collect()
878}
879
880/// Acorta el cwd: el `$HOME` se muestra como `~`.
881fn pretty_cwd(cwd: &str) -> String {
882    match std::env::var("HOME") {
883        Ok(home) if cwd == home => "~".to_string(),
884        Ok(home) if cwd.starts_with(&format!("{home}/")) => format!("~{}", &cwd[home.len()..]),
885        _ => cwd.to_string(),
886    }
887}
888
889/// Renderiza la tarjeta de un comando ejecutado: cabecera-acordeón +
890/// filtro stdout/stderr + cuerpo de salida.
891fn render_run(
892    r: &CommandRun,
893    ui: RunUi,
894    theme: &Theme,
895    node_bg: Hsla,
896    cx: &mut Context<Shell>,
897) -> impl IntoElement {
898    let id = r.id;
899    let dim = theme.fg_muted;
900    let (glyph, gcolor) = match r.status {
901        RunStatus::Running => ("▷", gpui::hsla(45.0 / 360.0, 0.75, 0.60, 1.0)),
902        RunStatus::Ok => ("✓", gpui::hsla(140.0 / 360.0, 0.48, 0.55, 1.0)),
903        RunStatus::Failed => ("✗", gpui::hsla(2.0 / 360.0, 0.68, 0.60, 1.0)),
904    };
905    let stderr_color = gpui::hsla(8.0 / 360.0, 0.62, 0.66, 1.0);
906    let accent = gpui::hsla(190.0 / 360.0, 0.60, 0.62, 1.0);
907
908    // Nota a la derecha: salida no-cero, truncado, y conteo si colapsada.
909    let mut parts: Vec<String> = Vec::new();
910    if let Some(c) = r.exit_code {
911        if c != 0 {
912            parts.push(format!("salió {c}"));
913        }
914    }
915    if r.truncated {
916        parts.push("⚠ truncado".to_string());
917    }
918    if ui.collapsed {
919        let n = r.count_of(Stream::Stdout);
920        if n > 0 {
921            parts.push(format!("{n} líneas"));
922        }
923    }
924    let note = parts.join(" · ");
925
926    // Cabecera-acordeón: un clic colapsa/expande.
927    let caret = if ui.collapsed { "▸" } else { "▾" };
928    let header_left = div()
929        .id(SharedString::from(format!("hdr-{id}")))
930        .flex()
931        .flex_row()
932        .items_center()
933        .gap(px(6.))
934        .flex_1()
935        .cursor_pointer()
936        .child(div().flex_none().text_color(dim).child(caret))
937        .child(div().flex_none().text_color(gcolor).child(glyph))
938        .children(highlight(&r.line, theme))
939        .child(
940            div()
941                .flex_none()
942                .text_size(px(11.))
943                .text_color(dim)
944                .child(SharedString::from(note)),
945        )
946        .on_click(cx.listener(move |shell, _, _, cx| {
947            let e = shell.run_ui.entry(id).or_default();
948            e.collapsed = !e.collapsed;
949            e.user_touched = true;
950            cx.notify();
951        }));
952
953    // Filtro de stderr — sólo aparece si el comando emitió errores.
954    let stderr_chip = if r.has_stderr() {
955        let n = r.count_of(Stream::Stderr);
956        Some(
957            div()
958                .id(SharedString::from(format!("err-{id}")))
959                .flex_none()
960                .px(px(6.))
961                .py(px(1.))
962                .rounded(px(3.))
963                .text_size(px(11.))
964                .cursor_pointer()
965                .when(ui.show_stderr, |d| {
966                    d.bg(stderr_color).text_color(gpui::hsla(0.0, 0.0, 0.12, 1.0))
967                })
968                .when(!ui.show_stderr, |d| d.text_color(stderr_color))
969                .child(SharedString::from(format!("⚠ {n}")))
970                .on_click(cx.listener(move |shell, _, _, cx| {
971                    let e = shell.run_ui.entry(id).or_default();
972                    e.show_stderr = !e.show_stderr;
973                    cx.notify();
974                })),
975        )
976    } else {
977        None
978    };
979
980    // Botón de matar — sólo mientras el comando sigue corriendo.
981    let kill_chip = if r.status == RunStatus::Running {
982        Some(
983            div()
984                .id(SharedString::from(format!("kill-{id}")))
985                .flex_none()
986                .px(px(6.))
987                .py(px(1.))
988                .rounded(px(3.))
989                .text_size(px(11.))
990                .text_color(gpui::hsla(2.0 / 360.0, 0.66, 0.64, 1.0))
991                .cursor_pointer()
992                .hover(|s| s.bg(gpui::hsla(2.0 / 360.0, 0.55, 0.28, 1.0)))
993                .child("✕ matar")
994                .on_click(cx.listener(move |shell, _, _, cx| {
995                    shell.kill_run(id);
996                    cx.notify();
997                })),
998        )
999    } else {
1000        None
1001    };
1002
1003    // Reprocesar — sólo si el comando dejó algo en stdout que filtrar.
1004    let reprocess_chip = if r.count_of(Stream::Stdout) > 0 {
1005        Some(
1006            div()
1007                .id(SharedString::from(format!("repro-{id}")))
1008                .flex_none()
1009                .px(px(6.))
1010                .py(px(1.))
1011                .rounded(px(3.))
1012                .text_size(px(11.))
1013                .text_color(accent)
1014                .cursor_pointer()
1015                .hover(|s| s.text_color(gpui::hsla(0.0, 0.0, 0.95, 1.0)))
1016                .child("⤳ reprocesar")
1017                .on_click(cx.listener(move |shell, _, _, cx| {
1018                    shell.reprocess_source = Some(id);
1019                    cx.notify();
1020                })),
1021        )
1022    } else {
1023        None
1024    };
1025
1026    let header = div()
1027        .flex()
1028        .flex_row()
1029        .items_center()
1030        .gap(px(6.))
1031        .child(header_left)
1032        .children(stderr_chip)
1033        .children(reprocess_chip)
1034        .children(kill_chip);
1035
1036    // Cuerpo: sólo con el acordeón abierto. El filtro elige el flujo.
1037    let mut body: Vec<gpui::Div> = Vec::new();
1038    if !ui.collapsed {
1039        // Etapas del pipe: un clic re-ejecuta la línea hasta esa etapa,
1040        // como un comando nuevo — así se inspeccionan los intermedios.
1041        if r.line.contains('|') {
1042            let toks = shuma_line::tokenize(&r.line, shuma_line::Dialect::Bash);
1043            let pipe = shuma_line::split_pipeline(&toks);
1044            if pipe.stages.len() >= 2 {
1045                let chip_bg = gpui::hsla(220.0 / 360.0, 0.18, 0.24, 1.0);
1046                let accent = gpui::hsla(190.0 / 360.0, 0.62, 0.62, 1.0);
1047                let mut chips: Vec<gpui::AnyElement> = vec![div()
1048                    .flex_none()
1049                    .text_size(px(10.))
1050                    .text_color(dim)
1051                    .child("⇢ etapas")
1052                    .into_any_element()];
1053                for (i, st) in pipe.stages.iter().enumerate() {
1054                    let end = st.tokens.last().map(|t| t.end).unwrap_or(r.line.len());
1055                    let prefix = r.line[..end].trim().to_string();
1056                    let name =
1057                        st.command.clone().unwrap_or_else(|| format!("{}", i + 1));
1058                    chips.push(
1059                        div()
1060                            .id(SharedString::from(format!("stage-{id}-{i}")))
1061                            .flex_none()
1062                            .px(px(6.))
1063                            .py(px(1.))
1064                            .rounded(px(3.))
1065                            .bg(chip_bg)
1066                            .text_size(px(11.))
1067                            .text_color(accent)
1068                            .cursor_pointer()
1069                            .hover(|s| s.text_color(gpui::hsla(0.0, 0.0, 0.95, 1.0)))
1070                            .child(SharedString::from(name))
1071                            .on_click(cx.listener(move |shell, _, _, cx| {
1072                                shell.run_command(prefix.clone());
1073                                cx.notify();
1074                            }))
1075                            .into_any_element(),
1076                    );
1077                }
1078                body.push(
1079                    div()
1080                        .flex()
1081                        .flex_row()
1082                        .flex_wrap()
1083                        .gap(px(4.))
1084                        .items_center()
1085                        .children(chips),
1086                );
1087            }
1088        }
1089
1090        let stream = if ui.show_stderr { Stream::Stderr } else { Stream::Stdout };
1091        let lines: Vec<&str> = r.lines_of(stream).collect();
1092        let color = if ui.show_stderr { stderr_color } else { theme.fg_text };
1093        if lines.is_empty() {
1094            body.push(div().text_size(px(11.)).text_color(dim).child(
1095                if ui.show_stderr { "sin errores" } else { "sin salida" },
1096            ));
1097        } else {
1098            // Sin truncar: si hay contenido, se muestra entero.
1099            for l in &lines {
1100                body.push(
1101                    div()
1102                        .text_size(px(12.))
1103                        .text_color(color)
1104                        .child(SharedString::from(l.to_string())),
1105                );
1106            }
1107        }
1108    }
1109
1110    div()
1111        .flex()
1112        .flex_col()
1113        .gap(px(3.))
1114        .p(px(8.))
1115        .bg(node_bg)
1116        .border_l_2()
1117        .border_color(gcolor)
1118        .rounded(px(5.))
1119        .child(header)
1120        .children(body)
1121}
1122
1123impl Render for Shell {
1124    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1125        if !self.focused_once {
1126            window.focus(&self.focus);
1127            self.focused_once = true;
1128        }
1129        let theme = Theme::global(cx).clone();
1130        let bg = theme.bg_app.clone();
1131        let panel = gpui::hsla(220.0 / 360.0, 0.16, 0.11, 1.0);
1132        let node_bg = gpui::hsla(220.0 / 360.0, 0.14, 0.16, 1.0);
1133        let accent = gpui::hsla(190.0 / 360.0, 0.70, 0.62, 1.0);
1134        let text = theme.fg_text;
1135        let dim = theme.fg_muted;
1136
1137        let pipeline = self.line.pipeline();
1138        let piped = pipeline.stages.iter().filter(|s| s.command.is_some()).count();
1139
1140        // --- Barra de estado: cwd + identificador de aislamiento ---
1141        let status = div()
1142            .h(px(32.))
1143            .flex()
1144            .flex_row()
1145            .items_center()
1146            .justify_between()
1147            .px(px(14.))
1148            .bg(panel)
1149            .text_color(text)
1150            .child(
1151                div()
1152                    .flex()
1153                    .flex_row()
1154                    .gap(px(10.))
1155                    .items_baseline()
1156                    .child("● shuma")
1157                    .child(div().text_color(accent).child(SharedString::from(format!(
1158                        "📁 {}",
1159                        pretty_cwd(self.session.cwd())
1160                    ))))
1161                    .child(div().text_color(dim).text_size(px(11.)).child(SharedString::from(
1162                        format!("aisl:{}", self.session.isolation_id()),
1163                    ))),
1164            )
1165            .child(
1166                div().text_color(dim).text_size(px(12.)).child(SharedString::from({
1167                    // Política de captura de la sesión: tope + volcado.
1168                    let pol = self.session.capture();
1169                    let cap = if pol.limit_bytes == 0 {
1170                        "cap ∞".to_string()
1171                    } else {
1172                        format!(
1173                            "cap {}M{}",
1174                            pol.limit_bytes / (1024 * 1024),
1175                            if pol.spill { "↡" } else { "" }
1176                        )
1177                    };
1178                    let running = if piped > 1 {
1179                        format!("⇄ {piped} etapas · {} en curso", self.active.len())
1180                    } else {
1181                        format!("{} en curso", self.active.len())
1182                    };
1183                    format!("{cap}  ·  {running}")
1184                })),
1185            );
1186
1187        // --- Panel izquierdo: grupos reutilizables [RUN] ---
1188        let left = if self.left_collapsed {
1189            div()
1190                .id("expand-left")
1191                .w(px(26.))
1192                .flex()
1193                .flex_col()
1194                .items_center()
1195                .pt(px(8.))
1196                .bg(panel)
1197                .text_color(dim)
1198                .cursor_pointer()
1199                .hover(|s| s.bg(node_bg))
1200                .child("»")
1201                .on_click(cx.listener(|s, _, _, cx| {
1202                    s.left_collapsed = false;
1203                    cx.notify();
1204                }))
1205        } else {
1206            let groups: Vec<_> = self
1207                .session
1208                .groups()
1209                .iter()
1210                .enumerate()
1211                .map(|(idx, g)| {
1212                    let joined = g.lines.join(" && ");
1213                    let count = g.lines.len();
1214                    // Los 8 primeros grupos llevan atajo dinámico F1..F8.
1215                    let label = if idx < 8 {
1216                        format!("F{}  ▸ {}  ·{count}", idx + 1, g.name)
1217                    } else {
1218                        format!("▸ {}  ·{count}", g.name)
1219                    };
1220                    div()
1221                        .id(SharedString::from(format!("group-{}", g.name)))
1222                        .px(px(8.))
1223                        .py(px(6.))
1224                        .bg(node_bg)
1225                        .rounded(px(4.))
1226                        .text_color(text)
1227                        .text_size(px(13.))
1228                        .cursor_pointer()
1229                        .hover(|s| s.bg(theme.bg_row_hover))
1230                        .child(SharedString::from(label))
1231                        .on_click(cx.listener(move |shell, _, _, cx| {
1232                            shell.run_command(joined.clone());
1233                            cx.notify();
1234                        }))
1235                })
1236                .collect();
1237            div()
1238                .id("run-panel")
1239                .w(px(self.left_width))
1240                .flex()
1241                .flex_col()
1242                .gap(px(6.))
1243                .p(px(10.))
1244                .bg(panel)
1245                .child(
1246                    div()
1247                        .flex()
1248                        .flex_row()
1249                        .justify_between()
1250                        .items_center()
1251                        .child(div().text_color(dim).text_size(px(12.)).child("[RUN] grupos"))
1252                        .child(
1253                            div()
1254                                .flex()
1255                                .flex_row()
1256                                .gap(px(4.))
1257                                .items_center()
1258                                .child(
1259                                    div()
1260                                        .id("save-group")
1261                                        .px(px(5.))
1262                                        .text_color(dim)
1263                                        .cursor_pointer()
1264                                        .hover(|s| s.text_color(accent))
1265                                        .child("+")
1266                                        .on_click(cx.listener(|s, _, _, cx| {
1267                                            s.line.set_text(":save ");
1268                                            cx.notify();
1269                                        })),
1270                                )
1271                                .child(
1272                                    div()
1273                                        .id("collapse-left")
1274                                        .px(px(5.))
1275                                        .text_color(dim)
1276                                        .cursor_pointer()
1277                                        .hover(|s| s.text_color(accent))
1278                                        .child("«")
1279                                        .on_click(cx.listener(|s, _, _, cx| {
1280                                            s.left_collapsed = true;
1281                                            cx.notify();
1282                                        })),
1283                                ),
1284                        ),
1285                )
1286                .children(groups)
1287                .child(
1288                    div()
1289                        .text_size(px(10.))
1290                        .text_color(dim)
1291                        .child("clic ejecuta · + guarda lo último"),
1292                )
1293        };
1294
1295        // --- Lienzo central: comandos ejecutados + su salida ---
1296        // Orden de terminal: los más viejos arriba, los nuevos abajo.
1297        let hist = self.session.history();
1298        let start = hist.len().saturating_sub(40);
1299        let runs: Vec<_> = hist[start..]
1300            .iter()
1301            .map(|r| {
1302                let ui = self.run_ui.get(&r.id).copied().unwrap_or_default();
1303                render_run(r, ui, &theme, node_bg, cx)
1304            })
1305            .collect();
1306        let runs_empty = runs.is_empty();
1307        let canvas = div()
1308            .id("runs")
1309            .flex_1()
1310            .overflow_y_scroll()
1311            .track_scroll(&self.scroll)
1312            .flex()
1313            .flex_col()
1314            .gap(px(8.))
1315            .p(px(10.))
1316            .bg(bg.clone())
1317            .when(runs_empty, |d| {
1318                d.child(div().text_color(dim).child(
1319                    "Escribe un comando abajo y presiona Enter — su salida aparece aquí.",
1320                ))
1321            })
1322            .children(runs);
1323
1324        // --- Panel derecho: monitores [SENS] ---
1325        let right = if self.right_collapsed {
1326            div()
1327                .id("expand-right")
1328                .w(px(26.))
1329                .flex()
1330                .flex_col()
1331                .items_center()
1332                .pt(px(8.))
1333                .bg(panel)
1334                .text_color(dim)
1335                .cursor_pointer()
1336                .hover(|s| s.bg(node_bg))
1337                .child("«")
1338                .on_click(cx.listener(|s, _, _, cx| {
1339                    s.right_collapsed = false;
1340                    cx.notify();
1341                }))
1342        } else {
1343            let cpu = self.snapshot.cpu_percent;
1344            let cpu_curve = self.sampler.cpu_history().values();
1345            let mem_curve = self.sampler.mem_history().values();
1346            let cpu_color = gpui::hsla(190.0 / 360.0, 0.72, 0.62, 1.0);
1347            let mem_color = gpui::hsla(265.0 / 360.0, 0.55, 0.70, 1.0);
1348
1349            let monitor = |title: &str, value: String, curve: Vec<f32>, color: Hsla| {
1350                div()
1351                    .flex()
1352                    .flex_col()
1353                    .gap(px(4.))
1354                    .p(px(8.))
1355                    .bg(node_bg)
1356                    .rounded(px(5.))
1357                    .child(
1358                        div()
1359                            .flex()
1360                            .flex_row()
1361                            .justify_between()
1362                            .items_baseline()
1363                            .child(div().text_color(dim).text_size(px(11.)).child(title.to_string()))
1364                            .child(div().text_color(color).child(SharedString::from(value))),
1365                    )
1366                    .child(div().h(px(44.)).child(CurveElement::new(curve, color)))
1367            };
1368
1369            div()
1370                .id("sens-panel")
1371                .w(px(self.right_width))
1372                .flex()
1373                .flex_col()
1374                .gap(px(10.))
1375                .p(px(10.))
1376                .bg(panel)
1377                .text_color(text)
1378                .child(
1379                    div()
1380                        .flex()
1381                        .flex_row()
1382                        .justify_between()
1383                        .items_center()
1384                        .child(div().text_color(dim).text_size(px(12.)).child("[SENS]"))
1385                        .child(
1386                            div()
1387                                .id("collapse-right")
1388                                .px(px(5.))
1389                                .text_color(dim)
1390                                .cursor_pointer()
1391                                .hover(|s| s.text_color(accent))
1392                                .child("»")
1393                                .on_click(cx.listener(|s, _, _, cx| {
1394                                    s.right_collapsed = true;
1395                                    cx.notify();
1396                                })),
1397                        ),
1398                )
1399                .child(monitor("CPU", format!("{cpu:.0} %"), cpu_curve, cpu_color))
1400                .child(monitor(
1401                    "MEM",
1402                    if self.snapshot.valid {
1403                        format!(
1404                            "{:.1}/{:.0} GB",
1405                            self.snapshot.mem_used_mb as f32 / 1024.0,
1406                            self.snapshot.mem_total_mb as f32 / 1024.0
1407                        )
1408                    } else {
1409                        "— GB".to_string()
1410                    },
1411                    mem_curve,
1412                    mem_color,
1413                ))
1414        };
1415
1416        // --- Zona prompt: el input inteligente ---
1417        let mut input_row: Vec<gpui::Div> = vec![div().flex_none().text_color(accent).child("›  ")];
1418        let cursor = self.line.cursor();
1419        let tokens = self.line.tokens();
1420        let caret = || div().w(px(2.)).h(px(19.)).bg(accent);
1421        if tokens.is_empty() {
1422            input_row.push(caret());
1423            input_row.push(
1424                div()
1425                    .text_color(dim)
1426                    .child("escribe un comando…  (Tab autocompleta · Enter ejecuta)"),
1427            );
1428        } else {
1429            let mut caret_done = false;
1430            for t in &tokens {
1431                let color = token_color(t.kind, &theme);
1432                if !caret_done && cursor >= t.start && cursor < t.end {
1433                    let local = cursor - t.start;
1434                    let (left_s, right_s) = t.text.split_at(local);
1435                    if !left_s.is_empty() {
1436                        input_row
1437                            .push(div().flex_none().text_color(color).child(left_s.to_string()));
1438                    }
1439                    input_row.push(caret());
1440                    input_row
1441                        .push(div().flex_none().text_color(color).child(right_s.to_string()));
1442                    caret_done = true;
1443                } else {
1444                    input_row.push(div().flex_none().text_color(color).child(t.text.clone()));
1445                }
1446            }
1447            if !caret_done {
1448                input_row.push(caret());
1449            }
1450        }
1451        // Sugerencia fantasma — el resto que el shell predice, en gris.
1452        if let Some(ghost) = self.compute_ghost() {
1453            input_row.push(
1454                div()
1455                    .flex_none()
1456                    .text_color(theme.fg_disabled)
1457                    .child(SharedString::from(ghost)),
1458            );
1459        }
1460        let input_bar = div()
1461            .h(px(46.))
1462            .flex()
1463            .flex_row()
1464            .items_center()
1465            .px(px(14.))
1466            .text_color(text)
1467            .text_size(px(14.))
1468            .children(input_row);
1469        // Banner del modo reproceso — escribí un filtro para la salida.
1470        let banner = self.reprocess_source.map(|src| {
1471            div()
1472                .px(px(14.))
1473                .py(px(3.))
1474                .bg(gpui::hsla(190.0 / 360.0, 0.30, 0.22, 1.0))
1475                .text_size(px(11.))
1476                .text_color(accent)
1477                .child(SharedString::from(format!(
1478                    "⤳ reprocesando la salida de #{src} — escribí un filtro · Esc cancela"
1479                )))
1480        });
1481        let prompt = div()
1482            .flex()
1483            .flex_col()
1484            .bg(panel)
1485            .children(banner)
1486            .child(input_bar);
1487
1488        // --- Popup de autocompletado ---
1489        let mut popup_layer: Vec<gpui::Div> = Vec::new();
1490        if self.show_completion {
1491            if let Some(comp) = &self.completion {
1492                if !comp.candidates.is_empty() {
1493                    let kind_label = match comp.kind {
1494                        CompletionKind::Command => "comando",
1495                        CompletionKind::Flag => "flag",
1496                        CompletionKind::Path => "ruta",
1497                    };
1498                    let total = comp.candidates.len();
1499                    let start = self
1500                        .completion_index
1501                        .saturating_sub(3)
1502                        .min(total.saturating_sub(8));
1503                    let rows: Vec<_> = comp
1504                        .candidates
1505                        .iter()
1506                        .enumerate()
1507                        .skip(start)
1508                        .take(8)
1509                        .map(|(i, cand)| {
1510                            let selected = i == self.completion_index;
1511                            div()
1512                                .px(px(8.))
1513                                .py(px(3.))
1514                                .when(selected, |d| {
1515                                    d.bg(accent).text_color(gpui::hsla(0.0, 0.0, 0.1, 1.0))
1516                                })
1517                                .when(!selected, |d| d.text_color(text))
1518                                .child(SharedString::from(cand.clone()))
1519                        })
1520                        .collect();
1521                    popup_layer.push(
1522                        div()
1523                            .absolute()
1524                            .left(px(28.))
1525                            .bottom(px(52.))
1526                            .w(px(320.))
1527                            .flex()
1528                            .flex_col()
1529                            .bg(node_bg)
1530                            .border_1()
1531                            .border_color(accent)
1532                            .rounded(px(5.))
1533                            .text_size(px(13.))
1534                            .child(
1535                                div()
1536                                    .px(px(8.))
1537                                    .py(px(3.))
1538                                    .text_color(dim)
1539                                    .text_size(px(11.))
1540                                    .child(SharedString::from(format!(
1541                                        "{kind_label} · {total} · ↑↓ Tab"
1542                                    ))),
1543                            )
1544                            .children(rows),
1545                    );
1546                }
1547            }
1548        }
1549
1550        // --- Divisores arrastrables ---
1551        let divider = |side: Side, cx: &mut Context<Self>| {
1552            div()
1553                .w(px(5.))
1554                .bg(node_bg)
1555                .cursor(CursorStyle::ResizeLeftRight)
1556                .hover(|s| s.bg(accent))
1557                .on_mouse_down(
1558                    MouseButton::Left,
1559                    cx.listener(move |shell, ev: &MouseDownEvent, _w, cx| {
1560                        let start_w = match side {
1561                            Side::Left => shell.left_width,
1562                            Side::Right => shell.right_width,
1563                        };
1564                        shell.drag = Some(Drag {
1565                            side,
1566                            start_x: ev.position.x.into(),
1567                            start_w,
1568                        });
1569                        cx.notify();
1570                    }),
1571                )
1572        };
1573
1574        let mut middle = div().flex().flex_row().flex_1().overflow_hidden().child(left);
1575        if !self.left_collapsed {
1576            middle = middle.child(divider(Side::Left, cx));
1577        }
1578        middle = middle.child(canvas);
1579        if !self.right_collapsed {
1580            middle = middle.child(divider(Side::Right, cx));
1581        }
1582        middle = middle.child(right);
1583
1584        // --- Composición ---
1585        div()
1586            .size_full()
1587            .relative()
1588            .flex()
1589            .flex_col()
1590            .bg(bg)
1591            .track_focus(&self.focus)
1592            .key_context("ShumaShell")
1593            .on_key_down(cx.listener(Self::handle_key))
1594            .on_mouse_move(cx.listener(|shell, ev: &MouseMoveEvent, _w, cx| {
1595                if let Some(drag) = &shell.drag {
1596                    let cur: f32 = ev.position.x.into();
1597                    let delta = cur - drag.start_x;
1598                    match drag.side {
1599                        // El panel izquierdo crece al arrastrar a la derecha.
1600                        Side::Left => {
1601                            shell.left_width = (drag.start_w + delta).clamp(130.0, 420.0)
1602                        }
1603                        // El derecho crece al arrastrar a la izquierda.
1604                        Side::Right => {
1605                            shell.right_width = (drag.start_w - delta).clamp(130.0, 420.0)
1606                        }
1607                    }
1608                    cx.notify();
1609                }
1610            }))
1611            .on_mouse_up(
1612                MouseButton::Left,
1613                cx.listener(|shell, _ev: &MouseUpEvent, _w, cx| {
1614                    if shell.drag.take().is_some() {
1615                        cx.notify();
1616                    }
1617                }),
1618            )
1619            .child(status)
1620            .child(middle)
1621            .child(prompt)
1622            .children(popup_layer)
1623    }
1624}
1625
1626impl Drop for Shell {
1627    /// Al cerrar la sesión, limpia sus archivos de volcado temporales.
1628    fn drop(&mut self) {
1629        let prefix = format!("shuma-spill-{}-", std::process::id());
1630        if let Ok(entries) = std::fs::read_dir(std::env::temp_dir()) {
1631            for e in entries.flatten() {
1632                if e.file_name().to_string_lossy().starts_with(&prefix) {
1633                    let _ = std::fs::remove_file(e.path());
1634                }
1635            }
1636        }
1637    }
1638}
1639
1640fn main() {
1641    launch_app("brahman · shuma shell", (1100., 700.), Shell::new);
1642}