⎇ b08d5cbe0a6222f5b1479f40e68346b5d38246a8
crates/apps/shuma-shell/src/main.rs
Ñawpa willay
Pi imata llamkarqan
ch'uya
feat(shuma): ejecución directa + captura configurable por sesión
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)
646 .then(|| std::env::temp_dir().join(format!("shuma-spill-{run_id}.log")));
647 CommandSpec {
648 exec: plan_exec(line),
649 cwd: self.session.cwd().to_string(),
650 capture_limit: policy.limit_bytes,
651 spill_path,
652 stdin_data: stdin,
653 }
654 }
655
656 /// Reprocesa la salida capturada del comando `source`: ejecuta `line`
657 /// alimentándole esa salida por stdin, sin volver a correr el
658 /// original. Así un resultado se filtra con distintas herramientas.
659 fn run_reprocess(&mut self, line: String, source: RunId) {
660 let line = line.trim().to_string();
661 if line.is_empty() {
662 return;
663 }
664 let data: String = self
665 .session
666 .run(source)
667 .map(|r| r.lines_of(Stream::Stdout).collect::<Vec<_>>().join("\n"))
668 .unwrap_or_default();
669 for ui in self.run_ui.values_mut() {
670 if !ui.user_touched {
671 ui.collapsed = true;
672 }
673 }
674 let now = unix_now();
675 let id = self.session.begin_run(&line, now);
676 self.run_ui.insert(id, RunUi::default());
677 let spec = self.build_spec(&line, Some(data), id);
678 self.active.push((id, exec_run(&spec)));
679 self.scroll.scroll_to_bottom();
680 }
681
682 /// Mata el proceso de un comando en curso.
683 fn kill_run(&self, id: RunId) {
684 if let Some((_, handle)) = self.active.iter().find(|(rid, _)| *rid == id) {
685 handle.kill();
686 }
687 }
688
689 fn refresh_completion(&mut self) {
690 let comp = self.line.complete(&self.source);
691 self.show_completion =
692 !comp.candidates.is_empty() && comp.replace_end > comp.replace_start;
693 self.completion_index = 0;
694 self.completion = Some(comp);
695 }
696
697 fn on_tab(&mut self) {
698 let comp = self.line.complete(&self.source);
699 if comp.candidates.is_empty() {
700 return;
701 }
702 if self.show_completion {
703 let idx = self.completion_index.min(comp.candidates.len() - 1);
704 let candidate = comp.candidates[idx].clone();
705 self.line.apply_completion(&comp, &candidate);
706 self.show_completion = false;
707 self.completion = None;
708 } else {
709 self.completion_index = 0;
710 self.completion = Some(comp);
711 self.show_completion = true;
712 }
713 }
714
715 fn cycle_completion(&mut self, delta: i32) {
716 if !self.show_completion {
717 return;
718 }
719 if let Some(comp) = &self.completion {
720 let n = comp.candidates.len();
721 if n > 0 {
722 let i = self.completion_index as i32 + delta;
723 self.completion_index = i.rem_euclid(n as i32) as usize;
724 }
725 }
726 }
727
728 /// Enter — ejecuta el contenido del input, o reprocesa una salida
729 /// previa si hay un origen de reproceso activo.
730 fn submit(&mut self) {
731 let line = self.line.text().to_string();
732 self.line.clear();
733 self.completion = None;
734 self.show_completion = false;
735 if let Some(source) = self.reprocess_source.take() {
736 self.run_reprocess(line, source);
737 } else {
738 self.run_command(line);
739 }
740 }
741
742 fn handle_key(&mut self, event: &KeyDownEvent, _w: &mut Window, cx: &mut Context<Self>) {
743 let ks = &event.keystroke;
744 let key = ks.key.as_str();
745 let ctrl = ks.modifiers.control;
746 let mut changed = false;
747
748 match key {
749 "enter" => {
750 self.submit();
751 cx.notify();
752 return;
753 }
754 "escape" => {
755 self.show_completion = false;
756 self.reprocess_source = None;
757 cx.notify();
758 return;
759 }
760 "tab" => {
761 self.on_tab();
762 cx.notify();
763 return;
764 }
765 "up" => {
766 self.cycle_completion(-1);
767 cx.notify();
768 return;
769 }
770 "down" => {
771 self.cycle_completion(1);
772 cx.notify();
773 return;
774 }
775 "backspace" => {
776 self.line.backspace();
777 changed = true;
778 }
779 "delete" => {
780 self.line.delete();
781 changed = true;
782 }
783 "left" => {
784 if ctrl {
785 self.line.move_word_left();
786 } else {
787 self.line.move_left();
788 }
789 }
790 "right" => {
791 if ctrl {
792 self.line.move_word_right();
793 } else if self.line.cursor() == self.line.text().len() {
794 // En el extremo, la flecha derecha acepta el fantasma.
795 if let Some(g) = self.compute_ghost() {
796 self.line.insert(&g);
797 changed = true;
798 }
799 } else {
800 self.line.move_right();
801 }
802 }
803 "home" => self.line.move_home(),
804 "end" => self.line.move_end(),
805 "a" if ctrl => self.line.move_home(),
806 "e" if ctrl => self.line.move_end(),
807 "u" if ctrl => {
808 self.line.clear();
809 changed = true;
810 }
811 "space" if ctrl => {
812 // Ctrl+Space también acepta el fantasma predicho.
813 if let Some(g) = self.compute_ghost() {
814 self.line.insert(&g);
815 changed = true;
816 }
817 }
818 _ if fkey_index(key).is_some() => {
819 // F1..F8 ejecutan el grupo de esa posición en [RUN].
820 let idx = fkey_index(key).unwrap();
821 let joined =
822 self.session.groups().get(idx).map(|g| g.lines.join(" && "));
823 if let Some(j) = joined {
824 self.run_command(j);
825 }
826 cx.notify();
827 return;
828 }
829 _ => {
830 if !ctrl {
831 if let Some(ch) = ks.key_char.as_deref() {
832 if !ch.chars().any(|c| c.is_control()) {
833 self.line.insert(ch);
834 changed = true;
835 }
836 }
837 }
838 }
839 }
840
841 if changed {
842 self.refresh_completion();
843 }
844 cx.notify();
845 }
846}
847
848/// Color de resaltado de cada clase de token.
849fn token_color(kind: TokenKind, theme: &Theme) -> Hsla {
850 match kind {
851 TokenKind::Command => gpui::hsla(190.0 / 360.0, 0.65, 0.62, 1.0),
852 TokenKind::Argument => theme.fg_text,
853 TokenKind::Flag => gpui::hsla(38.0 / 360.0, 0.80, 0.62, 1.0),
854 TokenKind::StringLit => gpui::hsla(95.0 / 360.0, 0.42, 0.60, 1.0),
855 TokenKind::Variable => gpui::hsla(280.0 / 360.0, 0.55, 0.72, 1.0),
856 TokenKind::Pipe => gpui::hsla(190.0 / 360.0, 0.90, 0.72, 1.0),
857 TokenKind::Redirect => gpui::hsla(20.0 / 360.0, 0.78, 0.62, 1.0),
858 TokenKind::Operator => gpui::hsla(0.0, 0.66, 0.66, 1.0),
859 TokenKind::Comment => theme.fg_muted,
860 TokenKind::Whitespace => theme.fg_text,
861 TokenKind::Unknown => gpui::hsla(0.0, 0.70, 0.60, 1.0),
862 }
863}
864
865/// Resalta un texto estático (la línea de un comando del historial).
866fn highlight(text: &str, theme: &Theme) -> Vec<gpui::Div> {
867 shuma_line::tokenize(text, shuma_line::Dialect::Bash)
868 .into_iter()
869 .map(|t| {
870 div()
871 .flex_none()
872 .text_color(token_color(t.kind, theme))
873 .child(t.text)
874 })
875 .collect()
876}
877
878/// Acorta el cwd: el `$HOME` se muestra como `~`.
879fn pretty_cwd(cwd: &str) -> String {
880 match std::env::var("HOME") {
881 Ok(home) if cwd == home => "~".to_string(),
882 Ok(home) if cwd.starts_with(&format!("{home}/")) => format!("~{}", &cwd[home.len()..]),
883 _ => cwd.to_string(),
884 }
885}
886
887/// Renderiza la tarjeta de un comando ejecutado: cabecera-acordeón +
888/// filtro stdout/stderr + cuerpo de salida.
889fn render_run(
890 r: &CommandRun,
891 ui: RunUi,
892 theme: &Theme,
893 node_bg: Hsla,
894 cx: &mut Context<Shell>,
895) -> impl IntoElement {
896 let id = r.id;
897 let dim = theme.fg_muted;
898 let (glyph, gcolor) = match r.status {
899 RunStatus::Running => ("▷", gpui::hsla(45.0 / 360.0, 0.75, 0.60, 1.0)),
900 RunStatus::Ok => ("✓", gpui::hsla(140.0 / 360.0, 0.48, 0.55, 1.0)),
901 RunStatus::Failed => ("✗", gpui::hsla(2.0 / 360.0, 0.68, 0.60, 1.0)),
902 };
903 let stderr_color = gpui::hsla(8.0 / 360.0, 0.62, 0.66, 1.0);
904 let accent = gpui::hsla(190.0 / 360.0, 0.60, 0.62, 1.0);
905
906 // Nota a la derecha: salida no-cero, truncado, y conteo si colapsada.
907 let mut parts: Vec<String> = Vec::new();
908 if let Some(c) = r.exit_code {
909 if c != 0 {
910 parts.push(format!("salió {c}"));
911 }
912 }
913 if r.truncated {
914 parts.push("⚠ truncado".to_string());
915 }
916 if ui.collapsed {
917 let n = r.count_of(Stream::Stdout);
918 if n > 0 {
919 parts.push(format!("{n} líneas"));
920 }
921 }
922 let note = parts.join(" · ");
923
924 // Cabecera-acordeón: un clic colapsa/expande.
925 let caret = if ui.collapsed { "▸" } else { "▾" };
926 let header_left = div()
927 .id(SharedString::from(format!("hdr-{id}")))
928 .flex()
929 .flex_row()
930 .items_center()
931 .gap(px(6.))
932 .flex_1()
933 .cursor_pointer()
934 .child(div().flex_none().text_color(dim).child(caret))
935 .child(div().flex_none().text_color(gcolor).child(glyph))
936 .children(highlight(&r.line, theme))
937 .child(
938 div()
939 .flex_none()
940 .text_size(px(11.))
941 .text_color(dim)
942 .child(SharedString::from(note)),
943 )
944 .on_click(cx.listener(move |shell, _, _, cx| {
945 let e = shell.run_ui.entry(id).or_default();
946 e.collapsed = !e.collapsed;
947 e.user_touched = true;
948 cx.notify();
949 }));
950
951 // Filtro de stderr — sólo aparece si el comando emitió errores.
952 let stderr_chip = if r.has_stderr() {
953 let n = r.count_of(Stream::Stderr);
954 Some(
955 div()
956 .id(SharedString::from(format!("err-{id}")))
957 .flex_none()
958 .px(px(6.))
959 .py(px(1.))
960 .rounded(px(3.))
961 .text_size(px(11.))
962 .cursor_pointer()
963 .when(ui.show_stderr, |d| {
964 d.bg(stderr_color).text_color(gpui::hsla(0.0, 0.0, 0.12, 1.0))
965 })
966 .when(!ui.show_stderr, |d| d.text_color(stderr_color))
967 .child(SharedString::from(format!("⚠ {n}")))
968 .on_click(cx.listener(move |shell, _, _, cx| {
969 let e = shell.run_ui.entry(id).or_default();
970 e.show_stderr = !e.show_stderr;
971 cx.notify();
972 })),
973 )
974 } else {
975 None
976 };
977
978 // Botón de matar — sólo mientras el comando sigue corriendo.
979 let kill_chip = if r.status == RunStatus::Running {
980 Some(
981 div()
982 .id(SharedString::from(format!("kill-{id}")))
983 .flex_none()
984 .px(px(6.))
985 .py(px(1.))
986 .rounded(px(3.))
987 .text_size(px(11.))
988 .text_color(gpui::hsla(2.0 / 360.0, 0.66, 0.64, 1.0))
989 .cursor_pointer()
990 .hover(|s| s.bg(gpui::hsla(2.0 / 360.0, 0.55, 0.28, 1.0)))
991 .child("✕ matar")
992 .on_click(cx.listener(move |shell, _, _, cx| {
993 shell.kill_run(id);
994 cx.notify();
995 })),
996 )
997 } else {
998 None
999 };
1000
1001 // Reprocesar — sólo si el comando dejó algo en stdout que filtrar.
1002 let reprocess_chip = if r.count_of(Stream::Stdout) > 0 {
1003 Some(
1004 div()
1005 .id(SharedString::from(format!("repro-{id}")))
1006 .flex_none()
1007 .px(px(6.))
1008 .py(px(1.))
1009 .rounded(px(3.))
1010 .text_size(px(11.))
1011 .text_color(accent)
1012 .cursor_pointer()
1013 .hover(|s| s.text_color(gpui::hsla(0.0, 0.0, 0.95, 1.0)))
1014 .child("⤳ reprocesar")
1015 .on_click(cx.listener(move |shell, _, _, cx| {
1016 shell.reprocess_source = Some(id);
1017 cx.notify();
1018 })),
1019 )
1020 } else {
1021 None
1022 };
1023
1024 let header = div()
1025 .flex()
1026 .flex_row()
1027 .items_center()
1028 .gap(px(6.))
1029 .child(header_left)
1030 .children(stderr_chip)
1031 .children(reprocess_chip)
1032 .children(kill_chip);
1033
1034 // Cuerpo: sólo con el acordeón abierto. El filtro elige el flujo.
1035 let mut body: Vec<gpui::Div> = Vec::new();
1036 if !ui.collapsed {
1037 // Etapas del pipe: un clic re-ejecuta la línea hasta esa etapa,
1038 // como un comando nuevo — así se inspeccionan los intermedios.
1039 if r.line.contains('|') {
1040 let toks = shuma_line::tokenize(&r.line, shuma_line::Dialect::Bash);
1041 let pipe = shuma_line::split_pipeline(&toks);
1042 if pipe.stages.len() >= 2 {
1043 let chip_bg = gpui::hsla(220.0 / 360.0, 0.18, 0.24, 1.0);
1044 let accent = gpui::hsla(190.0 / 360.0, 0.62, 0.62, 1.0);
1045 let mut chips: Vec<gpui::AnyElement> = vec![div()
1046 .flex_none()
1047 .text_size(px(10.))
1048 .text_color(dim)
1049 .child("⇢ etapas")
1050 .into_any_element()];
1051 for (i, st) in pipe.stages.iter().enumerate() {
1052 let end = st.tokens.last().map(|t| t.end).unwrap_or(r.line.len());
1053 let prefix = r.line[..end].trim().to_string();
1054 let name =
1055 st.command.clone().unwrap_or_else(|| format!("{}", i + 1));
1056 chips.push(
1057 div()
1058 .id(SharedString::from(format!("stage-{id}-{i}")))
1059 .flex_none()
1060 .px(px(6.))
1061 .py(px(1.))
1062 .rounded(px(3.))
1063 .bg(chip_bg)
1064 .text_size(px(11.))
1065 .text_color(accent)
1066 .cursor_pointer()
1067 .hover(|s| s.text_color(gpui::hsla(0.0, 0.0, 0.95, 1.0)))
1068 .child(SharedString::from(name))
1069 .on_click(cx.listener(move |shell, _, _, cx| {
1070 shell.run_command(prefix.clone());
1071 cx.notify();
1072 }))
1073 .into_any_element(),
1074 );
1075 }
1076 body.push(
1077 div()
1078 .flex()
1079 .flex_row()
1080 .flex_wrap()
1081 .gap(px(4.))
1082 .items_center()
1083 .children(chips),
1084 );
1085 }
1086 }
1087
1088 let stream = if ui.show_stderr { Stream::Stderr } else { Stream::Stdout };
1089 let lines: Vec<&str> = r.lines_of(stream).collect();
1090 let color = if ui.show_stderr { stderr_color } else { theme.fg_text };
1091 if lines.is_empty() {
1092 body.push(div().text_size(px(11.)).text_color(dim).child(
1093 if ui.show_stderr { "sin errores" } else { "sin salida" },
1094 ));
1095 } else {
1096 // Sin truncar: si hay contenido, se muestra entero.
1097 for l in &lines {
1098 body.push(
1099 div()
1100 .text_size(px(12.))
1101 .text_color(color)
1102 .child(SharedString::from(l.to_string())),
1103 );
1104 }
1105 }
1106 }
1107
1108 div()
1109 .flex()
1110 .flex_col()
1111 .gap(px(3.))
1112 .p(px(8.))
1113 .bg(node_bg)
1114 .border_l_2()
1115 .border_color(gcolor)
1116 .rounded(px(5.))
1117 .child(header)
1118 .children(body)
1119}
1120
1121impl Render for Shell {
1122 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1123 if !self.focused_once {
1124 window.focus(&self.focus);
1125 self.focused_once = true;
1126 }
1127 let theme = Theme::global(cx).clone();
1128 let bg = theme.bg_app.clone();
1129 let panel = gpui::hsla(220.0 / 360.0, 0.16, 0.11, 1.0);
1130 let node_bg = gpui::hsla(220.0 / 360.0, 0.14, 0.16, 1.0);
1131 let accent = gpui::hsla(190.0 / 360.0, 0.70, 0.62, 1.0);
1132 let text = theme.fg_text;
1133 let dim = theme.fg_muted;
1134
1135 let pipeline = self.line.pipeline();
1136 let piped = pipeline.stages.iter().filter(|s| s.command.is_some()).count();
1137
1138 // --- Barra de estado: cwd + identificador de aislamiento ---
1139 let status = div()
1140 .h(px(32.))
1141 .flex()
1142 .flex_row()
1143 .items_center()
1144 .justify_between()
1145 .px(px(14.))
1146 .bg(panel)
1147 .text_color(text)
1148 .child(
1149 div()
1150 .flex()
1151 .flex_row()
1152 .gap(px(10.))
1153 .items_baseline()
1154 .child("● shuma")
1155 .child(div().text_color(accent).child(SharedString::from(format!(
1156 "📁 {}",
1157 pretty_cwd(self.session.cwd())
1158 ))))
1159 .child(div().text_color(dim).text_size(px(11.)).child(SharedString::from(
1160 format!("aisl:{}", self.session.isolation_id()),
1161 ))),
1162 )
1163 .child(
1164 div().text_color(dim).text_size(px(12.)).child(SharedString::from({
1165 // Política de captura de la sesión: tope + volcado.
1166 let pol = self.session.capture();
1167 let cap = if pol.limit_bytes == 0 {
1168 "cap ∞".to_string()
1169 } else {
1170 format!(
1171 "cap {}M{}",
1172 pol.limit_bytes / (1024 * 1024),
1173 if pol.spill { "↡" } else { "" }
1174 )
1175 };
1176 let running = if piped > 1 {
1177 format!("⇄ {piped} etapas · {} en curso", self.active.len())
1178 } else {
1179 format!("{} en curso", self.active.len())
1180 };
1181 format!("{cap} · {running}")
1182 })),
1183 );
1184
1185 // --- Panel izquierdo: grupos reutilizables [RUN] ---
1186 let left = if self.left_collapsed {
1187 div()
1188 .id("expand-left")
1189 .w(px(26.))
1190 .flex()
1191 .flex_col()
1192 .items_center()
1193 .pt(px(8.))
1194 .bg(panel)
1195 .text_color(dim)
1196 .cursor_pointer()
1197 .hover(|s| s.bg(node_bg))
1198 .child("»")
1199 .on_click(cx.listener(|s, _, _, cx| {
1200 s.left_collapsed = false;
1201 cx.notify();
1202 }))
1203 } else {
1204 let groups: Vec<_> = self
1205 .session
1206 .groups()
1207 .iter()
1208 .enumerate()
1209 .map(|(idx, g)| {
1210 let joined = g.lines.join(" && ");
1211 let count = g.lines.len();
1212 // Los 8 primeros grupos llevan atajo dinámico F1..F8.
1213 let label = if idx < 8 {
1214 format!("F{} ▸ {} ·{count}", idx + 1, g.name)
1215 } else {
1216 format!("▸ {} ·{count}", g.name)
1217 };
1218 div()
1219 .id(SharedString::from(format!("group-{}", g.name)))
1220 .px(px(8.))
1221 .py(px(6.))
1222 .bg(node_bg)
1223 .rounded(px(4.))
1224 .text_color(text)
1225 .text_size(px(13.))
1226 .cursor_pointer()
1227 .hover(|s| s.bg(theme.bg_row_hover))
1228 .child(SharedString::from(label))
1229 .on_click(cx.listener(move |shell, _, _, cx| {
1230 shell.run_command(joined.clone());
1231 cx.notify();
1232 }))
1233 })
1234 .collect();
1235 div()
1236 .id("run-panel")
1237 .w(px(self.left_width))
1238 .flex()
1239 .flex_col()
1240 .gap(px(6.))
1241 .p(px(10.))
1242 .bg(panel)
1243 .child(
1244 div()
1245 .flex()
1246 .flex_row()
1247 .justify_between()
1248 .items_center()
1249 .child(div().text_color(dim).text_size(px(12.)).child("[RUN] grupos"))
1250 .child(
1251 div()
1252 .flex()
1253 .flex_row()
1254 .gap(px(4.))
1255 .items_center()
1256 .child(
1257 div()
1258 .id("save-group")
1259 .px(px(5.))
1260 .text_color(dim)
1261 .cursor_pointer()
1262 .hover(|s| s.text_color(accent))
1263 .child("+")
1264 .on_click(cx.listener(|s, _, _, cx| {
1265 s.line.set_text(":save ");
1266 cx.notify();
1267 })),
1268 )
1269 .child(
1270 div()
1271 .id("collapse-left")
1272 .px(px(5.))
1273 .text_color(dim)
1274 .cursor_pointer()
1275 .hover(|s| s.text_color(accent))
1276 .child("«")
1277 .on_click(cx.listener(|s, _, _, cx| {
1278 s.left_collapsed = true;
1279 cx.notify();
1280 })),
1281 ),
1282 ),
1283 )
1284 .children(groups)
1285 .child(
1286 div()
1287 .text_size(px(10.))
1288 .text_color(dim)
1289 .child("clic ejecuta · + guarda lo último"),
1290 )
1291 };
1292
1293 // --- Lienzo central: comandos ejecutados + su salida ---
1294 // Orden de terminal: los más viejos arriba, los nuevos abajo.
1295 let hist = self.session.history();
1296 let start = hist.len().saturating_sub(40);
1297 let runs: Vec<_> = hist[start..]
1298 .iter()
1299 .map(|r| {
1300 let ui = self.run_ui.get(&r.id).copied().unwrap_or_default();
1301 render_run(r, ui, &theme, node_bg, cx)
1302 })
1303 .collect();
1304 let runs_empty = runs.is_empty();
1305 let canvas = div()
1306 .id("runs")
1307 .flex_1()
1308 .overflow_y_scroll()
1309 .track_scroll(&self.scroll)
1310 .flex()
1311 .flex_col()
1312 .gap(px(8.))
1313 .p(px(10.))
1314 .bg(bg.clone())
1315 .when(runs_empty, |d| {
1316 d.child(div().text_color(dim).child(
1317 "Escribe un comando abajo y presiona Enter — su salida aparece aquí.",
1318 ))
1319 })
1320 .children(runs);
1321
1322 // --- Panel derecho: monitores [SENS] ---
1323 let right = if self.right_collapsed {
1324 div()
1325 .id("expand-right")
1326 .w(px(26.))
1327 .flex()
1328 .flex_col()
1329 .items_center()
1330 .pt(px(8.))
1331 .bg(panel)
1332 .text_color(dim)
1333 .cursor_pointer()
1334 .hover(|s| s.bg(node_bg))
1335 .child("«")
1336 .on_click(cx.listener(|s, _, _, cx| {
1337 s.right_collapsed = false;
1338 cx.notify();
1339 }))
1340 } else {
1341 let cpu = self.snapshot.cpu_percent;
1342 let cpu_curve = self.sampler.cpu_history().values();
1343 let mem_curve = self.sampler.mem_history().values();
1344 let cpu_color = gpui::hsla(190.0 / 360.0, 0.72, 0.62, 1.0);
1345 let mem_color = gpui::hsla(265.0 / 360.0, 0.55, 0.70, 1.0);
1346
1347 let monitor = |title: &str, value: String, curve: Vec<f32>, color: Hsla| {
1348 div()
1349 .flex()
1350 .flex_col()
1351 .gap(px(4.))
1352 .p(px(8.))
1353 .bg(node_bg)
1354 .rounded(px(5.))
1355 .child(
1356 div()
1357 .flex()
1358 .flex_row()
1359 .justify_between()
1360 .items_baseline()
1361 .child(div().text_color(dim).text_size(px(11.)).child(title.to_string()))
1362 .child(div().text_color(color).child(SharedString::from(value))),
1363 )
1364 .child(div().h(px(44.)).child(CurveElement::new(curve, color)))
1365 };
1366
1367 div()
1368 .id("sens-panel")
1369 .w(px(self.right_width))
1370 .flex()
1371 .flex_col()
1372 .gap(px(10.))
1373 .p(px(10.))
1374 .bg(panel)
1375 .text_color(text)
1376 .child(
1377 div()
1378 .flex()
1379 .flex_row()
1380 .justify_between()
1381 .items_center()
1382 .child(div().text_color(dim).text_size(px(12.)).child("[SENS]"))
1383 .child(
1384 div()
1385 .id("collapse-right")
1386 .px(px(5.))
1387 .text_color(dim)
1388 .cursor_pointer()
1389 .hover(|s| s.text_color(accent))
1390 .child("»")
1391 .on_click(cx.listener(|s, _, _, cx| {
1392 s.right_collapsed = true;
1393 cx.notify();
1394 })),
1395 ),
1396 )
1397 .child(monitor("CPU", format!("{cpu:.0} %"), cpu_curve, cpu_color))
1398 .child(monitor(
1399 "MEM",
1400 if self.snapshot.valid {
1401 format!(
1402 "{:.1}/{:.0} GB",
1403 self.snapshot.mem_used_mb as f32 / 1024.0,
1404 self.snapshot.mem_total_mb as f32 / 1024.0
1405 )
1406 } else {
1407 "— GB".to_string()
1408 },
1409 mem_curve,
1410 mem_color,
1411 ))
1412 };
1413
1414 // --- Zona prompt: el input inteligente ---
1415 let mut input_row: Vec<gpui::Div> = vec![div().flex_none().text_color(accent).child("› ")];
1416 let cursor = self.line.cursor();
1417 let tokens = self.line.tokens();
1418 let caret = || div().w(px(2.)).h(px(19.)).bg(accent);
1419 if tokens.is_empty() {
1420 input_row.push(caret());
1421 input_row.push(
1422 div()
1423 .text_color(dim)
1424 .child("escribe un comando… (Tab autocompleta · Enter ejecuta)"),
1425 );
1426 } else {
1427 let mut caret_done = false;
1428 for t in &tokens {
1429 let color = token_color(t.kind, &theme);
1430 if !caret_done && cursor >= t.start && cursor < t.end {
1431 let local = cursor - t.start;
1432 let (left_s, right_s) = t.text.split_at(local);
1433 if !left_s.is_empty() {
1434 input_row
1435 .push(div().flex_none().text_color(color).child(left_s.to_string()));
1436 }
1437 input_row.push(caret());
1438 input_row
1439 .push(div().flex_none().text_color(color).child(right_s.to_string()));
1440 caret_done = true;
1441 } else {
1442 input_row.push(div().flex_none().text_color(color).child(t.text.clone()));
1443 }
1444 }
1445 if !caret_done {
1446 input_row.push(caret());
1447 }
1448 }
1449 // Sugerencia fantasma — el resto que el shell predice, en gris.
1450 if let Some(ghost) = self.compute_ghost() {
1451 input_row.push(
1452 div()
1453 .flex_none()
1454 .text_color(theme.fg_disabled)
1455 .child(SharedString::from(ghost)),
1456 );
1457 }
1458 let input_bar = div()
1459 .h(px(46.))
1460 .flex()
1461 .flex_row()
1462 .items_center()
1463 .px(px(14.))
1464 .text_color(text)
1465 .text_size(px(14.))
1466 .children(input_row);
1467 // Banner del modo reproceso — escribí un filtro para la salida.
1468 let banner = self.reprocess_source.map(|src| {
1469 div()
1470 .px(px(14.))
1471 .py(px(3.))
1472 .bg(gpui::hsla(190.0 / 360.0, 0.30, 0.22, 1.0))
1473 .text_size(px(11.))
1474 .text_color(accent)
1475 .child(SharedString::from(format!(
1476 "⤳ reprocesando la salida de #{src} — escribí un filtro · Esc cancela"
1477 )))
1478 });
1479 let prompt = div()
1480 .flex()
1481 .flex_col()
1482 .bg(panel)
1483 .children(banner)
1484 .child(input_bar);
1485
1486 // --- Popup de autocompletado ---
1487 let mut popup_layer: Vec<gpui::Div> = Vec::new();
1488 if self.show_completion {
1489 if let Some(comp) = &self.completion {
1490 if !comp.candidates.is_empty() {
1491 let kind_label = match comp.kind {
1492 CompletionKind::Command => "comando",
1493 CompletionKind::Flag => "flag",
1494 CompletionKind::Path => "ruta",
1495 };
1496 let total = comp.candidates.len();
1497 let start = self
1498 .completion_index
1499 .saturating_sub(3)
1500 .min(total.saturating_sub(8));
1501 let rows: Vec<_> = comp
1502 .candidates
1503 .iter()
1504 .enumerate()
1505 .skip(start)
1506 .take(8)
1507 .map(|(i, cand)| {
1508 let selected = i == self.completion_index;
1509 div()
1510 .px(px(8.))
1511 .py(px(3.))
1512 .when(selected, |d| {
1513 d.bg(accent).text_color(gpui::hsla(0.0, 0.0, 0.1, 1.0))
1514 })
1515 .when(!selected, |d| d.text_color(text))
1516 .child(SharedString::from(cand.clone()))
1517 })
1518 .collect();
1519 popup_layer.push(
1520 div()
1521 .absolute()
1522 .left(px(28.))
1523 .bottom(px(52.))
1524 .w(px(320.))
1525 .flex()
1526 .flex_col()
1527 .bg(node_bg)
1528 .border_1()
1529 .border_color(accent)
1530 .rounded(px(5.))
1531 .text_size(px(13.))
1532 .child(
1533 div()
1534 .px(px(8.))
1535 .py(px(3.))
1536 .text_color(dim)
1537 .text_size(px(11.))
1538 .child(SharedString::from(format!(
1539 "{kind_label} · {total} · ↑↓ Tab"
1540 ))),
1541 )
1542 .children(rows),
1543 );
1544 }
1545 }
1546 }
1547
1548 // --- Divisores arrastrables ---
1549 let divider = |side: Side, cx: &mut Context<Self>| {
1550 div()
1551 .w(px(5.))
1552 .bg(node_bg)
1553 .cursor(CursorStyle::ResizeLeftRight)
1554 .hover(|s| s.bg(accent))
1555 .on_mouse_down(
1556 MouseButton::Left,
1557 cx.listener(move |shell, ev: &MouseDownEvent, _w, cx| {
1558 let start_w = match side {
1559 Side::Left => shell.left_width,
1560 Side::Right => shell.right_width,
1561 };
1562 shell.drag = Some(Drag {
1563 side,
1564 start_x: ev.position.x.into(),
1565 start_w,
1566 });
1567 cx.notify();
1568 }),
1569 )
1570 };
1571
1572 let mut middle = div().flex().flex_row().flex_1().overflow_hidden().child(left);
1573 if !self.left_collapsed {
1574 middle = middle.child(divider(Side::Left, cx));
1575 }
1576 middle = middle.child(canvas);
1577 if !self.right_collapsed {
1578 middle = middle.child(divider(Side::Right, cx));
1579 }
1580 middle = middle.child(right);
1581
1582 // --- Composición ---
1583 div()
1584 .size_full()
1585 .relative()
1586 .flex()
1587 .flex_col()
1588 .bg(bg)
1589 .track_focus(&self.focus)
1590 .key_context("ShumaShell")
1591 .on_key_down(cx.listener(Self::handle_key))
1592 .on_mouse_move(cx.listener(|shell, ev: &MouseMoveEvent, _w, cx| {
1593 if let Some(drag) = &shell.drag {
1594 let cur: f32 = ev.position.x.into();
1595 let delta = cur - drag.start_x;
1596 match drag.side {
1597 // El panel izquierdo crece al arrastrar a la derecha.
1598 Side::Left => {
1599 shell.left_width = (drag.start_w + delta).clamp(130.0, 420.0)
1600 }
1601 // El derecho crece al arrastrar a la izquierda.
1602 Side::Right => {
1603 shell.right_width = (drag.start_w - delta).clamp(130.0, 420.0)
1604 }
1605 }
1606 cx.notify();
1607 }
1608 }))
1609 .on_mouse_up(
1610 MouseButton::Left,
1611 cx.listener(|shell, _ev: &MouseUpEvent, _w, cx| {
1612 if shell.drag.take().is_some() {
1613 cx.notify();
1614 }
1615 }),
1616 )
1617 .child(status)
1618 .child(middle)
1619 .child(prompt)
1620 .children(popup_layer)
1621 }
1622}
1623
1624fn main() {
1625 launch_app("brahman · shuma shell", (1100., 700.), Shell::new);
1626}