diff --git a/Cargo.lock b/Cargo.lock index adef161..c288713 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11428,7 +11428,9 @@ dependencies = [ "nahual-launcher", "nahual-theme", "shuma-intent", + "shuma-line", "shuma-shell-render", + "shuma-sysmon", ] [[package]] diff --git a/crates/apps/shuma-shell/Cargo.toml b/crates/apps/shuma-shell/Cargo.toml index 4349f38..db0112b 100644 --- a/crates/apps/shuma-shell/Cargo.toml +++ b/crates/apps/shuma-shell/Cargo.toml @@ -6,7 +6,7 @@ rust-version.workspace = true license.workspace = true authors.workspace = true publish.workspace = true -description = "Shell shuma — las 3 zonas: barra RUN (macros) + Lienzo de Contexto (grafo de intenciones) + barra SENS (telemetría) + prompt fijo." +description = "Shell shuma — input de comandos inteligente (resaltado + autocompletado + pipes), monitores de CPU/memoria y lienzo de intenciones." [[bin]] name = "shuma-shell" @@ -14,6 +14,8 @@ path = "src/main.rs" [dependencies] shuma-intent = { path = "../../modules/shuma/shuma-intent" } +shuma-line = { path = "../../modules/shuma/shuma-line" } +shuma-sysmon = { path = "../../modules/shuma/shuma-sysmon" } shuma-shell-render = { path = "../../modules/shuma/shuma-shell-render" } nahual-theme = { path = "../../modules/nahual/libs/theme" } nahual-launcher = { path = "../../modules/nahual/libs/launcher" } diff --git a/crates/apps/shuma-shell/src/main.rs b/crates/apps/shuma-shell/src/main.rs index a1e755b..3f7fe78 100644 --- a/crates/apps/shuma-shell/src/main.rs +++ b/crates/apps/shuma-shell/src/main.rs @@ -1,79 +1,457 @@ -//! `shuma-shell` — el shell de brahman, en tres zonas. +//! `shuma-shell` — el shell de brahman, vivo. +//! +//! Tres zonas alrededor de su función principal, el input de abajo: //! -//! Layout fijo de la spec: //! ```text -//! ┌─ status ─────────────────────────────────────────┐ +//! ┌─ estado ─────────────────────────────────────────┐ //! │ [RUN] │ Lienzo de Contexto │ [SENS] │ -//! │ macros │ (grafo de intenciones) │ telemetría │ -//! └─ prompt fijo ────────────────────────────────────┘ +//! │ macros │ (grafo de intenciones) │ monitores │ +//! └─ prompt inteligente ─────────────────────────────┘ //! ``` //! -//! La lógica vive en `shuma-intent` (parser + grafo + macros) y -//! `shuma-shell-render` (layout del lienzo); la ejecución real la hace -//! `sandokan`. Esta v1 renderiza la estructura con datos de ejemplo — -//! el cableado interactivo (typing en el prompt, F-keys) es el paso -//! siguiente. +//! El input no es un campo de texto tonto: `shuma-line` analiza la línea +//! bash mientras se escribe —resaltado por token, autocompletado +//! posicional, descomposición de los pipes—. Los monitores de la derecha +//! grafican CPU y memoria con `shuma-sysmon`. Toda la lógica vive en +//! crates agnósticos; este binario sólo es el frontend GPUI. -use gpui::{div, prelude::*, px, Context, IntoElement, Render, SharedString, Window}; +use std::panic; +use std::time::Duration; + +use gpui::{ + div, point, prelude::*, px, App, Bounds, Context, Element, ElementId, FocusHandle, + GlobalElementId, Hsla, InspectorElementId, IntoElement, KeyDownEvent, LayoutId, PathBuilder, + Pixels, Render, SharedString, Style, Window, +}; use nahual_launcher::launch_app; use nahual_theme::Theme; use shuma_intent::{Macro, MacroBook, NodeStatus, SessionGraph}; +use shuma_line::{CompletionKind, CompletionSource, LineState, TokenKind}; use shuma_shell_render::{layout, LayoutParams}; +use shuma_sysmon::{Snapshot, SystemSampler}; + +/// Cuántas muestras guarda la curva de cada monitor. +const HISTORY: usize = 80; + +// ===================================================================== +// Fuente de autocompletado — la parte que sí toca el sistema. +// ===================================================================== + +/// Provee candidatos reales: comandos del `PATH` y rutas del disco. +struct ShellCompletionSource { + commands: Vec, +} + +impl ShellCompletionSource { + /// Escanea el `PATH` una vez al arrancar. + fn scan() -> Self { + let mut commands = Vec::new(); + if let Ok(path) = std::env::var("PATH") { + for dir in path.split(':') { + if let Ok(entries) = std::fs::read_dir(dir) { + for e in entries.flatten() { + if let Some(name) = e.file_name().to_str() { + commands.push(name.to_string()); + } + } + } + } + } + commands.sort(); + commands.dedup(); + Self { commands } + } +} + +impl CompletionSource for ShellCompletionSource { + fn commands(&self) -> Vec { + self.commands.clone() + } + + fn paths(&self, prefix: &str) -> Vec { + let (dir, partial) = match prefix.rfind('/') { + Some(i) => (&prefix[..=i], &prefix[i + 1..]), + None => ("", prefix), + }; + let read_from = if dir.is_empty() { "." } else { dir }; + let mut out = Vec::new(); + if let Ok(entries) = std::fs::read_dir(read_from) { + for e in entries.flatten() { + if let Some(name) = e.file_name().to_str() { + if name.starts_with(partial) { + let is_dir = e.file_type().map(|t| t.is_dir()).unwrap_or(false); + let slash = if is_dir { "/" } else { "" }; + out.push(format!("{dir}{name}{slash}")); + } + } + } + } + out.sort(); + out + } +} + +// ===================================================================== +// CurveElement — la "curvita" de un monitor. +// ===================================================================== + +/// `Element` GPUI que pinta una serie `0..=100` como una curva. +struct CurveElement { + values: Vec, + color: Hsla, +} + +impl CurveElement { + fn new(values: Vec, color: Hsla) -> Self { + Self { values, color } + } +} + +impl IntoElement for CurveElement { + type Element = Self; + fn into_element(self) -> Self::Element { + self + } +} + +impl Element for CurveElement { + type RequestLayoutState = (); + type PrepaintState = (); + + fn id(&self) -> Option { + None + } + fn source_location(&self) -> Option<&'static panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + _id: Option<&GlobalElementId>, + _inspector: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, ()) { + let mut style = Style::default(); + style.size.width = gpui::Length::Definite(gpui::DefiniteLength::Fraction(1.0)); + style.size.height = gpui::Length::Definite(gpui::DefiniteLength::Fraction(1.0)); + (window.request_layout(style, [], cx), ()) + } + + fn prepaint( + &mut self, + _id: Option<&GlobalElementId>, + _inspector: Option<&InspectorElementId>, + _bounds: Bounds, + _layout: &mut (), + _window: &mut Window, + _cx: &mut App, + ) { + } + + fn paint( + &mut self, + _id: Option<&GlobalElementId>, + _inspector: Option<&InspectorElementId>, + bounds: Bounds, + _layout: &mut (), + _prepaint: &mut (), + window: &mut Window, + _cx: &mut App, + ) { + let n = self.values.len(); + if n < 2 { + return; + } + let ox: f32 = bounds.origin.x.into(); + let oy: f32 = bounds.origin.y.into(); + let bw: f32 = bounds.size.width.into(); + let bh: f32 = bounds.size.height.into(); + + let mut pb = PathBuilder::stroke(px(1.6)); + for (i, v) in self.values.iter().enumerate() { + let x = ox + bw * (i as f32 / (n - 1) as f32); + let y = oy + bh - (v.clamp(0.0, 100.0) / 100.0) * bh; + let p = point(px(x), px(y)); + if i == 0 { + pb.move_to(p); + } else { + pb.line_to(p); + } + } + if let Ok(path) = pb.build() { + window.paint_path(path, self.color); + } + } +} + +// ===================================================================== +// El shell. +// ===================================================================== -/// Estado del shell. struct Shell { + /// El input inteligente — texto, cursor, análisis. + line: LineState, + /// Lienzo: el grafo de intenciones de la sesión. session: SessionGraph, macros: MacroBook, - prompt: String, + /// Autocompletado vigente y el candidato seleccionado. + completion: Option, + completion_index: usize, + show_completion: bool, + source: ShellCompletionSource, + /// Muestreo de CPU/memoria. + sampler: SystemSampler, + snapshot: Snapshot, + /// Estado de los paneles laterales. + left_collapsed: bool, + right_collapsed: bool, + focus: FocusHandle, + focused_once: bool, } impl Shell { - fn new(_cx: &mut Context) -> Self { - // --- Datos de ejemplo para ver la estructura poblada --- + fn new(cx: &mut Context) -> Self { + // Datos de ejemplo para que el lienzo no nazca vacío. let mut session = SessionGraph::new(); let c1 = session.record("ssh remote 'cat data.json'"); session.complete(c1, true, 2_400_000); let c2 = session.record("sort | %p1"); session.complete(c2, true, 2_390_000); - let c3 = session.record("wc -l | %p2"); - session.complete(c3, false, 0); - session.record("grep ERROR | %p1"); let mut macros = MacroBook::new(); macros.insert(Macro::new("build").bind("F1").step("cargo build --release")); macros.insert(Macro::new("deploy").bind("F2").step("scp target host:/srv")); macros.insert(Macro::new("clean").bind("F3").step("cargo clean")); - Self { + let shell = Self { + line: LineState::new(), session, macros, - prompt: "ssh remote 'cat data.json' | %p1 | sort".to_string(), + completion: None, + completion_index: 0, + show_completion: false, + source: ShellCompletionSource::scan(), + sampler: SystemSampler::new(HISTORY), + snapshot: Snapshot { + cpu_percent: 0.0, + mem_percent: 0.0, + mem_used_mb: 0, + mem_total_mb: 0, + valid: false, + }, + left_collapsed: false, + right_collapsed: false, + focus: cx.focus_handle(), + focused_once: false, + }; + shell.start_sampler(cx); + shell + } + + /// Bucle de fondo que refresca los monitores ~1 vez por segundo. + fn start_sampler(&self, cx: &mut Context) { + cx.spawn(async move |this, cx| loop { + cx.background_executor().timer(Duration::from_millis(1100)).await; + let alive = this.update(cx, |shell, cx| { + shell.snapshot = shell.sampler.sample(); + cx.notify(); + }); + if alive.is_err() { + break; + } + }) + .detach(); + } + + /// Recalcula el autocompletado tras un cambio en la línea. + fn refresh_completion(&mut self) { + let comp = self.line.complete(&self.source); + // El popup se muestra solo si hay una palabra parcial en curso. + self.show_completion = + !comp.candidates.is_empty() && comp.replace_end > comp.replace_start; + self.completion_index = 0; + self.completion = Some(comp); + } + + /// Tab: muestra el popup, o aplica el candidato seleccionado si ya + /// estaba visible. + fn on_tab(&mut self) { + let comp = self.line.complete(&self.source); + if comp.candidates.is_empty() { + return; } + if self.show_completion { + let idx = self.completion_index.min(comp.candidates.len() - 1); + let candidate = comp.candidates[idx].clone(); + self.line.apply_completion(&comp, &candidate); + self.show_completion = false; + self.completion = None; + } else { + self.completion_index = 0; + self.completion = Some(comp); + self.show_completion = true; + } + } + + /// Mueve la selección del popup. + fn cycle_completion(&mut self, delta: i32) { + if !self.show_completion { + return; + } + if let Some(comp) = &self.completion { + let n = comp.candidates.len(); + if n > 0 { + let i = self.completion_index as i32 + delta; + self.completion_index = i.rem_euclid(n as i32) as usize; + } + } + } + + /// Enter: registra la línea como una intención en el lienzo. + fn submit(&mut self) { + let cmd = self.line.text().trim().to_string(); + if !cmd.is_empty() { + let id = self.session.record(&cmd); + self.session.complete(id, true, 0); + } + self.line.clear(); + self.completion = None; + self.show_completion = false; + } + + fn handle_key(&mut self, event: &KeyDownEvent, _w: &mut Window, cx: &mut Context) { + let ks = &event.keystroke; + let key = ks.key.as_str(); + let ctrl = ks.modifiers.control; + let mut changed = false; + + match key { + "enter" => { + self.submit(); + cx.notify(); + return; + } + "escape" => { + self.show_completion = false; + cx.notify(); + return; + } + "tab" => { + self.on_tab(); + cx.notify(); + return; + } + "up" => { + self.cycle_completion(-1); + cx.notify(); + return; + } + "down" => { + self.cycle_completion(1); + cx.notify(); + return; + } + "backspace" => { + self.line.backspace(); + changed = true; + } + "delete" => { + self.line.delete(); + changed = true; + } + "left" => { + if ctrl { + self.line.move_word_left(); + } else { + self.line.move_left(); + } + } + "right" => { + if ctrl { + self.line.move_word_right(); + } else { + self.line.move_right(); + } + } + "home" => self.line.move_home(), + "end" => self.line.move_end(), + "a" if ctrl => self.line.move_home(), + "e" if ctrl => self.line.move_end(), + "u" if ctrl => { + self.line.clear(); + changed = true; + } + _ => { + if !ctrl { + if let Some(ch) = ks.key_char.as_deref() { + if !ch.chars().any(|c| c.is_control()) { + self.line.insert(ch); + changed = true; + } + } + } + } + } + + if changed { + self.refresh_completion(); + } + cx.notify(); } } -/// Color de borde según el estado de un nodo del lienzo. -fn status_rgb(s: NodeStatus) -> gpui::Rgba { +/// Color de resaltado de cada clase de token. +fn token_color(kind: TokenKind, theme: &Theme) -> Hsla { + match kind { + TokenKind::Command => gpui::hsla(190.0 / 360.0, 0.65, 0.62, 1.0), + TokenKind::Argument => theme.fg_text, + TokenKind::Flag => gpui::hsla(38.0 / 360.0, 0.80, 0.62, 1.0), + TokenKind::StringLit => gpui::hsla(95.0 / 360.0, 0.42, 0.60, 1.0), + TokenKind::Variable => gpui::hsla(280.0 / 360.0, 0.55, 0.72, 1.0), + TokenKind::Pipe => gpui::hsla(190.0 / 360.0, 0.90, 0.72, 1.0), + TokenKind::Redirect => gpui::hsla(20.0 / 360.0, 0.78, 0.62, 1.0), + TokenKind::Operator => gpui::hsla(0.0, 0.66, 0.66, 1.0), + TokenKind::Comment => theme.fg_muted, + TokenKind::Whitespace => theme.fg_text, + TokenKind::Unknown => gpui::hsla(0.0, 0.70, 0.60, 1.0), + } +} + +/// Estado del nodo del lienzo → color de borde. +fn status_rgb(s: NodeStatus) -> Hsla { match s { - NodeStatus::Running => gpui::rgb(0xe0b341), - NodeStatus::Ok => gpui::rgb(0x4caf6a), - NodeStatus::Failed => gpui::rgb(0xd0463b), + NodeStatus::Running => gpui::hsla(45.0 / 360.0, 0.70, 0.55, 1.0), + NodeStatus::Ok => gpui::hsla(140.0 / 360.0, 0.45, 0.52, 1.0), + NodeStatus::Failed => gpui::hsla(2.0 / 360.0, 0.65, 0.55, 1.0), } } impl Render for Shell { - fn render(&mut self, _w: &mut Window, cx: &mut Context) -> impl IntoElement { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + if !self.focused_once { + window.focus(&self.focus); + self.focused_once = true; + } let theme = Theme::global(cx).clone(); - let bg = theme.bg_app; - let panel = gpui::rgb(0x161b22); - let node_bg = gpui::rgb(0x1c2128); + let bg = theme.bg_app.clone(); + let panel = gpui::hsla(220.0 / 360.0, 0.16, 0.11, 1.0); + let node_bg = gpui::hsla(220.0 / 360.0, 0.14, 0.16, 1.0); + let accent = gpui::hsla(190.0 / 360.0, 0.70, 0.62, 1.0); let text = theme.fg_text; let dim = theme.fg_muted; - let accent = gpui::rgb(0x88c0d0); - // --- Zona status (arriba) --- + let pipeline = self.line.pipeline(); + let stage_count = pipeline.stages.iter().filter(|s| s.command.is_some()).count(); + + // --- Zona de estado --- + let pipe_note = if pipeline.is_piped() { + format!(" · ⇄ {stage_count} etapas") + } else { + String::new() + }; let status = div() - .h(px(34.)) + .h(px(32.)) .flex() .flex_row() .items_center() @@ -81,36 +459,84 @@ impl Render for Shell { .px(px(14.)) .bg(panel) .text_color(text) - .child("● sandokan UP · brahman shell") - .child(div().text_color(dim).child("shuma 0.1")); - - // --- Zona [RUN] — macros --- - let run_items: Vec<_> = self - .macros - .all() - .iter() - .map(|m| { - let key = m.key.clone().unwrap_or_default(); + .child(SharedString::from(format!( + "● shuma · shell brahman{pipe_note}" + ))) + .child( div() - .px(px(8.)) - .py(px(6.)) - .bg(node_bg) - .rounded(px(4.)) - .text_color(text) - .child(SharedString::from(format!("{key} {}", m.name))) - }) - .collect(); - let run = div() - .w(px(160.)) - .flex() - .flex_col() - .gap(px(6.)) - .p(px(10.)) - .bg(panel) - .child(div().text_color(dim).child("[RUN]")) - .children(run_items); + .text_color(dim) + .text_size(px(12.)) + .child(SharedString::from(format!("{} · launcher", self.line.dialect().name()))), + ); - // --- Zona lienzo central — grafo de intenciones --- + // --- Panel izquierdo: macros [RUN] --- + let left = if self.left_collapsed { + div() + .id("expand-left") + .w(px(26.)) + .flex() + .flex_col() + .items_center() + .pt(px(8.)) + .bg(panel) + .text_color(dim) + .cursor_pointer() + .hover(|s| s.bg(node_bg)) + .child("»") + .on_click(cx.listener(|s, _, _, cx| { + s.left_collapsed = false; + cx.notify(); + })) + } else { + let run_items: Vec<_> = self + .macros + .all() + .iter() + .map(|m| { + let key = m.key.clone().unwrap_or_default(); + div() + .px(px(8.)) + .py(px(6.)) + .bg(node_bg) + .rounded(px(4.)) + .text_color(text) + .text_size(px(13.)) + .child(SharedString::from(format!("{key} {}", m.name))) + }) + .collect(); + div() + .id("run-panel") + .w(px(168.)) + .flex() + .flex_col() + .gap(px(6.)) + .p(px(10.)) + .bg(panel) + .child( + div() + .flex() + .flex_row() + .justify_between() + .items_center() + .child(div().text_color(dim).text_size(px(12.)).child("[RUN]")) + .child( + div() + .id("collapse-left") + .px(px(5.)) + .text_color(dim) + .cursor_pointer() + .hover(|s| s.text_color(accent)) + .child("«") + .on_click(cx.listener(|s, _, _, cx| { + s.left_collapsed = true; + cx.notify(); + })), + ), + ) + .children(run_items) + }; + + // --- Lienzo central: grafo de intenciones --- let plan = layout(&self.session, &LayoutParams::default()); let node_els: Vec<_> = plan .nodes @@ -128,12 +554,9 @@ impl Render for Shell { .border_color(status_rgb(n.status)) .rounded(px(4.)) .text_color(text) + .text_size(px(12.)) .child(SharedString::from(format!("%c{}", n.command_id))) - .child( - div() - .text_color(dim) - .child(SharedString::from(n.label.clone())), - ) + .child(div().text_color(dim).child(SharedString::from(n.label.clone()))) }) .collect(); let canvas = div() @@ -141,67 +564,251 @@ impl Render for Shell { .relative() .overflow_hidden() .p(px(12.)) - .bg(bg) - .child(div().text_color(dim).child("Lienzo de Contexto")) + .bg(bg.clone()) + .child(div().text_color(dim).text_size(px(12.)).child("Lienzo de Contexto")) .children(node_els); - // --- Zona [SENS] — telemetría --- - let sens = div() - .w(px(180.)) - .flex() - .flex_col() - .gap(px(10.)) - .p(px(10.)) - .bg(panel) - .text_color(text) - .child(div().text_color(dim).child("[SENS]")) - .child( + // --- Panel derecho: monitores [SENS] --- + let right = if self.right_collapsed { + div() + .id("expand-right") + .w(px(26.)) + .flex() + .flex_col() + .items_center() + .pt(px(8.)) + .bg(panel) + .text_color(dim) + .cursor_pointer() + .hover(|s| s.bg(node_bg)) + .child("«") + .on_click(cx.listener(|s, _, _, cx| { + s.right_collapsed = false; + cx.notify(); + })) + } else { + let cpu = self.snapshot.cpu_percent; + let mem = self.snapshot.mem_percent; + let cpu_curve = self.sampler.cpu_history().values(); + let mem_curve = self.sampler.mem_history().values(); + let cpu_color = gpui::hsla(190.0 / 360.0, 0.72, 0.62, 1.0); + let mem_color = gpui::hsla(265.0 / 360.0, 0.55, 0.70, 1.0); + + let monitor = |title: &str, value: String, curve: Vec, color: Hsla| { div() + .flex() + .flex_col() + .gap(px(4.)) .p(px(8.)) .bg(node_bg) - .rounded(px(4.)) - .child("CPU") - .child(div().text_color(accent).child("— °C")), - ) - .child( + .rounded(px(5.)) + .child( + div() + .flex() + .flex_row() + .justify_between() + .items_baseline() + .child(div().text_color(dim).text_size(px(11.)).child(title.to_string())) + .child(div().text_color(color).child(SharedString::from(value))), + ) + .child(div().h(px(44.)).child(CurveElement::new(curve, color))) + }; + + div() + .id("sens-panel") + .w(px(184.)) + .flex() + .flex_col() + .gap(px(10.)) + .p(px(10.)) + .bg(panel) + .text_color(text) + .child( + div() + .flex() + .flex_row() + .justify_between() + .items_center() + .child(div().text_color(dim).text_size(px(12.)).child("[SENS]")) + .child( + div() + .id("collapse-right") + .px(px(5.)) + .text_color(dim) + .cursor_pointer() + .hover(|s| s.text_color(accent)) + .child("»") + .on_click(cx.listener(|s, _, _, cx| { + s.right_collapsed = true; + cx.notify(); + })), + ), + ) + .child(monitor( + "CPU", + format!("{cpu:.0} %"), + cpu_curve, + cpu_color, + )) + .child(monitor( + "MEM", + if self.snapshot.valid { + format!( + "{:.1}/{:.0} GB", + self.snapshot.mem_used_mb as f32 / 1024.0, + self.snapshot.mem_total_mb as f32 / 1024.0 + ) + } else { + "— GB".to_string() + }, + mem_curve, + mem_color, + )) + .child( + div() + .text_color(dim) + .text_size(px(10.)) + .child(SharedString::from(format!("mem {mem:.0} %"))), + ) + }; + + // --- Zona prompt: el input inteligente --- + let mut input_row: Vec = vec![div() + .flex_none() + .text_color(accent) + .child("› ")]; + let cursor = self.line.cursor(); + let tokens = self.line.tokens(); + let caret = || div().w(px(2.)).h(px(19.)).bg(accent); + if tokens.is_empty() { + input_row.push(caret()); + input_row.push( div() - .p(px(8.)) - .bg(node_bg) - .rounded(px(4.)) - .child("MEM") - .child(div().text_color(accent).child("— G")), + .text_color(dim) + .child("escribe un comando… (Tab autocompleta)"), ); + } else { + let mut caret_done = false; + for t in &tokens { + let color = token_color(t.kind, &theme); + if !caret_done && cursor >= t.start && cursor < t.end { + let local = cursor - t.start; + let (left_s, right_s) = t.text.split_at(local); + if !left_s.is_empty() { + input_row.push( + div().flex_none().text_color(color).child(left_s.to_string()), + ); + } + input_row.push(caret()); + input_row.push( + div().flex_none().text_color(color).child(right_s.to_string()), + ); + caret_done = true; + } else { + input_row.push( + div().flex_none().text_color(color).child(t.text.clone()), + ); + } + } + if !caret_done { + input_row.push(caret()); + } + } - // --- Zona prompt (abajo) --- let prompt = div() - .h(px(40.)) + .h(px(46.)) .flex() + .flex_row() .items_center() .px(px(14.)) .bg(panel) .text_color(text) - .child(SharedString::from(format!("› {}", self.prompt))); + .text_size(px(14.)) + .children(input_row); + + // --- Popup de autocompletado (flotante sobre el prompt) --- + let mut popup_layer: Vec = Vec::new(); + if self.show_completion { + if let Some(comp) = &self.completion { + if !comp.candidates.is_empty() { + let kind_label = match comp.kind { + CompletionKind::Command => "comando", + CompletionKind::Flag => "flag", + CompletionKind::Path => "ruta", + }; + // Ventana de 8 candidatos centrada en la selección. + let total = comp.candidates.len(); + let start = self.completion_index.saturating_sub(3).min(total.saturating_sub(8)); + let rows: Vec<_> = comp + .candidates + .iter() + .enumerate() + .skip(start) + .take(8) + .map(|(i, cand)| { + let selected = i == self.completion_index; + div() + .px(px(8.)) + .py(px(3.)) + .when(selected, |d| d.bg(accent).text_color(gpui::hsla(0.0, 0.0, 0.1, 1.0))) + .when(!selected, |d| d.text_color(text)) + .child(SharedString::from(cand.clone())) + }) + .collect(); + popup_layer.push( + div() + .absolute() + .left(px(28.)) + .bottom(px(52.)) + .w(px(320.)) + .flex() + .flex_col() + .bg(node_bg) + .border_1() + .border_color(accent) + .rounded(px(5.)) + .text_size(px(13.)) + .child( + div() + .px(px(8.)) + .py(px(3.)) + .text_color(dim) + .text_size(px(11.)) + .child(SharedString::from(format!( + "{kind_label} · {total} · ↑↓ Tab" + ))), + ) + .children(rows), + ); + } + } + } // --- Composición --- div() .size_full() + .relative() .flex() .flex_col() .bg(bg) + .track_focus(&self.focus) + .key_context("ShumaShell") + .on_key_down(cx.listener(Self::handle_key)) .child(status) .child( div() .flex() .flex_row() .flex_1() - .child(run) + .child(left) .child(canvas) - .child(sens), + .child(right), ) .child(prompt) + .children(popup_layer) } } fn main() { - launch_app("brahman · shuma shell", (1040., 660.), Shell::new); + launch_app("brahman · shuma shell", (1080., 680.), Shell::new); } diff --git a/crates/modules/shuma/shuma-line/src/lexer.rs b/crates/modules/shuma/shuma-line/src/lexer.rs index 18ff984..b4ec5fa 100644 --- a/crates/modules/shuma/shuma-line/src/lexer.rs +++ b/crates/modules/shuma/shuma-line/src/lexer.rs @@ -49,7 +49,7 @@ fn scan_bash(input: &str) -> Vec { let n = chars.len(); let byte_at = |p: usize| if p < n { chars[p].0 } else { input.len() }; let mut tokens: Vec = Vec::new(); - let mut push = |tokens: &mut Vec, kind: TokenKind, sp: usize, ep: usize| { + let push = |tokens: &mut Vec, kind: TokenKind, sp: usize, ep: usize| { let (sb, eb) = (byte_at(sp), byte_at(ep)); tokens.push(Token::new(kind, sb, eb, &input[sb..eb])); };