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