gioser

tawasuyu/brahman

Rust 570TOML 215Markdown 68JSON 198261 Sutichaykuna

⎇ 0f2f1033eb3498e3d2635cd89fb4ba6899165d95 crates/apps/shuma-shell/src/main.rs Rust · 814 siq'ikuna · 28.0 KB Ñawpa willay Pi imata llamkarqan ch'uya

feat(shuma-shell): el shell, vivo — input inteligente + monitores 69cee9548 · sergio ·

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}