⎇ 2bd6aaad0249e0a87d5a5c55ea1a250b5f258eac
crates/apps/shuma-shell/src/main.rs
Ñawpa willay
Pi imata llamkarqan
ch'uya
feat(shuma): cajón de resultados del shell — desplegable desde el pie
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(¤t, &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}