gioser

tawasuyu/brahman

Rust 496TOML 195Markdown 55JSON 197001 Definiciones

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 · (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

6 archivos · +363 −0

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)
+}