⎇ 0f2f1033eb3498e3d2635cd89fb4ba6899165d95
crates/apps/shuma-shell/src/main.rs
Historia
Quién tocó qué
crudo
feat(shuma-shell): el shell, vivo — input inteligente + monitores
1//! `shuma-shell` — el shell de brahman, vivo.
2//!
3//! Tres zonas alrededor de su función principal, el input de abajo:
4//!
5//! ```text
6//! ┌─ estado ─────────────────────────────────────────┐
7//! │ [RUN] │ Lienzo de Contexto │ [SENS] │
8//! │ macros │ (grafo de intenciones) │ monitores │
9//! └─ prompt inteligente ─────────────────────────────┘
10//! ```
11//!
12//! El input no es un campo de texto tonto: `shuma-line` analiza la línea
13//! bash mientras se escribe —resaltado por token, autocompletado
14//! posicional, descomposición de los pipes—. Los monitores de la derecha
15//! grafican CPU y memoria con `shuma-sysmon`. Toda la lógica vive en
16//! crates agnósticos; este binario sólo es el frontend GPUI.
17
18use std::panic;
19use std::time::Duration;
20
21use gpui::{
22 div, point, prelude::*, px, App, Bounds, Context, Element, ElementId, FocusHandle,
23 GlobalElementId, Hsla, InspectorElementId, IntoElement, KeyDownEvent, LayoutId, PathBuilder,
24 Pixels, Render, SharedString, Style, Window,
25};
26use nahual_launcher::launch_app;
27use nahual_theme::Theme;
28use shuma_intent::{Macro, MacroBook, NodeStatus, SessionGraph};
29use shuma_line::{CompletionKind, CompletionSource, LineState, TokenKind};
30use shuma_shell_render::{layout, LayoutParams};
31use shuma_sysmon::{Snapshot, SystemSampler};
32
33/// Cuántas muestras guarda la curva de cada monitor.
34const HISTORY: usize = 80;
35
36// =====================================================================
37// Fuente de autocompletado — la parte que sí toca el sistema.
38// =====================================================================
39
40/// Provee candidatos reales: comandos del `PATH` y rutas del disco.
41struct ShellCompletionSource {
42 commands: Vec<String>,
43}
44
45impl ShellCompletionSource {
46 /// Escanea el `PATH` una vez al arrancar.
47 fn scan() -> Self {
48 let mut commands = Vec::new();
49 if let Ok(path) = std::env::var("PATH") {
50 for dir in path.split(':') {
51 if let Ok(entries) = std::fs::read_dir(dir) {
52 for e in entries.flatten() {
53 if let Some(name) = e.file_name().to_str() {
54 commands.push(name.to_string());
55 }
56 }
57 }
58 }
59 }
60 commands.sort();
61 commands.dedup();
62 Self { commands }
63 }
64}
65
66impl CompletionSource for ShellCompletionSource {
67 fn commands(&self) -> Vec<String> {
68 self.commands.clone()
69 }
70
71 fn paths(&self, prefix: &str) -> Vec<String> {
72 let (dir, partial) = match prefix.rfind('/') {
73 Some(i) => (&prefix[..=i], &prefix[i + 1..]),
74 None => ("", prefix),
75 };
76 let read_from = if dir.is_empty() { "." } else { dir };
77 let mut out = Vec::new();
78 if let Ok(entries) = std::fs::read_dir(read_from) {
79 for e in entries.flatten() {
80 if let Some(name) = e.file_name().to_str() {
81 if name.starts_with(partial) {
82 let is_dir = e.file_type().map(|t| t.is_dir()).unwrap_or(false);
83 let slash = if is_dir { "/" } else { "" };
84 out.push(format!("{dir}{name}{slash}"));
85 }
86 }
87 }
88 }
89 out.sort();
90 out
91 }
92}
93
94// =====================================================================
95// CurveElement — la "curvita" de un monitor.
96// =====================================================================
97
98/// `Element` GPUI que pinta una serie `0..=100` como una curva.
99struct CurveElement {
100 values: Vec<f32>,
101 color: Hsla,
102}
103
104impl CurveElement {
105 fn new(values: Vec<f32>, color: Hsla) -> Self {
106 Self { values, color }
107 }
108}
109
110impl IntoElement for CurveElement {
111 type Element = Self;
112 fn into_element(self) -> Self::Element {
113 self
114 }
115}
116
117impl Element for CurveElement {
118 type RequestLayoutState = ();
119 type PrepaintState = ();
120
121 fn id(&self) -> Option<ElementId> {
122 None
123 }
124 fn source_location(&self) -> Option<&'static panic::Location<'static>> {
125 None
126 }
127
128 fn request_layout(
129 &mut self,
130 _id: Option<&GlobalElementId>,
131 _inspector: Option<&InspectorElementId>,
132 window: &mut Window,
133 cx: &mut App,
134 ) -> (LayoutId, ()) {
135 let mut style = Style::default();
136 style.size.width = gpui::Length::Definite(gpui::DefiniteLength::Fraction(1.0));
137 style.size.height = gpui::Length::Definite(gpui::DefiniteLength::Fraction(1.0));
138 (window.request_layout(style, [], cx), ())
139 }
140
141 fn prepaint(
142 &mut self,
143 _id: Option<&GlobalElementId>,
144 _inspector: Option<&InspectorElementId>,
145 _bounds: Bounds<Pixels>,
146 _layout: &mut (),
147 _window: &mut Window,
148 _cx: &mut App,
149 ) {
150 }
151
152 fn paint(
153 &mut self,
154 _id: Option<&GlobalElementId>,
155 _inspector: Option<&InspectorElementId>,
156 bounds: Bounds<Pixels>,
157 _layout: &mut (),
158 _prepaint: &mut (),
159 window: &mut Window,
160 _cx: &mut App,
161 ) {
162 let n = self.values.len();
163 if n < 2 {
164 return;
165 }
166 let ox: f32 = bounds.origin.x.into();
167 let oy: f32 = bounds.origin.y.into();
168 let bw: f32 = bounds.size.width.into();
169 let bh: f32 = bounds.size.height.into();
170
171 let mut pb = PathBuilder::stroke(px(1.6));
172 for (i, v) in self.values.iter().enumerate() {
173 let x = ox + bw * (i as f32 / (n - 1) as f32);
174 let y = oy + bh - (v.clamp(0.0, 100.0) / 100.0) * bh;
175 let p = point(px(x), px(y));
176 if i == 0 {
177 pb.move_to(p);
178 } else {
179 pb.line_to(p);
180 }
181 }
182 if let Ok(path) = pb.build() {
183 window.paint_path(path, self.color);
184 }
185 }
186}
187
188// =====================================================================
189// El shell.
190// =====================================================================
191
192struct Shell {
193 /// El input inteligente — texto, cursor, análisis.
194 line: LineState,
195 /// Lienzo: el grafo de intenciones de la sesión.
196 session: SessionGraph,
197 macros: MacroBook,
198 /// Autocompletado vigente y el candidato seleccionado.
199 completion: Option<shuma_line::Completion>,
200 completion_index: usize,
201 show_completion: bool,
202 source: ShellCompletionSource,
203 /// Muestreo de CPU/memoria.
204 sampler: SystemSampler,
205 snapshot: Snapshot,
206 /// Estado de los paneles laterales.
207 left_collapsed: bool,
208 right_collapsed: bool,
209 focus: FocusHandle,
210 focused_once: bool,
211}
212
213impl Shell {
214 fn new(cx: &mut Context<Self>) -> Self {
215 // Datos de ejemplo para que el lienzo no nazca vacío.
216 let mut session = SessionGraph::new();
217 let c1 = session.record("ssh remote 'cat data.json'");
218 session.complete(c1, true, 2_400_000);
219 let c2 = session.record("sort | %p1");
220 session.complete(c2, true, 2_390_000);
221
222 let mut macros = MacroBook::new();
223 macros.insert(Macro::new("build").bind("F1").step("cargo build --release"));
224 macros.insert(Macro::new("deploy").bind("F2").step("scp target host:/srv"));
225 macros.insert(Macro::new("clean").bind("F3").step("cargo clean"));
226
227 let shell = Self {
228 line: LineState::new(),
229 session,
230 macros,
231 completion: None,
232 completion_index: 0,
233 show_completion: false,
234 source: ShellCompletionSource::scan(),
235 sampler: SystemSampler::new(HISTORY),
236 snapshot: Snapshot {
237 cpu_percent: 0.0,
238 mem_percent: 0.0,
239 mem_used_mb: 0,
240 mem_total_mb: 0,
241 valid: false,
242 },
243 left_collapsed: false,
244 right_collapsed: false,
245 focus: cx.focus_handle(),
246 focused_once: false,
247 };
248 shell.start_sampler(cx);
249 shell
250 }
251
252 /// Bucle de fondo que refresca los monitores ~1 vez por segundo.
253 fn start_sampler(&self, cx: &mut Context<Self>) {
254 cx.spawn(async move |this, cx| loop {
255 cx.background_executor().timer(Duration::from_millis(1100)).await;
256 let alive = this.update(cx, |shell, cx| {
257 shell.snapshot = shell.sampler.sample();
258 cx.notify();
259 });
260 if alive.is_err() {
261 break;
262 }
263 })
264 .detach();
265 }
266
267 /// Recalcula el autocompletado tras un cambio en la línea.
268 fn refresh_completion(&mut self) {
269 let comp = self.line.complete(&self.source);
270 // El popup se muestra solo si hay una palabra parcial en curso.
271 self.show_completion =
272 !comp.candidates.is_empty() && comp.replace_end > comp.replace_start;
273 self.completion_index = 0;
274 self.completion = Some(comp);
275 }
276
277 /// Tab: muestra el popup, o aplica el candidato seleccionado si ya
278 /// estaba visible.
279 fn on_tab(&mut self) {
280 let comp = self.line.complete(&self.source);
281 if comp.candidates.is_empty() {
282 return;
283 }
284 if self.show_completion {
285 let idx = self.completion_index.min(comp.candidates.len() - 1);
286 let candidate = comp.candidates[idx].clone();
287 self.line.apply_completion(&comp, &candidate);
288 self.show_completion = false;
289 self.completion = None;
290 } else {
291 self.completion_index = 0;
292 self.completion = Some(comp);
293 self.show_completion = true;
294 }
295 }
296
297 /// Mueve la selección del popup.
298 fn cycle_completion(&mut self, delta: i32) {
299 if !self.show_completion {
300 return;
301 }
302 if let Some(comp) = &self.completion {
303 let n = comp.candidates.len();
304 if n > 0 {
305 let i = self.completion_index as i32 + delta;
306 self.completion_index = i.rem_euclid(n as i32) as usize;
307 }
308 }
309 }
310
311 /// Enter: registra la línea como una intención en el lienzo.
312 fn submit(&mut self) {
313 let cmd = self.line.text().trim().to_string();
314 if !cmd.is_empty() {
315 let id = self.session.record(&cmd);
316 self.session.complete(id, true, 0);
317 }
318 self.line.clear();
319 self.completion = None;
320 self.show_completion = false;
321 }
322
323 fn handle_key(&mut self, event: &KeyDownEvent, _w: &mut Window, cx: &mut Context<Self>) {
324 let ks = &event.keystroke;
325 let key = ks.key.as_str();
326 let ctrl = ks.modifiers.control;
327 let mut changed = false;
328
329 match key {
330 "enter" => {
331 self.submit();
332 cx.notify();
333 return;
334 }
335 "escape" => {
336 self.show_completion = false;
337 cx.notify();
338 return;
339 }
340 "tab" => {
341 self.on_tab();
342 cx.notify();
343 return;
344 }
345 "up" => {
346 self.cycle_completion(-1);
347 cx.notify();
348 return;
349 }
350 "down" => {
351 self.cycle_completion(1);
352 cx.notify();
353 return;
354 }
355 "backspace" => {
356 self.line.backspace();
357 changed = true;
358 }
359 "delete" => {
360 self.line.delete();
361 changed = true;
362 }
363 "left" => {
364 if ctrl {
365 self.line.move_word_left();
366 } else {
367 self.line.move_left();
368 }
369 }
370 "right" => {
371 if ctrl {
372 self.line.move_word_right();
373 } else {
374 self.line.move_right();
375 }
376 }
377 "home" => self.line.move_home(),
378 "end" => self.line.move_end(),
379 "a" if ctrl => self.line.move_home(),
380 "e" if ctrl => self.line.move_end(),
381 "u" if ctrl => {
382 self.line.clear();
383 changed = true;
384 }
385 _ => {
386 if !ctrl {
387 if let Some(ch) = ks.key_char.as_deref() {
388 if !ch.chars().any(|c| c.is_control()) {
389 self.line.insert(ch);
390 changed = true;
391 }
392 }
393 }
394 }
395 }
396
397 if changed {
398 self.refresh_completion();
399 }
400 cx.notify();
401 }
402}
403
404/// Color de resaltado de cada clase de token.
405fn token_color(kind: TokenKind, theme: &Theme) -> Hsla {
406 match kind {
407 TokenKind::Command => gpui::hsla(190.0 / 360.0, 0.65, 0.62, 1.0),
408 TokenKind::Argument => theme.fg_text,
409 TokenKind::Flag => gpui::hsla(38.0 / 360.0, 0.80, 0.62, 1.0),
410 TokenKind::StringLit => gpui::hsla(95.0 / 360.0, 0.42, 0.60, 1.0),
411 TokenKind::Variable => gpui::hsla(280.0 / 360.0, 0.55, 0.72, 1.0),
412 TokenKind::Pipe => gpui::hsla(190.0 / 360.0, 0.90, 0.72, 1.0),
413 TokenKind::Redirect => gpui::hsla(20.0 / 360.0, 0.78, 0.62, 1.0),
414 TokenKind::Operator => gpui::hsla(0.0, 0.66, 0.66, 1.0),
415 TokenKind::Comment => theme.fg_muted,
416 TokenKind::Whitespace => theme.fg_text,
417 TokenKind::Unknown => gpui::hsla(0.0, 0.70, 0.60, 1.0),
418 }
419}
420
421/// Estado del nodo del lienzo → color de borde.
422fn status_rgb(s: NodeStatus) -> Hsla {
423 match s {
424 NodeStatus::Running => gpui::hsla(45.0 / 360.0, 0.70, 0.55, 1.0),
425 NodeStatus::Ok => gpui::hsla(140.0 / 360.0, 0.45, 0.52, 1.0),
426 NodeStatus::Failed => gpui::hsla(2.0 / 360.0, 0.65, 0.55, 1.0),
427 }
428}
429
430impl Render for Shell {
431 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
432 if !self.focused_once {
433 window.focus(&self.focus);
434 self.focused_once = true;
435 }
436 let theme = Theme::global(cx).clone();
437 let bg = theme.bg_app.clone();
438 let panel = gpui::hsla(220.0 / 360.0, 0.16, 0.11, 1.0);
439 let node_bg = gpui::hsla(220.0 / 360.0, 0.14, 0.16, 1.0);
440 let accent = gpui::hsla(190.0 / 360.0, 0.70, 0.62, 1.0);
441 let text = theme.fg_text;
442 let dim = theme.fg_muted;
443
444 let pipeline = self.line.pipeline();
445 let stage_count = pipeline.stages.iter().filter(|s| s.command.is_some()).count();
446
447 // --- Zona de estado ---
448 let pipe_note = if pipeline.is_piped() {
449 format!(" · ⇄ {stage_count} etapas")
450 } else {
451 String::new()
452 };
453 let status = div()
454 .h(px(32.))
455 .flex()
456 .flex_row()
457 .items_center()
458 .justify_between()
459 .px(px(14.))
460 .bg(panel)
461 .text_color(text)
462 .child(SharedString::from(format!(
463 "● shuma · shell brahman{pipe_note}"
464 )))
465 .child(
466 div()
467 .text_color(dim)
468 .text_size(px(12.))
469 .child(SharedString::from(format!("{} · launcher", self.line.dialect().name()))),
470 );
471
472 // --- Panel izquierdo: macros [RUN] ---
473 let left = if self.left_collapsed {
474 div()
475 .id("expand-left")
476 .w(px(26.))
477 .flex()
478 .flex_col()
479 .items_center()
480 .pt(px(8.))
481 .bg(panel)
482 .text_color(dim)
483 .cursor_pointer()
484 .hover(|s| s.bg(node_bg))
485 .child("»")
486 .on_click(cx.listener(|s, _, _, cx| {
487 s.left_collapsed = false;
488 cx.notify();
489 }))
490 } else {
491 let run_items: Vec<_> = self
492 .macros
493 .all()
494 .iter()
495 .map(|m| {
496 let key = m.key.clone().unwrap_or_default();
497 div()
498 .px(px(8.))
499 .py(px(6.))
500 .bg(node_bg)
501 .rounded(px(4.))
502 .text_color(text)
503 .text_size(px(13.))
504 .child(SharedString::from(format!("{key} {}", m.name)))
505 })
506 .collect();
507 div()
508 .id("run-panel")
509 .w(px(168.))
510 .flex()
511 .flex_col()
512 .gap(px(6.))
513 .p(px(10.))
514 .bg(panel)
515 .child(
516 div()
517 .flex()
518 .flex_row()
519 .justify_between()
520 .items_center()
521 .child(div().text_color(dim).text_size(px(12.)).child("[RUN]"))
522 .child(
523 div()
524 .id("collapse-left")
525 .px(px(5.))
526 .text_color(dim)
527 .cursor_pointer()
528 .hover(|s| s.text_color(accent))
529 .child("«")
530 .on_click(cx.listener(|s, _, _, cx| {
531 s.left_collapsed = true;
532 cx.notify();
533 })),
534 ),
535 )
536 .children(run_items)
537 };
538
539 // --- Lienzo central: grafo de intenciones ---
540 let plan = layout(&self.session, &LayoutParams::default());
541 let node_els: Vec<_> = plan
542 .nodes
543 .iter()
544 .map(|n| {
545 div()
546 .absolute()
547 .left(px(n.rect.x))
548 .top(px(n.rect.y))
549 .w(px(n.rect.w))
550 .h(px(n.rect.h))
551 .p(px(6.))
552 .bg(node_bg)
553 .border_2()
554 .border_color(status_rgb(n.status))
555 .rounded(px(4.))
556 .text_color(text)
557 .text_size(px(12.))
558 .child(SharedString::from(format!("%c{}", n.command_id)))
559 .child(div().text_color(dim).child(SharedString::from(n.label.clone())))
560 })
561 .collect();
562 let canvas = div()
563 .flex_1()
564 .relative()
565 .overflow_hidden()
566 .p(px(12.))
567 .bg(bg.clone())
568 .child(div().text_color(dim).text_size(px(12.)).child("Lienzo de Contexto"))
569 .children(node_els);
570
571 // --- Panel derecho: monitores [SENS] ---
572 let right = if self.right_collapsed {
573 div()
574 .id("expand-right")
575 .w(px(26.))
576 .flex()
577 .flex_col()
578 .items_center()
579 .pt(px(8.))
580 .bg(panel)
581 .text_color(dim)
582 .cursor_pointer()
583 .hover(|s| s.bg(node_bg))
584 .child("«")
585 .on_click(cx.listener(|s, _, _, cx| {
586 s.right_collapsed = false;
587 cx.notify();
588 }))
589 } else {
590 let cpu = self.snapshot.cpu_percent;
591 let mem = self.snapshot.mem_percent;
592 let cpu_curve = self.sampler.cpu_history().values();
593 let mem_curve = self.sampler.mem_history().values();
594 let cpu_color = gpui::hsla(190.0 / 360.0, 0.72, 0.62, 1.0);
595 let mem_color = gpui::hsla(265.0 / 360.0, 0.55, 0.70, 1.0);
596
597 let monitor = |title: &str, value: String, curve: Vec<f32>, color: Hsla| {
598 div()
599 .flex()
600 .flex_col()
601 .gap(px(4.))
602 .p(px(8.))
603 .bg(node_bg)
604 .rounded(px(5.))
605 .child(
606 div()
607 .flex()
608 .flex_row()
609 .justify_between()
610 .items_baseline()
611 .child(div().text_color(dim).text_size(px(11.)).child(title.to_string()))
612 .child(div().text_color(color).child(SharedString::from(value))),
613 )
614 .child(div().h(px(44.)).child(CurveElement::new(curve, color)))
615 };
616
617 div()
618 .id("sens-panel")
619 .w(px(184.))
620 .flex()
621 .flex_col()
622 .gap(px(10.))
623 .p(px(10.))
624 .bg(panel)
625 .text_color(text)
626 .child(
627 div()
628 .flex()
629 .flex_row()
630 .justify_between()
631 .items_center()
632 .child(div().text_color(dim).text_size(px(12.)).child("[SENS]"))
633 .child(
634 div()
635 .id("collapse-right")
636 .px(px(5.))
637 .text_color(dim)
638 .cursor_pointer()
639 .hover(|s| s.text_color(accent))
640 .child("»")
641 .on_click(cx.listener(|s, _, _, cx| {
642 s.right_collapsed = true;
643 cx.notify();
644 })),
645 ),
646 )
647 .child(monitor(
648 "CPU",
649 format!("{cpu:.0} %"),
650 cpu_curve,
651 cpu_color,
652 ))
653 .child(monitor(
654 "MEM",
655 if self.snapshot.valid {
656 format!(
657 "{:.1}/{:.0} GB",
658 self.snapshot.mem_used_mb as f32 / 1024.0,
659 self.snapshot.mem_total_mb as f32 / 1024.0
660 )
661 } else {
662 "— GB".to_string()
663 },
664 mem_curve,
665 mem_color,
666 ))
667 .child(
668 div()
669 .text_color(dim)
670 .text_size(px(10.))
671 .child(SharedString::from(format!("mem {mem:.0} %"))),
672 )
673 };
674
675 // --- Zona prompt: el input inteligente ---
676 let mut input_row: Vec<gpui::Div> = vec![div()
677 .flex_none()
678 .text_color(accent)
679 .child("› ")];
680 let cursor = self.line.cursor();
681 let tokens = self.line.tokens();
682 let caret = || div().w(px(2.)).h(px(19.)).bg(accent);
683 if tokens.is_empty() {
684 input_row.push(caret());
685 input_row.push(
686 div()
687 .text_color(dim)
688 .child("escribe un comando… (Tab autocompleta)"),
689 );
690 } else {
691 let mut caret_done = false;
692 for t in &tokens {
693 let color = token_color(t.kind, &theme);
694 if !caret_done && cursor >= t.start && cursor < t.end {
695 let local = cursor - t.start;
696 let (left_s, right_s) = t.text.split_at(local);
697 if !left_s.is_empty() {
698 input_row.push(
699 div().flex_none().text_color(color).child(left_s.to_string()),
700 );
701 }
702 input_row.push(caret());
703 input_row.push(
704 div().flex_none().text_color(color).child(right_s.to_string()),
705 );
706 caret_done = true;
707 } else {
708 input_row.push(
709 div().flex_none().text_color(color).child(t.text.clone()),
710 );
711 }
712 }
713 if !caret_done {
714 input_row.push(caret());
715 }
716 }
717
718 let prompt = div()
719 .h(px(46.))
720 .flex()
721 .flex_row()
722 .items_center()
723 .px(px(14.))
724 .bg(panel)
725 .text_color(text)
726 .text_size(px(14.))
727 .children(input_row);
728
729 // --- Popup de autocompletado (flotante sobre el prompt) ---
730 let mut popup_layer: Vec<gpui::Div> = Vec::new();
731 if self.show_completion {
732 if let Some(comp) = &self.completion {
733 if !comp.candidates.is_empty() {
734 let kind_label = match comp.kind {
735 CompletionKind::Command => "comando",
736 CompletionKind::Flag => "flag",
737 CompletionKind::Path => "ruta",
738 };
739 // Ventana de 8 candidatos centrada en la selección.
740 let total = comp.candidates.len();
741 let start = self.completion_index.saturating_sub(3).min(total.saturating_sub(8));
742 let rows: Vec<_> = comp
743 .candidates
744 .iter()
745 .enumerate()
746 .skip(start)
747 .take(8)
748 .map(|(i, cand)| {
749 let selected = i == self.completion_index;
750 div()
751 .px(px(8.))
752 .py(px(3.))
753 .when(selected, |d| d.bg(accent).text_color(gpui::hsla(0.0, 0.0, 0.1, 1.0)))
754 .when(!selected, |d| d.text_color(text))
755 .child(SharedString::from(cand.clone()))
756 })
757 .collect();
758 popup_layer.push(
759 div()
760 .absolute()
761 .left(px(28.))
762 .bottom(px(52.))
763 .w(px(320.))
764 .flex()
765 .flex_col()
766 .bg(node_bg)
767 .border_1()
768 .border_color(accent)
769 .rounded(px(5.))
770 .text_size(px(13.))
771 .child(
772 div()
773 .px(px(8.))
774 .py(px(3.))
775 .text_color(dim)
776 .text_size(px(11.))
777 .child(SharedString::from(format!(
778 "{kind_label} · {total} · ↑↓ Tab"
779 ))),
780 )
781 .children(rows),
782 );
783 }
784 }
785 }
786
787 // --- Composición ---
788 div()
789 .size_full()
790 .relative()
791 .flex()
792 .flex_col()
793 .bg(bg)
794 .track_focus(&self.focus)
795 .key_context("ShumaShell")
796 .on_key_down(cx.listener(Self::handle_key))
797 .child(status)
798 .child(
799 div()
800 .flex()
801 .flex_row()
802 .flex_1()
803 .child(left)
804 .child(canvas)
805 .child(right),
806 )
807 .child(prompt)
808 .children(popup_layer)
809 }
810}
811
812fn main() {
813 launch_app("brahman · shuma shell", (1080., 680.), Shell::new);
814}