feat(shuma): reescritura del shell — las 3 zonas

apps/shuma-shell deja de ser el dashboard legacy de la era shipote y
pasa a ser el shell de la spec: layout fijo de 3 zonas.

- status (arriba) — estado de sandokan + versión.
- [RUN] (izquierda) — barra de macros desde MacroBook (F1/F2/F3).
- Lienzo de Contexto (centro) — grafo de intenciones: cada %cN es una
  caja posicionada por shuma-shell-render::layout, borde coloreado por
  estado (ámbar Running / verde Ok / rojo Failed).
- [SENS] (derecha) — telemetría (CPU/MEM, placeholders).
- prompt fijo (abajo) — la línea de intención.

v1: renderiza la estructura con datos de ejemplo. Cableado interactivo
(typing, F-keys ejecutando vía sandokan, telemetría viva) es el paso
siguiente. cargo check --workspace verde.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
sergio
2026-05-20 16:14:59 +00:00
parent ed651d6ac5
commit bbfa44ff35
4 changed files with 178 additions and 657 deletions
Binary file not shown.
Generated
+2 -7
View File
@@ -11252,13 +11252,8 @@ dependencies = [
"gpui",
"nahual-launcher",
"nahual-theme",
"nahual-widget-app-header",
"nahual-widget-banner",
"nahual-widget-stat-card",
"shuma-card",
"shuma-protocol",
"tokio",
"ulid",
"shuma-intent",
"shuma-shell-render",
]
[[package]]
+3 -8
View File
@@ -6,20 +6,15 @@ rust-version.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "GUI de shipote: vista de Workspaces+comandos+capabilities. Conecta al daemon vía shipote-protocol."
description = "Shell shuma — las 3 zonas: barra RUN (macros) + Lienzo de Contexto (grafo de intenciones) + barra SENS (telemetría) + prompt fijo."
[[bin]]
name = "shuma-shell"
path = "src/main.rs"
[dependencies]
shuma-card = { path = "../../modules/shuma/shuma-card" }
shuma-protocol = { path = "../../modules/shuma/shuma-protocol" }
shuma-intent = { path = "../../modules/shuma/shuma-intent" }
shuma-shell-render = { path = "../../modules/shuma/shuma-shell-render" }
nahual-theme = { path = "../../modules/nahual/libs/theme" }
nahual-launcher = { path = "../../modules/nahual/libs/launcher" }
nahual-widget-banner = { path = "../../modules/nahual/widgets/banner" }
nahual-widget-stat-card = { path = "../../modules/nahual/widgets/stat-card" }
nahual-widget-app-header = { path = "../../modules/nahual/widgets/app-header" }
tokio = { workspace = true }
gpui = { workspace = true }
ulid = { workspace = true }
+173 -642
View File
@@ -1,676 +1,207 @@
//! `shuma-shell` — GUI dashboard del daemon shuma.
//! `shuma-shell` — el shell de brahman, en tres zonas.
//!
//! Probe-style: conecta al socket del daemon cada 2s, pide
//! capabilities + workspace-list y los muestra en cards.
//! Si el daemon no está corriendo, marca DOWN.
//! Layout fijo de la spec:
//! ```text
//! ┌─ status ─────────────────────────────────────────┐
//! │ [RUN] │ Lienzo de Contexto │ [SENS] │
//! │ macros │ (grafo de intenciones) │ telemetría │
//! └─ prompt fijo ────────────────────────────────────┘
//! ```
//!
//! 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.
use gpui::{div, prelude::*, px, Context, IntoElement, Render, SharedString, Window};
use shuma_protocol::{
default_socket_path, read_frame, write_frame, CommandInfo, FlowInfo, FlowThroughputInfo,
QuotaReportInfo, Request, Response, WorkspaceStatsInfo, WorkspaceSummary,
};
use std::path::PathBuf;
use std::time::Duration;
use tokio::net::UnixStream;
use nahual_launcher::launch_app;
use nahual_theme::Theme;
use nahual_widget_app_header::app_header;
use nahual_widget_banner::{banner_themed, Banner};
use nahual_widget_stat_card::stat_card;
const POLL_INTERVAL: Duration = Duration::from_secs(2);
#[derive(Clone, Debug)]
enum DaemonState {
Pending,
Down { reason: String },
Up,
}
#[derive(Clone, Debug, Default)]
struct CapsSummary {
kernel_version: (u32, u32, u32),
user_ns: String,
cgroup_v2: String,
cgroup_delegated: bool,
has_cap_sys_admin: bool,
}
use shuma_intent::{Macro, MacroBook, NodeStatus, SessionGraph};
use shuma_shell_render::{layout, LayoutParams};
/// Estado del shell.
struct Shell {
socket_path: PathBuf,
state: DaemonState,
workspaces: Vec<WorkspaceSummary>,
/// Comandos por workspace, indexados por workspace id.toString().
commands: std::collections::BTreeMap<String, Vec<CommandInfo>>,
saved_pipelines: Vec<String>,
flows: Vec<FlowInfo>,
/// Throughput por flow socket (bytes_total + bytes/s).
flow_throughput: Vec<FlowThroughputInfo>,
/// History de RSS por workspace (últimas N samples).
stats_history: std::collections::BTreeMap<String, std::collections::VecDeque<WorkspaceStatsInfo>>,
/// Quota report fresco por workspace.
quotas: std::collections::BTreeMap<String, QuotaReportInfo>,
caps: Option<CapsSummary>,
last_probe_ms: u64,
recent_log: Option<(String, String)>,
}
const STATS_HISTORY_LEN: usize = 24;
fn main() {
launch_app("Shipote — Shell", (820., 560.), Shell::new);
session: SessionGraph,
macros: MacroBook,
prompt: String,
}
impl Shell {
fn new(cx: &mut Context<Self>) -> Self {
let socket_path = default_socket_path();
let socket_for_loop = socket_path.clone();
cx.spawn(async move |this, cx| {
let timer = cx.background_executor().clone();
let bg = cx.background_executor().clone();
loop {
let path = socket_for_loop.clone();
let started = std::time::Instant::now();
let result = bg
.spawn(async move { probe_blocking(&path) })
.await;
let elapsed = started.elapsed().as_millis() as u64;
let _ = this.update(cx, |me, cx| {
match result {
Ok(snap) => {
me.state = DaemonState::Up;
me.workspaces = snap.workspaces;
me.commands = snap.commands;
me.saved_pipelines = snap.saved_pipelines;
me.flows = snap.flows;
me.flow_throughput = snap.flow_throughput;
me.quotas = snap.quotas;
// Hidratar history server-side para workspaces
// que no tenían history local (primer probe).
for ws in &me.workspaces {
let key = ws.id.to_string();
if !me.stats_history.contains_key(&key) {
if let Some(hydrated) = snap.hydrate_history.get(&key) {
me.stats_history.insert(
key.clone(),
hydrated.iter().cloned().collect(),
);
}
}
}
// Append fresh sample a la history por workspace.
for (ws_id, fresh) in &snap.fresh_stats {
let h = me
.stats_history
.entry(ws_id.clone())
.or_default();
if h.len() >= STATS_HISTORY_LEN {
h.pop_front();
}
h.push_back(fresh.clone());
}
// Limpiar history de workspaces que ya no existen.
let alive: std::collections::HashSet<String> = me
.workspaces
.iter()
.map(|w| w.id.to_string())
.collect();
me.stats_history.retain(|k, _| alive.contains(k));
me.caps = Some(snap.caps);
me.recent_log = snap.recent_log;
}
Err(reason) => {
me.state = DaemonState::Down { reason };
me.workspaces.clear();
me.commands.clear();
me.saved_pipelines.clear();
me.flows.clear();
me.flow_throughput.clear();
me.quotas.clear();
me.caps = None;
me.recent_log = None;
}
}
me.last_probe_ms = elapsed;
cx.notify();
});
timer.timer(POLL_INTERVAL).await;
}
})
.detach();
fn new(_cx: &mut Context<Self>) -> Self {
// --- Datos de ejemplo para ver la estructura poblada ---
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 {
socket_path,
state: DaemonState::Pending,
workspaces: Vec::new(),
commands: std::collections::BTreeMap::new(),
saved_pipelines: Vec::new(),
flows: Vec::new(),
flow_throughput: Vec::new(),
stats_history: std::collections::BTreeMap::new(),
quotas: std::collections::BTreeMap::new(),
caps: None,
last_probe_ms: 0,
recent_log: None,
session,
macros,
prompt: "ssh remote 'cat data.json' | %p1 | sort".to_string(),
}
}
}
#[derive(Debug)]
struct Snapshot {
workspaces: Vec<WorkspaceSummary>,
commands: std::collections::BTreeMap<String, Vec<CommandInfo>>,
saved_pipelines: Vec<String>,
flows: Vec<FlowInfo>,
flow_throughput: Vec<FlowThroughputInfo>,
/// Stats fresco por workspace (id.toString → stats).
fresh_stats: std::collections::BTreeMap<String, WorkspaceStatsInfo>,
/// Quota report fresco por workspace.
quotas: std::collections::BTreeMap<String, QuotaReportInfo>,
/// Workspaces nuevos (no en history local): hidratamos history
/// server-side al primer probe que los vea.
hydrate_history: std::collections::BTreeMap<String, Vec<WorkspaceStatsInfo>>,
caps: CapsSummary,
/// tail del log del comando más reciente (label + bytes). None si no hay.
recent_log: Option<(String, String)>,
}
fn probe_blocking(path: &std::path::Path) -> Result<Snapshot, String> {
// Mini tokio runtime efímero por probe — no compartimos runtime con
// GPUI. Costo aceptable cada 2s: setup ≈ <1 ms.
let rt = tokio::runtime::Builder::new_current_thread()
.enable_io()
.enable_time()
.build()
.map_err(|e| format!("rt: {e}"))?;
rt.block_on(async {
let mut stream = UnixStream::connect(path)
.await
.map_err(|e| format!("connect: {e}"))?;
write_frame(&mut stream, &Request::WorkspaceList)
.await
.map_err(|e| format!("write list: {e}"))?;
let resp: Response = read_frame(&mut stream).await.map_err(|e| format!("read list: {e}"))?;
let workspaces = match resp {
Response::WorkspaceList { items } => items,
other => return Err(format!("unexpected list resp: {other:?}")),
};
// Batched: stats+quota+commands+flow_sockets en 1 roundtrip por ws.
// Para workspaces nuevos, también pedimos history server-side.
let mut commands_map = std::collections::BTreeMap::new();
let mut fresh_stats = std::collections::BTreeMap::new();
let mut quotas = std::collections::BTreeMap::new();
let mut hydrate_history = std::collections::BTreeMap::new();
for w in &workspaces {
write_frame(&mut stream, &Request::WorkspaceFullSummary { workspace: w.id })
.await
.map_err(|e| format!("write summary: {e}"))?;
let resp: Response = read_frame(&mut stream)
.await
.map_err(|e| format!("read summary: {e}"))?;
if let Response::WorkspaceFullSummary { stats, quota, commands, .. } = resp {
let key = w.id.to_string();
fresh_stats.insert(key.clone(), stats);
quotas.insert(key.clone(), quota);
if !commands.is_empty() {
commands_map.insert(key, commands);
}
}
// History server-side (para hidratar si el shell es nuevo).
write_frame(
&mut stream,
&Request::WorkspaceStatsHistory {
workspace: w.id,
tail: 24, // mismo cap que STATS_HISTORY_LEN
},
)
.await
.map_err(|e| format!("write history: {e}"))?;
let resp: Response = read_frame(&mut stream)
.await
.map_err(|e| format!("read history: {e}"))?;
if let Response::WorkspaceStatsHistory { samples } = resp {
if !samples.is_empty() {
hydrate_history.insert(w.id.to_string(), samples);
}
}
}
// Saved pipelines.
write_frame(&mut stream, &Request::PipelineSavedList)
.await
.map_err(|e| format!("write saved: {e}"))?;
let resp: Response = read_frame(&mut stream)
.await
.map_err(|e| format!("read saved: {e}"))?;
let saved_pipelines = match resp {
Response::PipelineSavedList { names } => names,
_ => Vec::new(),
};
// Flow channels activos (data plane).
write_frame(&mut stream, &Request::FlowList)
.await
.map_err(|e| format!("write flows: {e}"))?;
let resp: Response = read_frame(&mut stream)
.await
.map_err(|e| format!("read flows: {e}"))?;
let flows = match resp {
Response::FlowList { items } => items,
_ => Vec::new(),
};
// Throughput per-socket.
write_frame(&mut stream, &Request::FlowThroughput)
.await
.map_err(|e| format!("write throughput: {e}"))?;
let resp: Response = read_frame(&mut stream)
.await
.map_err(|e| format!("read throughput: {e}"))?;
let flow_throughput = match resp {
Response::FlowThroughput { items } => items,
_ => Vec::new(),
};
// Live tail: log del comando más reciente con bytes>0.
let recent_log = {
// Pick: comando con id más alto que tiene log_bytes>0, en cualquier workspace.
let mut best: Option<(&str, &CommandInfo)> = None;
for (ws, cmds) in &commands_map {
for c in cmds {
if c.log_bytes == 0 {
continue;
}
let take = match &best {
Some((_, prev)) => c.id > prev.id,
None => true,
};
if take {
best = Some((ws.as_str(), c));
}
}
}
match best {
Some((ws_str, cmd)) => {
let ws_id: shuma_card::WorkspaceId = ws_str
.parse::<ulid::Ulid>()
.map(shuma_card::WorkspaceId)
.map_err(|e| format!("ulid parse: {e}"))?;
write_frame(
&mut stream,
&Request::CommandLogs {
workspace: ws_id,
command: cmd.id,
tail_bytes: 512,
stream: "both".into(),
},
)
.await
.map_err(|e| format!("write logs: {e}"))?;
let resp: Response = read_frame(&mut stream)
.await
.map_err(|e| format!("read logs: {e}"))?;
match resp {
Response::CommandLogs { bytes } => {
let text = String::from_utf8_lossy(&bytes).to_string();
Some((cmd.label.clone(), text))
}
_ => None,
}
}
None => None,
}
};
write_frame(&mut stream, &Request::Capabilities)
.await
.map_err(|e| format!("write caps: {e}"))?;
let resp: Response = read_frame(&mut stream).await.map_err(|e| format!("read caps: {e}"))?;
let caps = match resp {
Response::Capabilities {
kernel_version,
user_ns,
cgroup_v2,
cgroup_delegated,
has_cap_sys_admin,
} => CapsSummary {
kernel_version,
user_ns,
cgroup_v2,
cgroup_delegated,
has_cap_sys_admin,
},
other => return Err(format!("unexpected caps resp: {other:?}")),
};
Ok(Snapshot {
workspaces,
commands: commands_map,
saved_pipelines,
flows,
flow_throughput,
fresh_stats,
quotas,
hydrate_history,
caps,
recent_log,
})
})
}
/// Render ASCII de sparkline para una serie de valores. `chars` define los
/// glifos (orden ascendente). Auto-scales al máximo de la serie.
fn sparkline(values: &[u64], width: usize) -> String {
if values.is_empty() {
return String::new();
/// Color de borde según el estado de un nodo del lienzo.
fn status_rgb(s: NodeStatus) -> gpui::Rgba {
match s {
NodeStatus::Running => gpui::rgb(0xe0b341),
NodeStatus::Ok => gpui::rgb(0x4caf6a),
NodeStatus::Failed => gpui::rgb(0xd0463b),
}
const CHARS: &[char] = &['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
let take = values.len().min(width);
let series = &values[values.len() - take..];
let max = *series.iter().max().unwrap_or(&1);
if max == 0 {
return "".repeat(take);
}
series
.iter()
.map(|v| {
let idx = ((*v as f64 / max as f64) * (CHARS.len() as f64 - 1.0)).round() as usize;
CHARS[idx.min(CHARS.len() - 1)]
})
.collect()
}
impl Render for Shell {
fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let theme = Theme::global(cx).clone();
let bg = theme.bg_app.clone();
let bg = theme.bg_app;
let panel = gpui::rgb(0x161b22);
let node_bg = gpui::rgb(0x1c2128);
let text = theme.fg_text;
let text_dim = theme.fg_muted;
let dim = theme.fg_muted;
let accent = gpui::rgb(0x88c0d0);
let accent_up = gpui::rgb(0xa3be8c);
let accent_down = gpui::rgb(0xbf616a);
let accent_pending = gpui::rgb(0x6a7280);
let accent_info = gpui::rgb(0x88c0d0);
// --- Zona status (arriba) ---
let status = div()
.h(px(34.))
.flex()
.flex_row()
.items_center()
.justify_between()
.px(px(14.))
.bg(panel)
.text_color(text)
.child("● sandokan UP · brahman shell")
.child(div().text_color(dim).child("shuma 0.1"));
let header_text = format!(
"Daemon: {} · reload {} ms",
self.socket_path.display(),
self.last_probe_ms
);
let header = app_header(cx, header_text);
let status_banner = match &self.state {
DaemonState::Pending => None,
DaemonState::Down { reason } => Some(banner_themed(
cx,
Banner::Error,
SharedString::from(format!("Daemon DOWN — {reason}")),
)),
DaemonState::Up => Some(banner_themed(
cx,
Banner::Success,
SharedString::from("Daemon UP"),
)),
};
let (status_value, status_descr, status_accent) = match &self.state {
DaemonState::Pending => ("PENDING".to_string(), "primer probe…".to_string(), accent_pending),
DaemonState::Down { reason } => ("DOWN".to_string(), reason.clone(), accent_down),
DaemonState::Up => ("UP".to_string(), "shuma-daemon respondiendo".to_string(), accent_up),
};
let caps_items: Vec<String> = self
.caps
.as_ref()
.map(|c| {
vec![
format!(
"kernel: {}.{}.{}",
c.kernel_version.0, c.kernel_version.1, c.kernel_version.2
),
format!("user_ns: {}", c.user_ns),
format!("cgroup_v2: {}", c.cgroup_v2),
format!("cgroup_delegated: {}", c.cgroup_delegated),
format!("cap_sys_admin: {}", c.has_cap_sys_admin),
]
})
.unwrap_or_default();
let caps_value = if self.caps.is_some() { "OK".to_string() } else { "".to_string() };
let ws_items: Vec<String> = self
.workspaces
// --- Zona [RUN] — macros ---
let run_items: Vec<_> = self
.macros
.all()
.iter()
.map(|w| {
let key = w.id.to_string();
let history = self.stats_history.get(&key);
let rss_series: Vec<u64> = history
.map(|h| h.iter().map(|s| s.rss_bytes.unwrap_or(0)).collect())
.unwrap_or_default();
let spark = sparkline(&rss_series, STATS_HISTORY_LEN);
let latest = history.and_then(|h| h.back());
let (rss_now, peak, cpu_pct) = latest
.map(|s| (
s.rss_bytes.unwrap_or(0),
s.rss_peak_bytes.unwrap_or(0),
s.cpu_percent.unwrap_or(0.0),
))
.unwrap_or((0, 0, 0.0));
let rss_mb = rss_now as f64 / 1024.0 / 1024.0;
let peak_mb = peak as f64 / 1024.0 / 1024.0;
format!(
"{:<14} {:<14} {} {:>6.1}M peak {:>6.1}M {:>5.1}%cpu",
&w.id.to_string()[20..],
w.label,
spark,
rss_mb,
peak_mb,
cpu_pct,
)
.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)
.child(SharedString::from(format!("{key} {}", m.name)))
})
.collect();
let ws_count = self.workspaces.len().to_string();
let ws_descr = if self.workspaces.is_empty() {
"no hay workspaces vivos".to_string()
} else {
"id · label · cmds · uptime".to_string()
};
// Comandos: aplanamos por workspace, tomamos los más recientes (orden ULID ya temporal).
let mut cmd_items: Vec<String> = Vec::new();
let mut cmd_total = 0usize;
for (ws_id, cmds) in &self.commands {
cmd_total += cmds.len();
for c in cmds.iter().rev().take(8) {
let alive = if c.alive { "" } else { "" };
let exit = c
.exit_status
.map(|s| format!(" exit={s}"))
.unwrap_or_default();
cmd_items.push(format!(
"{} {} {:<20} pid={} logs={}B{}",
alive,
&ws_id[..6.min(ws_id.len())],
c.label,
c.pid,
c.log_bytes,
exit
));
}
}
let cmd_count = cmd_total.to_string();
let cmd_descr = if cmd_total == 0 {
"no hay comandos lanzados".to_string()
} else {
"▶=alive ✓=exited · ws_prefix · label · pid · logs".to_string()
};
// Saved pipelines.
let saved_count = self.saved_pipelines.len().to_string();
let saved_items: Vec<String> = self.saved_pipelines.clone();
let saved_descr = if saved_items.is_empty() {
"shuma pipeline save <name> <file> para persistir".to_string()
} else {
"definiciones reusables vía run-saved".to_string()
};
// Quota breaches por workspace.
let mut breach_items: Vec<String> = Vec::new();
for (ws_id, q) in &self.quotas {
for b in &q.breaches {
let short = &ws_id[20..];
breach_items.push(format!("{short} {b}"));
}
}
let breach_count = breach_items.len().to_string();
let breach_descr = if breach_items.is_empty() {
"todos los workspaces dentro de quota".to_string()
} else {
"ws_suffix · recurso · uso > limit".to_string()
};
// Flow channels (data plane) con throughput.
let flow_count: usize = self.flows.iter().map(|f| f.sockets.len()).sum();
// Lookup helper que NO captura por ref (evita issue de borrow
// en el closure de flat_map).
let find_tp = |s: &std::path::PathBuf| -> (f64, f64) {
for t in &self.flow_throughput {
if t.socket == *s {
return (t.bytes_total as f64 / 1024.0, t.bytes_per_sec / 1024.0);
}
}
(0.0, 0.0)
};
let mut flow_items: Vec<String> = Vec::new();
for f in &self.flows {
let pipe = f.pipeline.to_string();
let short_pipe = &pipe[pipe.len() - 6..];
for s in &f.sockets {
let name = s
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| s.display().to_string());
let (total_kib, rate_kib) = find_tp(s);
flow_items.push(format!(
"{short_pipe} {:<48} {:>7.1} KiB {:>6.2} KiB/s",
name, total_kib, rate_kib
));
}
}
let flow_descr = if flow_count == 0 {
"pipelines con --tap exponen sockets aquí".to_string()
} else {
"pipe6 · socket · total · rate".to_string()
};
let body = div()
let run = div()
.w(px(160.))
.flex()
.flex_col()
.gap(px(8.))
.px(px(16.))
.py(px(16.))
.child(stat_card(
cx,
"Estado",
status_value,
&status_descr,
status_accent,
text,
text_dim,
&[],
))
.child(stat_card(
cx,
"Capabilities",
caps_value,
"kernel + namespaces + cgroup delegation",
accent_info,
text,
text_dim,
&caps_items,
))
.child(stat_card(
cx,
"Workspaces",
ws_count,
&ws_descr,
accent_info,
text,
text_dim,
&ws_items,
))
.child(stat_card(
cx,
"Comandos",
cmd_count,
&cmd_descr,
accent_info,
text,
text_dim,
&cmd_items,
))
.child(stat_card(
cx,
"Saved pipelines",
saved_count,
&saved_descr,
accent_info,
text,
text_dim,
&saved_items,
))
.child(stat_card(
cx,
"Flow channels",
flow_count.to_string(),
&flow_descr,
accent_up,
text,
text_dim,
&flow_items,
))
.child(stat_card(
cx,
"Quota breaches",
breach_count,
&breach_descr,
if breach_items.is_empty() { accent_up } else { accent_down },
text,
text_dim,
&breach_items,
));
.gap(px(6.))
.p(px(10.))
.bg(panel)
.child(div().text_color(dim).child("[RUN]"))
.children(run_items);
// Live tail del comando más reciente con output.
let live_card = self.recent_log.as_ref().map(|(label, content)| {
// Cortamos a las últimas ~12 líneas para no inflar el panel.
let lines: Vec<String> = content
.lines()
.rev()
.take(12)
.map(|l| l.to_string())
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
stat_card(
cx,
"Live tail",
label.clone(),
"últimas líneas del comando más reciente",
accent_up,
text,
text_dim,
&lines,
)
});
let body = body.when_some(live_card, |d, c| d.child(c));
div()
.flex()
.flex_col()
.size_full()
// --- Zona lienzo central — grafo de intenciones ---
let plan = layout(&self.session, &LayoutParams::default());
let node_els: Vec<_> = plan
.nodes
.iter()
.map(|n| {
div()
.absolute()
.left(px(n.rect.x))
.top(px(n.rect.y))
.w(px(n.rect.w))
.h(px(n.rect.h))
.p(px(6.))
.bg(node_bg)
.border_2()
.border_color(status_rgb(n.status))
.rounded(px(4.))
.text_color(text)
.child(SharedString::from(format!("%c{}", n.command_id)))
.child(
div()
.text_color(dim)
.child(SharedString::from(n.label.clone())),
)
})
.collect();
let canvas = div()
.flex_1()
.relative()
.overflow_hidden()
.p(px(12.))
.bg(bg)
.child(header)
.when_some(status_banner, |d, b| d.child(b))
.child(body)
.child(div().text_color(dim).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(
div()
.p(px(8.))
.bg(node_bg)
.rounded(px(4.))
.child("CPU")
.child(div().text_color(accent).child("— °C")),
)
.child(
div()
.p(px(8.))
.bg(node_bg)
.rounded(px(4.))
.child("MEM")
.child(div().text_color(accent).child("— G")),
);
// --- Zona prompt (abajo) ---
let prompt = div()
.h(px(40.))
.flex()
.items_center()
.px(px(14.))
.bg(panel)
.text_color(text)
.child(SharedString::from(format!(" {}", self.prompt)));
// --- Composición ---
div()
.size_full()
.flex()
.flex_col()
.bg(bg)
.child(status)
.child(
div()
.flex()
.flex_row()
.flex_1()
.child(run)
.child(canvas)
.child(sens),
)
.child(prompt)
}
}
fn main() {
launch_app("brahman · shuma shell", (1040., 660.), Shell::new);
}