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