gioser

tawasuyu/brahman

Rust 582TOML 222Markdown 73JSON 198635 Sutichaykuna

⎇ b08d5cbe0a6222f5b1479f40e68346b5d38246a8 crates/apps/shuma-shell/src/main.rs Rust · 1626 siq'ikuna · 58.5 KB Ñawpa willay Pi imata llamkarqan ch'uya

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