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