diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d42f6d..33ddc3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,33 @@ ratio/diff ver `git show `. ## 2026-05-08 +### feat(nouser): yahweh widget — `nouser-explorer` panel GPUI +Bin GPUI standalone que consulta `brahman-admin` cada 2s y renderea +todas las sesiones del Init como cards. Cierra el círculo visual del +ecosistema brahman. + +- Crate nuevo `crates/apps/nouser-explorer` (deps: brahman-admin, + brahman-card, gpui). +- Ventana 900×640 con header del estado del Init, banner de error + cuando no conecta, y lista de cards (una por sesión). +- Cada card muestra: kind + label + lifecycle, ULID corto, summary + (si data), keywords, lens hint, service_socket si está, y refs + (RelationshipKind → target_label). El borde izquierdo coloreado + diferencia ente (azul) de data (lavanda). +- `cx.spawn(async move |this, cx| { … })` corre el loop de refresh + en el GPUI executor; `query_blocking` se usa porque GPUI no provee + un runtime tokio. +- Nuevo helper en brahman-admin: `client::query_blocking(path)` — + versión sync de `query()`, para callers con su propio executor. + +Uso: + + $ ente-zero & nouser daemon crates/core & + $ cargo run -p nouser-explorer + # ventana muestra ~6 cards en vivo, refrescando cada 2s. + +cargo check --workspace: 0 errores, 0 warnings. + ### feat(nouser): persistencia sled write-through del MonadDb `MonadDb` ahora soporta backend dual: diff --git a/Cargo.lock b/Cargo.lock index 180c34c..763abc7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6374,6 +6374,15 @@ dependencies = [ "walkdir", ] +[[package]] +name = "nouser-explorer" +version = "0.1.0" +dependencies = [ + "brahman-admin", + "brahman-card", + "gpui", +] + [[package]] name = "nouser-nous" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index f7b5c77..dde00c0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -81,6 +81,7 @@ members = [ "crates/apps/text_viewer", "crates/apps/image_viewer", "crates/apps/yahweh-shell", + "crates/apps/nouser-explorer", ] [workspace.package] diff --git a/crates/apps/nouser-explorer/Cargo.toml b/crates/apps/nouser-explorer/Cargo.toml new file mode 100644 index 0000000..b90d7fd --- /dev/null +++ b/crates/apps/nouser-explorer/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "nouser-explorer" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Explorador GPUI de Mónadas: panel que consulta brahman-admin y renderea las sesiones como cards." + +[dependencies] +brahman-admin = { path = "../../core/brahman-admin" } +brahman-card = { path = "../../core/brahman-card" } +gpui = { workspace = true } + +[[bin]] +name = "nouser-explorer" +path = "src/main.rs" diff --git a/crates/apps/nouser-explorer/src/main.rs b/crates/apps/nouser-explorer/src/main.rs new file mode 100644 index 0000000..3fbe271 --- /dev/null +++ b/crates/apps/nouser-explorer/src/main.rs @@ -0,0 +1,295 @@ +//! `nouser-explorer` — panel GPUI que muestra las Mónadas (y demás +//! sesiones) registradas en el Init brahman. +//! +//! Diseño: ventana standalone que cada N segundos consulta el socket +//! admin (`brahman_admin::client::query_blocking`) y renderea las +//! sesiones como cards. Sin integración con yahweh-shell — es su +//! propio binario para que el ecosistema sea visible incluso sin la +//! shell completa. +//! +//! Uso: +//! ```sh +//! cargo run -p nouser-explorer +//! # con override de socket admin: +//! BRAHMAN_ADMIN_SOCKET=/tmp/brahman-admin.sock cargo run -p nouser-explorer +//! ``` + +use std::time::Duration; + +use brahman_admin::{client::query_blocking, transport, StatusSnapshot}; +use brahman_card::CardKind; +use gpui::{ + div, prelude::*, px, rgb, App, Application, Bounds, Context, IntoElement, Render, SharedString, + Window, WindowBounds, WindowOptions, +}; + +const REFRESH_INTERVAL: Duration = Duration::from_secs(2); + +fn main() { + Application::new().run(|cx: &mut App| { + let bounds = Bounds::centered(None, gpui::size(px(900.), px(640.)), cx); + cx.open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(bounds)), + titlebar: Some(gpui::TitlebarOptions { + title: Some(SharedString::from("Nouser — Mónadas")), + ..Default::default() + }), + ..Default::default() + }, + |_w, cx| cx.new(Explorer::new), + ) + .expect("open window"); + cx.activate(true); + }); +} + +/// Vista raíz: contiene el último snapshot recibido y el último error. +struct Explorer { + snapshot: Option, + error: Option, +} + +impl Explorer { + fn new(cx: &mut Context) -> Self { + // Loop de refresh: cada `REFRESH_INTERVAL`, query al admin y + // actualiza el modelo. `cx.spawn` corre en el GPUI executor; + // el `query_blocking` sí bloquea pero sólo brevemente — admin + // responde con un snapshot pequeño. + cx.spawn(async move |this, cx| { + let timer = cx.background_executor().clone(); + loop { + let path = transport::default_socket_path(); + let result = query_blocking(&path); + let _ = this.update(cx, |me, cx| { + match result { + Ok(snap) => { + me.snapshot = Some(snap); + me.error = None; + } + Err(e) => { + me.error = Some(SharedString::from(format!( + "no conectado a {}: {}", + path.display(), + e + ))); + } + } + cx.notify(); + }); + timer.timer(REFRESH_INTERVAL).await; + } + }) + .detach(); + + Self { + snapshot: None, + error: None, + } + } +} + +impl Render for Explorer { + fn render(&mut self, _w: &mut Window, _cx: &mut Context) -> impl IntoElement { + let bg = rgb(0x14171c); + let card_bg = rgb(0x1d2128); + let text_dim = rgb(0x9ba1ad); + let text = rgb(0xe6e8ec); + let accent_ente = rgb(0x88c0d0); + let accent_data = rgb(0xb48ead); + + let header_text = match &self.snapshot { + Some(s) => format!( + "Init · protocol={} · attached={} · {} sesión(es){}", + s.protocol_version, + s.init_attached, + s.sessions.len(), + s.current_context + .as_deref() + .map(|c| format!(" · context: {}", c)) + .unwrap_or_default() + ), + None => "Esperando snapshot del Init brahman…".to_string(), + }; + + let header = div() + .px(px(16.)) + .py(px(12.)) + .bg(card_bg) + .border_b_1() + .border_color(rgb(0x2a2f38)) + .text_color(text) + .text_size(px(14.)) + .child(header_text); + + let error_banner = self.error.as_ref().map(|e| { + div() + .px(px(16.)) + .py(px(8.)) + .bg(rgb(0x4a2020)) + .text_color(rgb(0xffd0d0)) + .text_size(px(12.)) + .child(e.clone()) + }); + + let cards: Vec = match &self.snapshot { + None => vec![], + Some(snap) => snap + .sessions + .iter() + .map(|s| { + let (kind_label, accent) = match s.kind { + CardKind::Ente => ("ente", accent_ente), + CardKind::Data => ("data", accent_data), + }; + + let summary_line = s + .data + .as_ref() + .map(|d| d.summary.clone()) + .unwrap_or_default(); + + let keywords = s + .data + .as_ref() + .map(|d| d.keywords.join(", ")) + .unwrap_or_default(); + + let lens_line = s + .data + .as_ref() + .map(|d| d.presentation_hint.clone()) + .filter(|h| !h.is_empty()) + .map(|h| format!("lens: {h}")) + .unwrap_or_default(); + + let sock_line = s + .service_socket + .as_ref() + .map(|p| format!("socket: {}", p.display())) + .unwrap_or_default(); + + let refs_line = if s.references.is_empty() { + String::new() + } else { + let parts: Vec = s + .references + .iter() + .map(|r| { + format!( + "{:?}→{}", + r.kind, + if r.target_label.is_empty() { + "?" + } else { + r.target_label.as_str() + } + ) + }) + .collect(); + format!("refs: {}", parts.join(" ")) + }; + + div() + .flex() + .flex_col() + .p(px(12.)) + .mb(px(8.)) + .bg(card_bg) + .rounded(px(6.)) + .border_l_4() + .border_color(accent) + .gap(px(2.)) + .child( + div() + .flex() + .flex_row() + .gap(px(8.)) + .items_center() + .child( + div() + .text_color(accent) + .text_size(px(11.)) + .child(format!("[{kind_label}]")), + ) + .child( + div() + .text_color(text) + .text_size(px(15.)) + .child(s.label.clone()), + ) + .child( + div() + .text_color(text_dim) + .text_size(px(11.)) + .child(format!("{:?}", s.lifecycle)), + ), + ) + .child( + div() + .text_color(text_dim) + .text_size(px(11.)) + .child(format!("id: {}", s.session)), + ) + .when(!summary_line.is_empty(), |d| { + d.child( + div() + .text_color(text) + .text_size(px(12.)) + .child(summary_line.clone()), + ) + }) + .when(!keywords.is_empty(), |d| { + d.child( + div() + .text_color(text_dim) + .text_size(px(11.)) + .child(format!("keywords: {keywords}")), + ) + }) + .when(!lens_line.is_empty(), |d| { + d.child( + div() + .text_color(text_dim) + .text_size(px(11.)) + .child(lens_line), + ) + }) + .when(!sock_line.is_empty(), |d| { + d.child( + div() + .text_color(text_dim) + .text_size(px(11.)) + .child(sock_line), + ) + }) + .when(!refs_line.is_empty(), |d| { + d.child( + div() + .text_color(text_dim) + .text_size(px(11.)) + .child(refs_line), + ) + }) + .into_any_element() + }) + .collect(), + }; + + let body = div() + .flex() + .flex_col() + .p(px(16.)) + .overflow_hidden() + .children(cards); + + div() + .flex() + .flex_col() + .size_full() + .bg(bg) + .child(header) + .when_some(error_banner, |d, b| d.child(b)) + .child(body) + } +} diff --git a/crates/core/brahman-admin/src/client.rs b/crates/core/brahman-admin/src/client.rs index 2c5c7d3..40cba19 100644 --- a/crates/core/brahman-admin/src/client.rs +++ b/crates/core/brahman-admin/src/client.rs @@ -30,3 +30,19 @@ pub async fn query(path: impl AsRef) -> Result let snapshot = serde_json::from_str(&line)?; Ok(snapshot) } + +/// Variante sync de [`query`] para callers que no tienen runtime tokio +/// (típicamente: GUIs con su propio executor, como GPUI). +pub fn query_blocking(path: impl AsRef) -> Result { + use std::io::{BufRead, BufReader as StdBufReader}; + use std::os::unix::net::UnixStream as StdUnixStream; + let stream = StdUnixStream::connect(path)?; + let mut reader = StdBufReader::new(stream); + let mut line = String::new(); + let n = reader.read_line(&mut line)?; + if n == 0 { + return Err(AdminError::Empty); + } + let snapshot = serde_json::from_str(&line)?; + Ok(snapshot) +}