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 900x640 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 porque GPUI no tiene runtime
tokio.
- Helper nuevo 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 900x640, ~6 cards en vivo, refrescando cada 2s.
cargo check --workspace: 0 errores, 0 warnings.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5c41ef920d6871b8bbac67f34032f318139319f6 · por Sergio · 2026-05-08T19:54:49Z (hace 5 meses) · padre 7831c0c82 · ver el árbol en este commit
Delta semántico · 6
crates/apps/nouser-explorer/src/main.rs
crates/core/brahman-admin/src/client.rs
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 <sha>`.
## 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<StatusSnapshot>,
+ error: Option<SharedString>,
+}
+
+impl Explorer {
+ fn new(cx: &mut Context<Self>) -> 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<Self>) -> 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<gpui::AnyElement> = 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<String> = 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<Path>) -> Result<StatusSnapshot, AdminError>
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<Path>) -> Result<StatusSnapshot, AdminError> {
+ 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)
+}