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