⎇ 0fde2aa2732815edaf12c2715bb154ec944d92fe
crates/apps/shuma-shell/src/main.rs
Historia
Quién tocó qué
crudo
feat(shuma-shell): tarjetas acordeón con filtro stdout/stderr
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 /// Scroll del feed central — sigue al comando más reciente.
256 scroll: ScrollHandle,
257 focus: FocusHandle,
258 focused_once: bool,
259}
260
261impl Shell {
262 fn new(cx: &mut Context<Self>) -> Self {
263 let cwd = std::env::current_dir()
264 .map(|p| p.to_string_lossy().into_owned())
265 .unwrap_or_else(|_| "/".to_string());
266
267 let mut session = WorkSession::new("sesión", &cwd);
268 // Grupos de ejemplo — recetas reutilizables.
269 session.save_group("estado git", vec!["git status --short".into()]);
270 session.save_group("build", vec!["cargo build --release".into()]);
271
272 let shell = Self {
273 line: LineState::new(),
274 session,
275 active: Vec::new(),
276 completion: None,
277 completion_index: 0,
278 show_completion: false,
279 source: ShellCompletionSource::scan(cwd),
280 sampler: SystemSampler::new(HISTORY),
281 snapshot: Snapshot {
282 cpu_percent: 0.0,
283 mem_percent: 0.0,
284 mem_used_mb: 0,
285 mem_total_mb: 0,
286 valid: false,
287 },
288 left_collapsed: false,
289 right_collapsed: false,
290 left_width: 176.0,
291 right_width: 188.0,
292 drag: None,
293 run_ui: HashMap::new(),
294 scroll: ScrollHandle::new(),
295 focus: cx.focus_handle(),
296 focused_once: false,
297 };
298 shell.start_loop(cx);
299 shell
300 }
301
302 /// Bucle de fondo: drena la salida de los comandos (~9/s) y refresca
303 /// los monitores (~1/s).
304 fn start_loop(&self, cx: &mut Context<Self>) {
305 cx.spawn(async move |this, cx| {
306 let mut tick: u32 = 0;
307 loop {
308 cx.background_executor().timer(Duration::from_millis(110)).await;
309 tick += 1;
310 let sysmon = tick % 10 == 0;
311 let alive = this.update(cx, |shell, cx| {
312 let mut changed = shell.drain_exec();
313 if sysmon {
314 shell.snapshot = shell.sampler.sample();
315 changed = true;
316 }
317 if changed {
318 cx.notify();
319 }
320 });
321 if alive.is_err() {
322 break;
323 }
324 }
325 })
326 .detach();
327 }
328
329 /// Vuelca la salida disponible de los comandos en curso al historial.
330 fn drain_exec(&mut self) -> bool {
331 let now = unix_now();
332 let mut changed = false;
333 for (id, handle) in &mut self.active {
334 for ev in handle.try_events() {
335 changed = true;
336 match ev {
337 RunEvent::Stdout(l) => {
338 self.session.append_output(*id, Stream::Stdout, l)
339 }
340 RunEvent::Stderr(l) => {
341 self.session.append_output(*id, Stream::Stderr, l)
342 }
343 RunEvent::Exited(code) => self.session.finish_run(*id, code, now),
344 RunEvent::Failed(msg) => {
345 self.session.append_output(
346 *id,
347 Stream::Stderr,
348 format!("✗ no se pudo lanzar: {msg}"),
349 );
350 self.session.finish_run(*id, -1, now);
351 }
352 }
353 }
354 }
355 self.active.retain(|(_, h)| !h.is_finished());
356 if changed {
357 self.scroll.scroll_to_bottom();
358 }
359 changed
360 }
361
362 /// Resuelve el destino de un `cd` contra el cwd de la sesión.
363 fn resolve_cd(&self, arg: &str) -> Result<String, String> {
364 let home = std::env::var("HOME").unwrap_or_else(|_| "/".into());
365 let target = if arg.is_empty() || arg == "~" {
366 home
367 } else if let Some(rest) = arg.strip_prefix("~/") {
368 format!("{home}/{rest}")
369 } else if arg.starts_with('/') {
370 arg.to_string()
371 } else {
372 format!("{}/{}", self.session.cwd(), arg)
373 };
374 match std::fs::canonicalize(&target) {
375 Ok(p) if p.is_dir() => Ok(p.to_string_lossy().into_owned()),
376 Ok(_) => Err(format!("cd: no es un directorio: {target}")),
377 Err(e) => Err(format!("cd: {target}: {e}")),
378 }
379 }
380
381 /// Ejecuta una línea: `cd` se maneja internamente (cambia el cwd y,
382 /// con él, el aislamiento); el resto se lanza con `shuma-exec` y su
383 /// salida se transmite al panel central.
384 fn run_command(&mut self, line: String) {
385 let line = line.trim().to_string();
386 if line.is_empty() {
387 return;
388 }
389 let now = unix_now();
390
391 // Los comandos anteriores que el usuario no fijó se autocolapsan
392 // al aparecer uno nuevo abajo — orden de terminal tradicional.
393 for ui in self.run_ui.values_mut() {
394 if !ui.user_touched {
395 ui.collapsed = true;
396 }
397 }
398
399 // `cd` interno — un subproceso no podría cambiar nuestro cwd.
400 if line == "cd" || line.starts_with("cd ") {
401 let arg = line.strip_prefix("cd").unwrap_or("").trim();
402 let id = self.session.begin_run(&line, now);
403 self.run_ui.insert(id, RunUi::default());
404 match self.resolve_cd(arg) {
405 Ok(new_cwd) => {
406 self.session.set_cwd(new_cwd.clone());
407 self.source.cwd = new_cwd.clone();
408 self.session
409 .append_output(id, Stream::Stdout, format!("→ {new_cwd}"));
410 self.session.finish_run(id, 0, now);
411 }
412 Err(e) => {
413 self.session.append_output(id, Stream::Stderr, e);
414 self.session.finish_run(id, 1, now);
415 }
416 }
417 self.scroll.scroll_to_bottom();
418 return;
419 }
420
421 let id = self.session.begin_run(&line, now);
422 self.run_ui.insert(id, RunUi::default());
423 let spec = CommandSpec::bash(&line, self.session.cwd());
424 self.active.push((id, exec_run(&spec)));
425 self.scroll.scroll_to_bottom();
426 }
427
428 fn refresh_completion(&mut self) {
429 let comp = self.line.complete(&self.source);
430 self.show_completion =
431 !comp.candidates.is_empty() && comp.replace_end > comp.replace_start;
432 self.completion_index = 0;
433 self.completion = Some(comp);
434 }
435
436 fn on_tab(&mut self) {
437 let comp = self.line.complete(&self.source);
438 if comp.candidates.is_empty() {
439 return;
440 }
441 if self.show_completion {
442 let idx = self.completion_index.min(comp.candidates.len() - 1);
443 let candidate = comp.candidates[idx].clone();
444 self.line.apply_completion(&comp, &candidate);
445 self.show_completion = false;
446 self.completion = None;
447 } else {
448 self.completion_index = 0;
449 self.completion = Some(comp);
450 self.show_completion = true;
451 }
452 }
453
454 fn cycle_completion(&mut self, delta: i32) {
455 if !self.show_completion {
456 return;
457 }
458 if let Some(comp) = &self.completion {
459 let n = comp.candidates.len();
460 if n > 0 {
461 let i = self.completion_index as i32 + delta;
462 self.completion_index = i.rem_euclid(n as i32) as usize;
463 }
464 }
465 }
466
467 /// Enter — ejecuta el contenido del input.
468 fn submit(&mut self) {
469 let line = self.line.text().to_string();
470 self.line.clear();
471 self.completion = None;
472 self.show_completion = false;
473 self.run_command(line);
474 }
475
476 fn handle_key(&mut self, event: &KeyDownEvent, _w: &mut Window, cx: &mut Context<Self>) {
477 let ks = &event.keystroke;
478 let key = ks.key.as_str();
479 let ctrl = ks.modifiers.control;
480 let mut changed = false;
481
482 match key {
483 "enter" => {
484 self.submit();
485 cx.notify();
486 return;
487 }
488 "escape" => {
489 self.show_completion = false;
490 cx.notify();
491 return;
492 }
493 "tab" => {
494 self.on_tab();
495 cx.notify();
496 return;
497 }
498 "up" => {
499 self.cycle_completion(-1);
500 cx.notify();
501 return;
502 }
503 "down" => {
504 self.cycle_completion(1);
505 cx.notify();
506 return;
507 }
508 "backspace" => {
509 self.line.backspace();
510 changed = true;
511 }
512 "delete" => {
513 self.line.delete();
514 changed = true;
515 }
516 "left" => {
517 if ctrl {
518 self.line.move_word_left();
519 } else {
520 self.line.move_left();
521 }
522 }
523 "right" => {
524 if ctrl {
525 self.line.move_word_right();
526 } else {
527 self.line.move_right();
528 }
529 }
530 "home" => self.line.move_home(),
531 "end" => self.line.move_end(),
532 "a" if ctrl => self.line.move_home(),
533 "e" if ctrl => self.line.move_end(),
534 "u" if ctrl => {
535 self.line.clear();
536 changed = true;
537 }
538 _ => {
539 if !ctrl {
540 if let Some(ch) = ks.key_char.as_deref() {
541 if !ch.chars().any(|c| c.is_control()) {
542 self.line.insert(ch);
543 changed = true;
544 }
545 }
546 }
547 }
548 }
549
550 if changed {
551 self.refresh_completion();
552 }
553 cx.notify();
554 }
555}
556
557/// Color de resaltado de cada clase de token.
558fn token_color(kind: TokenKind, theme: &Theme) -> Hsla {
559 match kind {
560 TokenKind::Command => gpui::hsla(190.0 / 360.0, 0.65, 0.62, 1.0),
561 TokenKind::Argument => theme.fg_text,
562 TokenKind::Flag => gpui::hsla(38.0 / 360.0, 0.80, 0.62, 1.0),
563 TokenKind::StringLit => gpui::hsla(95.0 / 360.0, 0.42, 0.60, 1.0),
564 TokenKind::Variable => gpui::hsla(280.0 / 360.0, 0.55, 0.72, 1.0),
565 TokenKind::Pipe => gpui::hsla(190.0 / 360.0, 0.90, 0.72, 1.0),
566 TokenKind::Redirect => gpui::hsla(20.0 / 360.0, 0.78, 0.62, 1.0),
567 TokenKind::Operator => gpui::hsla(0.0, 0.66, 0.66, 1.0),
568 TokenKind::Comment => theme.fg_muted,
569 TokenKind::Whitespace => theme.fg_text,
570 TokenKind::Unknown => gpui::hsla(0.0, 0.70, 0.60, 1.0),
571 }
572}
573
574/// Resalta un texto estático (la línea de un comando del historial).
575fn highlight(text: &str, theme: &Theme) -> Vec<gpui::Div> {
576 shuma_line::tokenize(text, shuma_line::Dialect::Bash)
577 .into_iter()
578 .map(|t| {
579 div()
580 .flex_none()
581 .text_color(token_color(t.kind, theme))
582 .child(t.text)
583 })
584 .collect()
585}
586
587/// Acorta el cwd: el `$HOME` se muestra como `~`.
588fn pretty_cwd(cwd: &str) -> String {
589 match std::env::var("HOME") {
590 Ok(home) if cwd == home => "~".to_string(),
591 Ok(home) if cwd.starts_with(&format!("{home}/")) => format!("~{}", &cwd[home.len()..]),
592 _ => cwd.to_string(),
593 }
594}
595
596/// Renderiza la tarjeta de un comando ejecutado: cabecera-acordeón +
597/// filtro stdout/stderr + cuerpo de salida.
598fn render_run(
599 r: &CommandRun,
600 ui: RunUi,
601 theme: &Theme,
602 node_bg: Hsla,
603 cx: &mut Context<Shell>,
604) -> impl IntoElement {
605 let id = r.id;
606 let dim = theme.fg_muted;
607 let (glyph, gcolor) = match r.status {
608 RunStatus::Running => ("▷", gpui::hsla(45.0 / 360.0, 0.75, 0.60, 1.0)),
609 RunStatus::Ok => ("✓", gpui::hsla(140.0 / 360.0, 0.48, 0.55, 1.0)),
610 RunStatus::Failed => ("✗", gpui::hsla(2.0 / 360.0, 0.68, 0.60, 1.0)),
611 };
612 let stderr_color = gpui::hsla(8.0 / 360.0, 0.62, 0.66, 1.0);
613
614 // Nota a la derecha: código de salida, y al colapsar, conteo de líneas.
615 let mut note = match r.exit_code {
616 Some(0) | None => String::new(),
617 Some(c) => format!("salió {c}"),
618 };
619 if ui.collapsed {
620 let n = r.count_of(Stream::Stdout);
621 if n > 0 {
622 note = format!("{note} · {n} líneas").trim_start().to_string();
623 }
624 }
625
626 // Cabecera-acordeón: un clic colapsa/expande.
627 let caret = if ui.collapsed { "▸" } else { "▾" };
628 let header_left = div()
629 .id(SharedString::from(format!("hdr-{id}")))
630 .flex()
631 .flex_row()
632 .items_center()
633 .gap(px(6.))
634 .flex_1()
635 .cursor_pointer()
636 .child(div().flex_none().text_color(dim).child(caret))
637 .child(div().flex_none().text_color(gcolor).child(glyph))
638 .children(highlight(&r.line, theme))
639 .child(
640 div()
641 .flex_none()
642 .text_size(px(11.))
643 .text_color(dim)
644 .child(SharedString::from(note)),
645 )
646 .on_click(cx.listener(move |shell, _, _, cx| {
647 let e = shell.run_ui.entry(id).or_default();
648 e.collapsed = !e.collapsed;
649 e.user_touched = true;
650 cx.notify();
651 }));
652
653 // Filtro de stderr — sólo aparece si el comando emitió errores.
654 let stderr_chip = if r.has_stderr() {
655 let n = r.count_of(Stream::Stderr);
656 Some(
657 div()
658 .id(SharedString::from(format!("err-{id}")))
659 .flex_none()
660 .px(px(6.))
661 .py(px(1.))
662 .rounded(px(3.))
663 .text_size(px(11.))
664 .cursor_pointer()
665 .when(ui.show_stderr, |d| {
666 d.bg(stderr_color).text_color(gpui::hsla(0.0, 0.0, 0.12, 1.0))
667 })
668 .when(!ui.show_stderr, |d| d.text_color(stderr_color))
669 .child(SharedString::from(format!("⚠ {n}")))
670 .on_click(cx.listener(move |shell, _, _, cx| {
671 let e = shell.run_ui.entry(id).or_default();
672 e.show_stderr = !e.show_stderr;
673 cx.notify();
674 })),
675 )
676 } else {
677 None
678 };
679
680 let header = div()
681 .flex()
682 .flex_row()
683 .items_center()
684 .gap(px(6.))
685 .child(header_left)
686 .children(stderr_chip);
687
688 // Cuerpo: sólo con el acordeón abierto. El filtro elige el flujo.
689 let mut body: Vec<gpui::Div> = Vec::new();
690 if !ui.collapsed {
691 let stream = if ui.show_stderr { Stream::Stderr } else { Stream::Stdout };
692 let lines: Vec<&str> = r.lines_of(stream).collect();
693 let color = if ui.show_stderr { stderr_color } else { theme.fg_text };
694 if lines.is_empty() {
695 body.push(div().text_size(px(11.)).text_color(dim).child(
696 if ui.show_stderr { "sin errores" } else { "sin salida" },
697 ));
698 } else {
699 let skipped = lines.len().saturating_sub(OUTPUT_LINES);
700 if skipped > 0 {
701 body.push(
702 div()
703 .text_size(px(11.))
704 .text_color(dim)
705 .child(SharedString::from(format!("… {skipped} líneas antes"))),
706 );
707 }
708 for l in lines.iter().skip(skipped) {
709 body.push(
710 div()
711 .text_size(px(12.))
712 .text_color(color)
713 .child(SharedString::from(l.to_string())),
714 );
715 }
716 }
717 }
718
719 div()
720 .flex()
721 .flex_col()
722 .gap(px(3.))
723 .p(px(8.))
724 .bg(node_bg)
725 .border_l_2()
726 .border_color(gcolor)
727 .rounded(px(5.))
728 .child(header)
729 .children(body)
730}
731
732impl Render for Shell {
733 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
734 if !self.focused_once {
735 window.focus(&self.focus);
736 self.focused_once = true;
737 }
738 let theme = Theme::global(cx).clone();
739 let bg = theme.bg_app.clone();
740 let panel = gpui::hsla(220.0 / 360.0, 0.16, 0.11, 1.0);
741 let node_bg = gpui::hsla(220.0 / 360.0, 0.14, 0.16, 1.0);
742 let accent = gpui::hsla(190.0 / 360.0, 0.70, 0.62, 1.0);
743 let text = theme.fg_text;
744 let dim = theme.fg_muted;
745
746 let pipeline = self.line.pipeline();
747 let piped = pipeline.stages.iter().filter(|s| s.command.is_some()).count();
748
749 // --- Barra de estado: cwd + identificador de aislamiento ---
750 let status = div()
751 .h(px(32.))
752 .flex()
753 .flex_row()
754 .items_center()
755 .justify_between()
756 .px(px(14.))
757 .bg(panel)
758 .text_color(text)
759 .child(
760 div()
761 .flex()
762 .flex_row()
763 .gap(px(10.))
764 .items_baseline()
765 .child("● shuma")
766 .child(div().text_color(accent).child(SharedString::from(format!(
767 "📁 {}",
768 pretty_cwd(self.session.cwd())
769 ))))
770 .child(div().text_color(dim).text_size(px(11.)).child(SharedString::from(
771 format!("aisl:{}", self.session.isolation_id()),
772 ))),
773 )
774 .child(
775 div().text_color(dim).text_size(px(12.)).child(SharedString::from(
776 if piped > 1 {
777 format!("⇄ {piped} etapas · {} en curso", self.active.len())
778 } else {
779 format!("{} en curso", self.active.len())
780 },
781 )),
782 );
783
784 // --- Panel izquierdo: grupos reutilizables [RUN] ---
785 let left = if self.left_collapsed {
786 div()
787 .id("expand-left")
788 .w(px(26.))
789 .flex()
790 .flex_col()
791 .items_center()
792 .pt(px(8.))
793 .bg(panel)
794 .text_color(dim)
795 .cursor_pointer()
796 .hover(|s| s.bg(node_bg))
797 .child("»")
798 .on_click(cx.listener(|s, _, _, cx| {
799 s.left_collapsed = false;
800 cx.notify();
801 }))
802 } else {
803 let groups: Vec<_> = self
804 .session
805 .groups()
806 .iter()
807 .map(|g| {
808 let joined = g.lines.join(" && ");
809 let count = g.lines.len();
810 div()
811 .id(SharedString::from(format!("group-{}", g.name)))
812 .px(px(8.))
813 .py(px(6.))
814 .bg(node_bg)
815 .rounded(px(4.))
816 .text_color(text)
817 .text_size(px(13.))
818 .cursor_pointer()
819 .hover(|s| s.bg(theme.bg_row_hover))
820 .child(SharedString::from(format!("▸ {} ·{count}", g.name)))
821 .on_click(cx.listener(move |shell, _, _, cx| {
822 shell.run_command(joined.clone());
823 cx.notify();
824 }))
825 })
826 .collect();
827 div()
828 .id("run-panel")
829 .w(px(self.left_width))
830 .flex()
831 .flex_col()
832 .gap(px(6.))
833 .p(px(10.))
834 .bg(panel)
835 .child(
836 div()
837 .flex()
838 .flex_row()
839 .justify_between()
840 .items_center()
841 .child(div().text_color(dim).text_size(px(12.)).child("[RUN] grupos"))
842 .child(
843 div()
844 .id("collapse-left")
845 .px(px(5.))
846 .text_color(dim)
847 .cursor_pointer()
848 .hover(|s| s.text_color(accent))
849 .child("«")
850 .on_click(cx.listener(|s, _, _, cx| {
851 s.left_collapsed = true;
852 cx.notify();
853 })),
854 ),
855 )
856 .children(groups)
857 .child(
858 div()
859 .text_size(px(10.))
860 .text_color(dim)
861 .child("clic para ejecutar el grupo"),
862 )
863 };
864
865 // --- Lienzo central: comandos ejecutados + su salida ---
866 // Orden de terminal: los más viejos arriba, los nuevos abajo.
867 let hist = self.session.history();
868 let start = hist.len().saturating_sub(40);
869 let runs: Vec<_> = hist[start..]
870 .iter()
871 .map(|r| {
872 let ui = self.run_ui.get(&r.id).copied().unwrap_or_default();
873 render_run(r, ui, &theme, node_bg, cx)
874 })
875 .collect();
876 let runs_empty = runs.is_empty();
877 let canvas = div()
878 .id("runs")
879 .flex_1()
880 .overflow_y_scroll()
881 .track_scroll(&self.scroll)
882 .flex()
883 .flex_col()
884 .gap(px(8.))
885 .p(px(10.))
886 .bg(bg.clone())
887 .when(runs_empty, |d| {
888 d.child(div().text_color(dim).child(
889 "Escribe un comando abajo y presiona Enter — su salida aparece aquí.",
890 ))
891 })
892 .children(runs);
893
894 // --- Panel derecho: monitores [SENS] ---
895 let right = if self.right_collapsed {
896 div()
897 .id("expand-right")
898 .w(px(26.))
899 .flex()
900 .flex_col()
901 .items_center()
902 .pt(px(8.))
903 .bg(panel)
904 .text_color(dim)
905 .cursor_pointer()
906 .hover(|s| s.bg(node_bg))
907 .child("«")
908 .on_click(cx.listener(|s, _, _, cx| {
909 s.right_collapsed = false;
910 cx.notify();
911 }))
912 } else {
913 let cpu = self.snapshot.cpu_percent;
914 let cpu_curve = self.sampler.cpu_history().values();
915 let mem_curve = self.sampler.mem_history().values();
916 let cpu_color = gpui::hsla(190.0 / 360.0, 0.72, 0.62, 1.0);
917 let mem_color = gpui::hsla(265.0 / 360.0, 0.55, 0.70, 1.0);
918
919 let monitor = |title: &str, value: String, curve: Vec<f32>, color: Hsla| {
920 div()
921 .flex()
922 .flex_col()
923 .gap(px(4.))
924 .p(px(8.))
925 .bg(node_bg)
926 .rounded(px(5.))
927 .child(
928 div()
929 .flex()
930 .flex_row()
931 .justify_between()
932 .items_baseline()
933 .child(div().text_color(dim).text_size(px(11.)).child(title.to_string()))
934 .child(div().text_color(color).child(SharedString::from(value))),
935 )
936 .child(div().h(px(44.)).child(CurveElement::new(curve, color)))
937 };
938
939 div()
940 .id("sens-panel")
941 .w(px(self.right_width))
942 .flex()
943 .flex_col()
944 .gap(px(10.))
945 .p(px(10.))
946 .bg(panel)
947 .text_color(text)
948 .child(
949 div()
950 .flex()
951 .flex_row()
952 .justify_between()
953 .items_center()
954 .child(div().text_color(dim).text_size(px(12.)).child("[SENS]"))
955 .child(
956 div()
957 .id("collapse-right")
958 .px(px(5.))
959 .text_color(dim)
960 .cursor_pointer()
961 .hover(|s| s.text_color(accent))
962 .child("»")
963 .on_click(cx.listener(|s, _, _, cx| {
964 s.right_collapsed = true;
965 cx.notify();
966 })),
967 ),
968 )
969 .child(monitor("CPU", format!("{cpu:.0} %"), cpu_curve, cpu_color))
970 .child(monitor(
971 "MEM",
972 if self.snapshot.valid {
973 format!(
974 "{:.1}/{:.0} GB",
975 self.snapshot.mem_used_mb as f32 / 1024.0,
976 self.snapshot.mem_total_mb as f32 / 1024.0
977 )
978 } else {
979 "— GB".to_string()
980 },
981 mem_curve,
982 mem_color,
983 ))
984 };
985
986 // --- Zona prompt: el input inteligente ---
987 let mut input_row: Vec<gpui::Div> = vec![div().flex_none().text_color(accent).child("› ")];
988 let cursor = self.line.cursor();
989 let tokens = self.line.tokens();
990 let caret = || div().w(px(2.)).h(px(19.)).bg(accent);
991 if tokens.is_empty() {
992 input_row.push(caret());
993 input_row.push(
994 div()
995 .text_color(dim)
996 .child("escribe un comando… (Tab autocompleta · Enter ejecuta)"),
997 );
998 } else {
999 let mut caret_done = false;
1000 for t in &tokens {
1001 let color = token_color(t.kind, &theme);
1002 if !caret_done && cursor >= t.start && cursor < t.end {
1003 let local = cursor - t.start;
1004 let (left_s, right_s) = t.text.split_at(local);
1005 if !left_s.is_empty() {
1006 input_row
1007 .push(div().flex_none().text_color(color).child(left_s.to_string()));
1008 }
1009 input_row.push(caret());
1010 input_row
1011 .push(div().flex_none().text_color(color).child(right_s.to_string()));
1012 caret_done = true;
1013 } else {
1014 input_row.push(div().flex_none().text_color(color).child(t.text.clone()));
1015 }
1016 }
1017 if !caret_done {
1018 input_row.push(caret());
1019 }
1020 }
1021 let prompt = div()
1022 .h(px(46.))
1023 .flex()
1024 .flex_row()
1025 .items_center()
1026 .px(px(14.))
1027 .bg(panel)
1028 .text_color(text)
1029 .text_size(px(14.))
1030 .children(input_row);
1031
1032 // --- Popup de autocompletado ---
1033 let mut popup_layer: Vec<gpui::Div> = Vec::new();
1034 if self.show_completion {
1035 if let Some(comp) = &self.completion {
1036 if !comp.candidates.is_empty() {
1037 let kind_label = match comp.kind {
1038 CompletionKind::Command => "comando",
1039 CompletionKind::Flag => "flag",
1040 CompletionKind::Path => "ruta",
1041 };
1042 let total = comp.candidates.len();
1043 let start = self
1044 .completion_index
1045 .saturating_sub(3)
1046 .min(total.saturating_sub(8));
1047 let rows: Vec<_> = comp
1048 .candidates
1049 .iter()
1050 .enumerate()
1051 .skip(start)
1052 .take(8)
1053 .map(|(i, cand)| {
1054 let selected = i == self.completion_index;
1055 div()
1056 .px(px(8.))
1057 .py(px(3.))
1058 .when(selected, |d| {
1059 d.bg(accent).text_color(gpui::hsla(0.0, 0.0, 0.1, 1.0))
1060 })
1061 .when(!selected, |d| d.text_color(text))
1062 .child(SharedString::from(cand.clone()))
1063 })
1064 .collect();
1065 popup_layer.push(
1066 div()
1067 .absolute()
1068 .left(px(28.))
1069 .bottom(px(52.))
1070 .w(px(320.))
1071 .flex()
1072 .flex_col()
1073 .bg(node_bg)
1074 .border_1()
1075 .border_color(accent)
1076 .rounded(px(5.))
1077 .text_size(px(13.))
1078 .child(
1079 div()
1080 .px(px(8.))
1081 .py(px(3.))
1082 .text_color(dim)
1083 .text_size(px(11.))
1084 .child(SharedString::from(format!(
1085 "{kind_label} · {total} · ↑↓ Tab"
1086 ))),
1087 )
1088 .children(rows),
1089 );
1090 }
1091 }
1092 }
1093
1094 // --- Divisores arrastrables ---
1095 let divider = |side: Side, cx: &mut Context<Self>| {
1096 div()
1097 .w(px(5.))
1098 .bg(node_bg)
1099 .cursor(CursorStyle::ResizeLeftRight)
1100 .hover(|s| s.bg(accent))
1101 .on_mouse_down(
1102 MouseButton::Left,
1103 cx.listener(move |shell, ev: &MouseDownEvent, _w, cx| {
1104 let start_w = match side {
1105 Side::Left => shell.left_width,
1106 Side::Right => shell.right_width,
1107 };
1108 shell.drag = Some(Drag {
1109 side,
1110 start_x: ev.position.x.into(),
1111 start_w,
1112 });
1113 cx.notify();
1114 }),
1115 )
1116 };
1117
1118 let mut middle = div().flex().flex_row().flex_1().overflow_hidden().child(left);
1119 if !self.left_collapsed {
1120 middle = middle.child(divider(Side::Left, cx));
1121 }
1122 middle = middle.child(canvas);
1123 if !self.right_collapsed {
1124 middle = middle.child(divider(Side::Right, cx));
1125 }
1126 middle = middle.child(right);
1127
1128 // --- Composición ---
1129 div()
1130 .size_full()
1131 .relative()
1132 .flex()
1133 .flex_col()
1134 .bg(bg)
1135 .track_focus(&self.focus)
1136 .key_context("ShumaShell")
1137 .on_key_down(cx.listener(Self::handle_key))
1138 .on_mouse_move(cx.listener(|shell, ev: &MouseMoveEvent, _w, cx| {
1139 if let Some(drag) = &shell.drag {
1140 let cur: f32 = ev.position.x.into();
1141 let delta = cur - drag.start_x;
1142 match drag.side {
1143 // El panel izquierdo crece al arrastrar a la derecha.
1144 Side::Left => {
1145 shell.left_width = (drag.start_w + delta).clamp(130.0, 420.0)
1146 }
1147 // El derecho crece al arrastrar a la izquierda.
1148 Side::Right => {
1149 shell.right_width = (drag.start_w - delta).clamp(130.0, 420.0)
1150 }
1151 }
1152 cx.notify();
1153 }
1154 }))
1155 .on_mouse_up(
1156 MouseButton::Left,
1157 cx.listener(|shell, _ev: &MouseUpEvent, _w, cx| {
1158 if shell.drag.take().is_some() {
1159 cx.notify();
1160 }
1161 }),
1162 )
1163 .child(status)
1164 .child(middle)
1165 .child(prompt)
1166 .children(popup_layer)
1167 }
1168}
1169
1170fn main() {
1171 launch_app("brahman · shuma shell", (1100., 700.), Shell::new);
1172}