tawasuyu

tawasuyu/brahman

Rust 332TOML 127JSON 17Markdown 154710 Sutichaykuna

⎇ 2bd6aaad0249e0a87d5a5c55ea1a250b5f258eac crates/apps/shuma-shell/src/main.rs Rust · 2045 siq'ikuna · 74.9 KB Ñawpa willay Pi imata llamkarqan ch'uya

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