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