gioser

tawasuyu/brahman

Rust 326TOML 123JSON 14Markdown 114554 Definiciones

⎇ b4be5e1c72d728d5e3aaf2f294b35a3c34c7de99 crates/apps/shuma-shell/src/main.rs Rust · 1535 líneas · 54.7 KB Historia Quién tocó qué crudo

feat(shuma): captura acotada + reproceso de salidas vía stdin b4be5e1c7 · sergio ·

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