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