From ef760f0053480589aeab9f8afa982cc4b5e94a98 Mon Sep 17 00:00:00 2001 From: ProofOfConcept Date: Tue, 10 Mar 2026 00:41:29 -0400 Subject: [PATCH] poc-memory status: add ratatui TUI dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-agent-type tabs (health, replay, linker, separator, transfer, apply, orphans, cap, digest, digest-links, knowledge) with dynamic visibility — tabs only appear when tasks or log history exist. Features: - Overview tab: health gauges (α, gini, cc, episodic%), in-flight tasks, and recent log entries - Pipeline tab: table with phase ordering and status - Per-agent tabs: active tasks, output logs, log history - Log tab: auto-scrolling daemon.log tail - Vim-style count prefix: e.g. 5r runs 5 iterations of the agent - Flash messages for RPC feedback - Tab/Shift-Tab navigation, number keys for tab selection Also adds run-agent RPC to the daemon: accepts agent type and iteration count, spawns chained tasks with LLM resource pool. poc-memory status launches TUI when stdout is a terminal and daemon is running, falls back to text output otherwise. --- Cargo.lock | 286 ++++++++++ poc-memory/Cargo.toml | 2 + poc-memory/src/agents/daemon.rs | 43 +- poc-memory/src/lib.rs | 1 + poc-memory/src/main.rs | 10 + poc-memory/src/tui.rs | 907 ++++++++++++++++++++++++++++++++ 6 files changed, 1247 insertions(+), 2 deletions(-) create mode 100644 poc-memory/src/tui.rs diff --git a/Cargo.lock b/Cargo.lock index 868acae..c6e228a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -22,6 +22,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -273,6 +279,21 @@ dependencies = [ "capnp", ] +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.2.56" @@ -365,6 +386,20 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "compact_str" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -436,6 +471,32 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags", + "crossterm_winapi", + "futures-core", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crunchy" version = "0.2.4" @@ -452,6 +513,40 @@ dependencies = [ "typenum", ] +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.117", +] + [[package]] name = "defer" version = "0.2.1" @@ -987,6 +1082,8 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ + "allocator-api2", + "equivalent", "foldhash", ] @@ -1219,6 +1316,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -1252,6 +1355,28 @@ dependencies = [ "serde_core", ] +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357b7205c6cd18dd2c86ed312d1e70add149aea98e7ef72b9fdf0270e555c11d" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "interpol" version = "0.2.1" @@ -1285,6 +1410,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.17" @@ -1347,6 +1481,12 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "litemap" version = "0.8.1" @@ -1381,6 +1521,15 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -1418,6 +1567,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] @@ -1730,6 +1880,7 @@ dependencies = [ "capnpc", "chrono", "clap", + "crossterm", "faer", "jobkit", "libc", @@ -1737,6 +1888,7 @@ dependencies = [ "memmap2", "paste", "peg", + "ratatui", "rayon", "regex", "rkyv", @@ -2021,6 +2173,27 @@ dependencies = [ "rand 0.9.2", ] +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags", + "cassowary", + "compact_str", + "crossterm", + "indoc", + "instability", + "itertools", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + [[package]] name = "raw-cpuid" version = "11.6.0" @@ -2190,6 +2363,19 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.52.0", +] + [[package]] name = "rustls" version = "0.23.37" @@ -2369,6 +2555,27 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -2426,12 +2633,40 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.117", +] + [[package]] name = "subtle" version = "2.6.1" @@ -2840,6 +3075,35 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width 0.1.14", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -3064,6 +3328,22 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.11" @@ -3073,6 +3353,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-core" version = "0.62.2" diff --git a/poc-memory/Cargo.toml b/poc-memory/Cargo.toml index 9c98aa7..117c528 100644 --- a/poc-memory/Cargo.toml +++ b/poc-memory/Cargo.toml @@ -21,6 +21,8 @@ peg = "0.8" paste = "1" jobkit = { git = "https://evilpiepirate.org/git/jobkit.git/" } log = "0.4" +ratatui = "0.29" +crossterm = { version = "0.28", features = ["event-stream"] } [build-dependencies] capnpc = "0.20" diff --git a/poc-memory/src/agents/daemon.rs b/poc-memory/src/agents/daemon.rs index 1a81f4e..4019dca 100644 --- a/poc-memory/src/agents/daemon.rs +++ b/poc-memory/src/agents/daemon.rs @@ -937,7 +937,7 @@ pub fn run_daemon() -> Result<(), String> { let choir_main = Arc::clone(&choir); let last_daily_main = Arc::clone(&last_daily); let graph_health_main = Arc::clone(&graph_health); - status_socket_loop(&choir_main, &last_daily_main, &graph_health_main); + status_socket_loop(&choir_main, &last_daily_main, &graph_health_main, &llm); log_event("daemon", "stopping", ""); eprintln!("Shutting down..."); @@ -994,9 +994,10 @@ fn status_sock_path() -> PathBuf { /// Any connection gets the live status JSON written and closed. /// Also handles SIGINT/SIGTERM for clean shutdown. fn status_socket_loop( - choir: &Choir, + choir: &Arc, last_daily: &Arc>>, graph_health: &Arc>>, + llm: &Arc, ) { use std::io::{Read as _, Write as _}; use std::os::unix::net::UnixListener; @@ -1047,6 +1048,44 @@ fn status_socket_loop( let _ = stream.write_all(b"{\"ok\":true,\"action\":\"consolidation scheduled\"}\n"); log_event("rpc", "consolidate", "triggered via socket"); } + cmd if cmd.starts_with("run-agent ") => { + let parts: Vec<&str> = cmd.splitn(3, ' ').collect(); + let agent_type = parts.get(1).unwrap_or(&"replay"); + let count: usize = parts.get(2) + .and_then(|s| s.parse().ok()) + .unwrap_or(1); + let batch_size = 5; + + let today = chrono::Local::now().format("%Y-%m-%d"); + let ts = chrono::Local::now().format("%H%M%S"); + let mut prev = None; + let mut spawned = 0; + let mut remaining = count; + + while remaining > 0 { + let batch = remaining.min(batch_size); + let agent = agent_type.to_string(); + let task_name = format!("c-{}-rpc{}:{}", agent, ts, today); + let mut builder = choir.spawn(task_name) + .resource(llm) + .retries(1) + .init(move |ctx| { + job_consolidation_agent(ctx, &agent, batch) + }); + if let Some(ref dep) = prev { + builder.depend_on(dep); + } + prev = Some(builder.run()); + remaining -= batch; + spawned += 1; + } + + let msg = format!("{{\"ok\":true,\"action\":\"queued {} {} run(s) ({} tasks)\"}}\n", + count, agent_type, spawned); + let _ = stream.write_all(msg.as_bytes()); + log_event("rpc", "run-agent", + &format!("{} x{}", agent_type, count)); + } _ => { // Default: return status let status = build_status(choir, *last_daily.lock().unwrap(), graph_health); diff --git a/poc-memory/src/lib.rs b/poc-memory/src/lib.rs index 96cc57b..9d7c6bb 100644 --- a/poc-memory/src/lib.rs +++ b/poc-memory/src/lib.rs @@ -19,6 +19,7 @@ pub mod neuro; // Agent layer (LLM-powered operations) pub mod agents; +pub mod tui; // Re-export agent submodules at crate root for backwards compatibility pub use agents::{ diff --git a/poc-memory/src/main.rs b/poc-memory/src/main.rs index 33387d8..690336d 100644 --- a/poc-memory/src/main.rs +++ b/poc-memory/src/main.rs @@ -805,6 +805,15 @@ fn cmd_health() -> Result<(), String> { } fn cmd_status() -> Result<(), String> { + // If stdout is a tty and daemon is running, launch TUI + if std::io::IsTerminal::is_terminal(&std::io::stdout()) { + // Try TUI first — falls back if daemon not running + match tui::run_tui() { + Ok(()) => return Ok(()), + Err(_) => {} // fall through to text output + } + } + let store = store::Store::load()?; let g = store.build_graph(); @@ -2183,6 +2192,7 @@ fn cmd_daemon(sub: Option<&str>, args: &[String]) -> Result<(), String> { } Some("install") => daemon::install_service(), Some("consolidate") => daemon::rpc_consolidate(), + Some("tui") => tui::run_tui(), Some(other) => Err(format!("unknown daemon subcommand: {}", other)), } } diff --git a/poc-memory/src/tui.rs b/poc-memory/src/tui.rs new file mode 100644 index 0000000..7ebee45 --- /dev/null +++ b/poc-memory/src/tui.rs @@ -0,0 +1,907 @@ +// TUI dashboard for poc-memory daemon +// +// Connects to the daemon status socket, polls periodically, and renders +// a tabbed interface with per-agent-type tabs for drill-down. Designed +// for observability and control of the consolidation system. +// +// Tabs: +// Overview — graph health gauges, in-flight tasks, recent completions +// Pipeline — daily pipeline phases in execution order +// — one tab per agent type (replay, linker, separator, transfer, +// health, apply, etc.) showing all runs with output + log history +// Log — auto-scrolling daemon.log tail + +use crate::agents::daemon::GraphHealth; +use crossterm::event::{self, Event, KeyCode, KeyModifiers}; +use jobkit::{TaskInfo, TaskStatus}; +use ratatui::{ + layout::{Constraint, Layout, Rect}, + style::{Color, Modifier, Style, Stylize}, + text::{Line, Span}, + widgets::{Block, Borders, Cell, Gauge, Paragraph, Row, Table, Tabs, Wrap}, + DefaultTerminal, Frame, +}; +use std::fs; +use std::io::Read as _; +use std::os::unix::net::UnixStream; +use std::path::PathBuf; +use std::time::{Duration, Instant}; + +const POLL_INTERVAL: Duration = Duration::from_secs(2); + +// Agent types we know about, in display order +const AGENT_TYPES: &[&str] = &[ + "health", "replay", "linker", "separator", "transfer", + "apply", "orphans", "cap", "digest", "digest-links", "knowledge", +]; + +fn status_sock_path() -> PathBuf { + crate::config::get().data_dir.join("daemon.sock") +} + +fn log_path() -> PathBuf { + crate::config::get().data_dir.join("daemon.log") +} + +// --- Data fetching --- + +#[derive(serde::Deserialize)] +struct DaemonStatus { + #[allow(dead_code)] + pid: u32, + tasks: Vec, + #[serde(default)] + #[allow(dead_code)] + last_daily: Option, + #[serde(default)] + graph_health: Option, +} + +fn fetch_status() -> Option { + let mut stream = UnixStream::connect(status_sock_path()).ok()?; + stream.set_read_timeout(Some(Duration::from_secs(2))).ok(); + let mut buf = String::new(); + stream.read_to_string(&mut buf).ok()?; + serde_json::from_str(&buf).ok() +} + +#[derive(Clone)] +struct LogEntry { + ts: String, + job: String, + event: String, + detail: String, +} + +fn load_log_entries(max: usize) -> Vec { + let content = match fs::read_to_string(log_path()) { + Ok(c) => c, + Err(_) => return Vec::new(), + }; + + content + .lines() + .rev() + .take(max) + .filter_map(|line| { + let obj: serde_json::Value = serde_json::from_str(line).ok()?; + Some(LogEntry { + ts: obj.get("ts")?.as_str()?.to_string(), + job: obj.get("job")?.as_str()?.to_string(), + event: obj.get("event")?.as_str()?.to_string(), + detail: obj + .get("detail") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + }) + }) + .collect::>() + .into_iter() + .rev() + .collect() +} + +// --- Tab model --- + +#[derive(Clone, PartialEq, Eq)] +enum Tab { + Overview, + Pipeline, + Agent(String), // agent type name: "replay", "linker", etc. + Log, +} + +impl Tab { + fn label(&self) -> String { + match self { + Tab::Overview => "Overview".into(), + Tab::Pipeline => "Pipeline".into(), + Tab::Agent(name) => name.clone(), + Tab::Log => "Log".into(), + } + } +} + +// --- App state --- + +struct App { + tabs: Vec, + tab_idx: usize, + status: Option, + log_entries: Vec, + last_poll: Instant, + scroll: usize, + count_prefix: Option, // numeric prefix for commands (vim-style) + flash_msg: Option<(String, Instant)>, // transient status message +} + +impl App { + fn new() -> Self { + let status = fetch_status(); + let log_entries = load_log_entries(500); + let tabs = Self::build_tabs(&status, &log_entries); + Self { + tabs, + tab_idx: 0, + status, + log_entries, + last_poll: Instant::now(), + scroll: 0, + count_prefix: None, + flash_msg: None, + } + } + + fn build_tabs(status: &Option, log_entries: &[LogEntry]) -> Vec { + let mut tabs = vec![Tab::Overview, Tab::Pipeline]; + + for agent_type in AGENT_TYPES { + let prefix = format!("c-{}", agent_type); + let has_tasks = status + .as_ref() + .map(|s| s.tasks.iter().any(|t| t.name.starts_with(&prefix))) + .unwrap_or(false); + let has_logs = log_entries.iter().any(|e| { + e.job.starts_with(&prefix) || e.job == *agent_type + }); + if has_tasks || has_logs { + tabs.push(Tab::Agent(agent_type.to_string())); + } + } + + tabs.push(Tab::Log); + tabs + } + + fn poll(&mut self) { + if self.last_poll.elapsed() >= POLL_INTERVAL { + self.status = fetch_status(); + self.log_entries = load_log_entries(500); + + // Rebuild tabs, preserving current selection + let current = self.tabs.get(self.tab_idx).cloned(); + self.tabs = Self::build_tabs(&self.status, &self.log_entries); + if let Some(ref cur) = current { + self.tab_idx = self.tabs.iter().position(|t| t == cur).unwrap_or(0); + } + + self.last_poll = Instant::now(); + } + } + + fn current_tab(&self) -> &Tab { + self.tabs.get(self.tab_idx).unwrap_or(&Tab::Overview) + } + + fn tasks(&self) -> &[TaskInfo] { + self.status + .as_ref() + .map(|s| s.tasks.as_slice()) + .unwrap_or(&[]) + } + + fn tasks_for_agent(&self, agent_type: &str) -> Vec<&TaskInfo> { + let prefix = format!("c-{}", agent_type); + self.tasks() + .iter() + .filter(|t| t.name.starts_with(&prefix)) + .collect() + } + + fn logs_for_agent(&self, agent_type: &str) -> Vec<&LogEntry> { + let prefix = format!("c-{}", agent_type); + self.log_entries + .iter() + .filter(|e| e.job.starts_with(&prefix) || e.job == agent_type) + .collect() + } + + fn pipeline_tasks(&self) -> Vec<&TaskInfo> { + self.tasks() + .iter() + .filter(|t| { + let n = &t.name; + n.starts_with("c-") + || n.starts_with("consolidate:") + || n.starts_with("knowledge-loop:") + || n.starts_with("digest:") + || n.starts_with("decay:") + }) + .collect() + } + + fn next_tab(&mut self) { + self.tab_idx = (self.tab_idx + 1) % self.tabs.len(); + self.scroll = 0; + } + + fn prev_tab(&mut self) { + self.tab_idx = (self.tab_idx + self.tabs.len() - 1) % self.tabs.len(); + self.scroll = 0; + } +} + +// --- Rendering --- + +fn format_duration(d: Duration) -> String { + let ms = d.as_millis(); + if ms < 1_000 { + format!("{}ms", ms) + } else if ms < 60_000 { + format!("{:.1}s", ms as f64 / 1000.0) + } else if ms < 3_600_000 { + format!("{}m{}s", ms / 60_000, (ms % 60_000) / 1000) + } else { + format!("{}h{}m", ms / 3_600_000, (ms % 3_600_000) / 60_000) + } +} + +fn task_elapsed(t: &TaskInfo) -> Duration { + if matches!(t.status, TaskStatus::Running) { + if let Some(started) = t.started_at { + let now = std::time::SystemTime::now() + .duration_since(std::time::SystemTime::UNIX_EPOCH) + .unwrap_or_default() + .as_secs_f64(); + Duration::from_secs_f64((now - started).max(0.0)) + } else { + t.elapsed + } + } else { + t.result.as_ref().map(|r| r.duration).unwrap_or(t.elapsed) + } +} + +fn status_style(t: &TaskInfo) -> Style { + if t.cancelled { + return Style::default().fg(Color::DarkGray); + } + match t.status { + TaskStatus::Running => Style::default().fg(Color::Green), + TaskStatus::Completed => Style::default().fg(Color::Blue), + TaskStatus::Failed => Style::default().fg(Color::Red), + TaskStatus::Pending => Style::default().fg(Color::DarkGray), + } +} + +fn status_symbol(t: &TaskInfo) -> &'static str { + if t.cancelled { + return "✗"; + } + match t.status { + TaskStatus::Running => "▶", + TaskStatus::Completed => "✓", + TaskStatus::Failed => "✗", + TaskStatus::Pending => "·", + } +} + +fn event_style(event: &str) -> Style { + match event { + "completed" => Style::default().fg(Color::Blue), + "failed" => Style::default().fg(Color::Red), + "started" => Style::default().fg(Color::Green), + _ => Style::default().fg(Color::DarkGray), + } +} + +fn event_symbol(event: &str) -> &'static str { + match event { + "completed" => "✓", + "failed" => "✗", + "started" => "▶", + _ => "·", + } +} + +fn ts_time(ts: &str) -> &str { + if ts.len() >= 19 { &ts[11..19] } else { ts } +} + +fn render(frame: &mut Frame, app: &App) { + let [header, body, footer] = Layout::vertical([ + Constraint::Length(3), + Constraint::Min(0), + Constraint::Length(1), + ]) + .areas(frame.area()); + + // Tab bar — show index hints for first 9 tabs + let tab_titles: Vec = app + .tabs + .iter() + .enumerate() + .map(|(i, t)| { + let hint = if i < 9 { + format!("{}", i + 1) + } else { + " ".into() + }; + Line::from(format!(" {} {} ", hint, t.label())) + }) + .collect(); + let tabs = Tabs::new(tab_titles) + .select(app.tab_idx) + .highlight_style( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ) + .block(Block::default().borders(Borders::ALL).title(" poc-memory daemon ")); + frame.render_widget(tabs, header); + + // Body + match app.current_tab() { + Tab::Overview => render_overview(frame, app, body), + Tab::Pipeline => render_pipeline(frame, app, body), + Tab::Agent(name) => render_agent_tab(frame, app, name, body), + Tab::Log => render_log(frame, app, body), + } + + // Footer — flash message, count prefix, or help text + let footer_text = if let Some((ref msg, when)) = app.flash_msg { + if when.elapsed() < Duration::from_secs(3) { + Line::from(vec![ + Span::raw(" "), + Span::styled(msg.as_str(), Style::default().fg(Color::Green)), + ]) + } else { + Line::raw("") // expired, will show help below + } + } else { + Line::raw("") + }; + + let footer_line = if !footer_text.spans.is_empty() { + footer_text + } else if let Some(n) = app.count_prefix { + Line::from(vec![ + Span::styled(format!(" {}×", n), Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), + Span::raw(" r: run agent │ Esc: cancel"), + ]) + } else { + match app.current_tab() { + Tab::Agent(_) => Line::from( + " Tab: switch │ ↑↓: scroll │ [N]r: run agent │ c: consolidate │ q: quit ", + ), + _ => Line::from( + " Tab/1-9: switch │ ↑↓: scroll │ c: consolidate │ q: quit ", + ), + } + }; + let footer_widget = Paragraph::new(footer_line).style(Style::default().fg(Color::DarkGray)); + frame.render_widget(footer_widget, footer); +} + +// --- Overview tab --- + +fn render_overview(frame: &mut Frame, app: &App, area: Rect) { + let [health_area, tasks_area] = + Layout::vertical([Constraint::Length(12), Constraint::Min(0)]).areas(area); + + if let Some(ref gh) = app.status.as_ref().and_then(|s| s.graph_health.as_ref()) { + render_health(frame, gh, health_area); + } else { + let p = Paragraph::new(" No graph health data available") + .block(Block::default().borders(Borders::ALL).title(" Graph Health ")); + frame.render_widget(p, health_area); + } + + // In-flight + recent + let in_flight: Vec<&TaskInfo> = app + .tasks() + .iter() + .filter(|t| matches!(t.status, TaskStatus::Running | TaskStatus::Pending)) + .collect(); + + let mut lines: Vec = Vec::new(); + + if in_flight.is_empty() { + lines.push(Line::from(" No tasks in flight").fg(Color::DarkGray)); + } else { + for t in &in_flight { + let elapsed = task_elapsed(t); + let progress = t + .progress + .as_deref() + .filter(|p| *p != "idle") + .unwrap_or(""); + lines.push(Line::from(vec![ + Span::styled(format!(" {} ", status_symbol(t)), status_style(t)), + Span::raw(format!("{:30}", short_name(&t.name))), + Span::styled( + format!(" {:>8}", format_duration(elapsed)), + Style::default().fg(Color::DarkGray), + ), + Span::raw(format!(" {}", progress)), + ])); + if matches!(t.status, TaskStatus::Running) && !t.output_log.is_empty() { + let skip = t.output_log.len().saturating_sub(2); + for line in &t.output_log[skip..] { + lines.push(Line::from(format!(" │ {}", line)).fg(Color::DarkGray)); + } + } + } + } + + lines.push(Line::raw("")); + lines.push(Line::from(" Recent:").fg(Color::DarkGray)); + let recent: Vec<&LogEntry> = app + .log_entries + .iter() + .rev() + .filter(|e| e.event == "completed" || e.event == "failed") + .take(10) + .collect::>() + .into_iter() + .rev() + .collect(); + for entry in &recent { + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(event_symbol(&entry.event), event_style(&entry.event)), + Span::raw(format!( + " {} {:28} {}", + ts_time(&entry.ts), + short_name(&entry.job), + entry.detail + )), + ])); + } + + let tasks_widget = Paragraph::new(lines) + .block(Block::default().borders(Borders::ALL).title(" Tasks ")) + .scroll((app.scroll as u16, 0)); + frame.render_widget(tasks_widget, tasks_area); +} + +fn render_health(frame: &mut Frame, gh: &GraphHealth, area: Rect) { + let block = Block::default() + .borders(Borders::ALL) + .title(format!(" Graph Health ({}) ", gh.computed_at)); + let inner = block.inner(area); + frame.render_widget(block, area); + + let [metrics_area, gauges_area, plan_area] = Layout::vertical([ + Constraint::Length(2), + Constraint::Length(4), + Constraint::Min(1), + ]) + .areas(inner); + + // Metrics + let summary = Line::from(format!( + " {} nodes {} edges {} communities", + gh.nodes, gh.edges, gh.communities + )); + let ep_line = Line::from(vec![ + Span::raw(" episodic: "), + Span::styled( + format!("{:.0}%", gh.episodic_ratio * 100.0), + if gh.episodic_ratio < 0.4 { + Style::default().fg(Color::Green) + } else { + Style::default().fg(Color::Red) + }, + ), + Span::raw(format!(" σ={:.1}", gh.sigma)), + ]); + frame.render_widget(Paragraph::new(vec![summary, ep_line]), metrics_area); + + // Gauges + let [g1, g2, g3] = Layout::horizontal([ + Constraint::Ratio(1, 3), + Constraint::Ratio(1, 3), + Constraint::Ratio(1, 3), + ]) + .areas(gauges_area); + + let alpha_color = if gh.alpha >= 2.5 { Color::Green } else { Color::Red }; + frame.render_widget( + Gauge::default() + .block(Block::default().borders(Borders::ALL).title(" α (≥2.5) ")) + .gauge_style(Style::default().fg(alpha_color)) + .ratio((gh.alpha / 5.0).clamp(0.0, 1.0) as f64) + .label(format!("{:.2}", gh.alpha)), + g1, + ); + + let gini_color = if gh.gini <= 0.4 { Color::Green } else { Color::Red }; + frame.render_widget( + Gauge::default() + .block(Block::default().borders(Borders::ALL).title(" gini (≤0.4) ")) + .gauge_style(Style::default().fg(gini_color)) + .ratio(gh.gini.clamp(0.0, 1.0) as f64) + .label(format!("{:.3}", gh.gini)), + g2, + ); + + let cc_color = if gh.avg_cc >= 0.2 { Color::Green } else { Color::Red }; + frame.render_widget( + Gauge::default() + .block(Block::default().borders(Borders::ALL).title(" cc (≥0.2) ")) + .gauge_style(Style::default().fg(cc_color)) + .ratio(gh.avg_cc.clamp(0.0, 1.0) as f64) + .label(format!("{:.3}", gh.avg_cc)), + g3, + ); + + // Plan + let total = gh.plan_replay + gh.plan_linker + gh.plan_separator + gh.plan_transfer + 1; + let plan_line = Line::from(vec![ + Span::raw(" plan: "), + Span::styled( + format!("{}", total), + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(format!( + " agents ({}r {}l {}s {}t +health)", + gh.plan_replay, gh.plan_linker, gh.plan_separator, gh.plan_transfer + )), + ]); + frame.render_widget(Paragraph::new(plan_line), plan_area); +} + +// --- Pipeline tab --- + +fn render_pipeline(frame: &mut Frame, app: &App, area: Rect) { + let pipeline = app.pipeline_tasks(); + + if pipeline.is_empty() { + let p = Paragraph::new(" No pipeline tasks") + .block(Block::default().borders(Borders::ALL).title(" Daily Pipeline ")); + frame.render_widget(p, area); + return; + } + + let phase_order = [ + "c-health", "c-replay", "c-linker", "c-separator", "c-transfer", + "c-apply", "c-orphans", "c-cap", "c-digest", "c-digest-links", "c-knowledge", + ]; + + let mut rows: Vec = Vec::new(); + let mut seen = std::collections::HashSet::new(); + for phase in &phase_order { + for t in &pipeline { + if t.name.starts_with(phase) && seen.insert(&t.name) { + rows.push(pipeline_row(t)); + } + } + } + for t in &pipeline { + if seen.insert(&t.name) { + rows.push(pipeline_row(t)); + } + } + + let header = Row::new(vec!["", "Phase", "Status", "Duration", "Progress"]) + .style( + Style::default() + .add_modifier(Modifier::BOLD) + .fg(Color::DarkGray), + ); + let widths = [ + Constraint::Length(2), + Constraint::Length(30), + Constraint::Length(10), + Constraint::Length(10), + Constraint::Min(20), + ]; + + let table = Table::new(rows, widths) + .header(header) + .block(Block::default().borders(Borders::ALL).title(" Daily Pipeline ")); + frame.render_widget(table, area); +} + +fn pipeline_row(t: &TaskInfo) -> Row<'static> { + let elapsed = task_elapsed(t); + let progress = t.progress.as_deref().unwrap_or("").to_string(); + let error = t + .result + .as_ref() + .and_then(|r| r.error.as_ref()) + .map(|e| { + let short = if e.len() > 40 { &e[..40] } else { e }; + format!("err: {}", short) + }) + .unwrap_or_default(); + let detail = if !error.is_empty() { error } else { progress }; + + Row::new(vec![ + Cell::from(status_symbol(t)).style(status_style(t)), + Cell::from(short_name(&t.name)), + Cell::from(format!("{}", t.status)), + Cell::from(if !elapsed.is_zero() { + format_duration(elapsed) + } else { + String::new() + }), + Cell::from(detail), + ]) + .style(status_style(t)) +} + +// --- Per-agent-type tab --- + +fn render_agent_tab(frame: &mut Frame, app: &App, agent_type: &str, area: Rect) { + let tasks = app.tasks_for_agent(agent_type); + let logs = app.logs_for_agent(agent_type); + + let mut lines: Vec = Vec::new(); + + // Active/recent tasks + if tasks.is_empty() { + lines.push(Line::from(" No active tasks").fg(Color::DarkGray)); + } else { + lines.push(Line::styled( + " Tasks:", + Style::default().add_modifier(Modifier::BOLD), + )); + lines.push(Line::raw("")); + for t in &tasks { + let elapsed = task_elapsed(t); + let elapsed_str = if !elapsed.is_zero() { + format_duration(elapsed) + } else { + String::new() + }; + let progress = t + .progress + .as_deref() + .filter(|p| *p != "idle") + .unwrap_or(""); + + lines.push(Line::from(vec![ + Span::styled(format!(" {} ", status_symbol(t)), status_style(t)), + Span::styled(format!("{:30}", &t.name), status_style(t)), + Span::styled( + format!(" {:>8}", elapsed_str), + Style::default().fg(Color::DarkGray), + ), + Span::raw(format!(" {}", progress)), + ])); + + // Retries + if t.max_retries > 0 && t.retry_count > 0 { + lines.push(Line::from(vec![ + Span::raw(" retry "), + Span::styled( + format!("{}/{}", t.retry_count, t.max_retries), + Style::default().fg(Color::Yellow), + ), + ])); + } + + // Output log + if !t.output_log.is_empty() { + for log_line in &t.output_log { + lines.push(Line::from(format!(" │ {}", log_line)).fg(Color::DarkGray)); + } + } + + // Error + if matches!(t.status, TaskStatus::Failed) { + if let Some(ref r) = t.result { + if let Some(ref err) = r.error { + lines.push(Line::from(vec![ + Span::styled(" error: ", Style::default().fg(Color::Red)), + Span::styled(err.as_str(), Style::default().fg(Color::Red)), + ])); + } + } + } + + lines.push(Line::raw("")); + } + } + + // Log history for this agent type + lines.push(Line::styled( + " Log history:", + Style::default().add_modifier(Modifier::BOLD), + )); + lines.push(Line::raw("")); + + if logs.is_empty() { + lines.push(Line::from(" (no log entries)").fg(Color::DarkGray)); + } else { + // Show last 30 entries + let start = logs.len().saturating_sub(30); + for entry in &logs[start..] { + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(event_symbol(&entry.event), event_style(&entry.event)), + Span::raw(" "), + Span::styled(ts_time(&entry.ts), Style::default().fg(Color::DarkGray)), + Span::raw(" "), + Span::styled(format!("{:12}", entry.event), event_style(&entry.event)), + Span::raw(format!(" {}", entry.detail)), + ])); + } + } + + let title = format!(" {} ", agent_type); + let p = Paragraph::new(lines) + .block(Block::default().borders(Borders::ALL).title(title)) + .wrap(Wrap { trim: false }) + .scroll((app.scroll as u16, 0)); + frame.render_widget(p, area); +} + +// --- Log tab --- + +fn render_log(frame: &mut Frame, app: &App, area: Rect) { + let block = Block::default().borders(Borders::ALL).title(" Daemon Log "); + let inner = block.inner(area); + frame.render_widget(block, area); + + let visible_height = inner.height as usize; + let total = app.log_entries.len(); + + // Auto-scroll to bottom unless user has scrolled up + let offset = if app.scroll == 0 { + total.saturating_sub(visible_height) + } else { + app.scroll.min(total.saturating_sub(visible_height)) + }; + + let mut lines: Vec = Vec::new(); + for entry in app.log_entries.iter().skip(offset).take(visible_height) { + lines.push(Line::from(vec![ + Span::styled(ts_time(&entry.ts), Style::default().fg(Color::DarkGray)), + Span::raw(" "), + Span::styled(format!("{:12}", entry.event), event_style(&entry.event)), + Span::raw(format!(" {:30} {}", short_name(&entry.job), entry.detail)), + ])); + } + + frame.render_widget(Paragraph::new(lines), inner); +} + +// --- Helpers --- + +fn short_name(name: &str) -> String { + if let Some((verb, path)) = name.split_once(' ') { + let file = path.rsplit('/').next().unwrap_or(path); + let file = file.strip_suffix(".jsonl").unwrap_or(file); + let short = if file.len() > 12 { &file[..12] } else { file }; + format!("{} {}", verb, short) + } else { + name.to_string() + } +} + +fn send_rpc(cmd: &str) -> Option { + let mut stream = UnixStream::connect(status_sock_path()).ok()?; + stream.set_write_timeout(Some(Duration::from_secs(2))).ok(); + stream.set_read_timeout(Some(Duration::from_secs(5))).ok(); + std::io::Write::write_all(&mut stream, cmd.as_bytes()).ok()?; + stream.shutdown(std::net::Shutdown::Write).ok()?; + let mut buf = String::new(); + stream.read_to_string(&mut buf).ok()?; + Some(buf) +} + +// --- Entry point --- + +pub fn run_tui() -> Result<(), String> { + use crossterm::terminal; + + terminal::enable_raw_mode().map_err(|e| format!("not a terminal: {}", e))?; + terminal::disable_raw_mode().ok(); + + let mut terminal = ratatui::init(); + let result = run_event_loop(&mut terminal); + ratatui::restore(); + result +} + +fn run_event_loop(terminal: &mut DefaultTerminal) -> Result<(), String> { + let mut app = App::new(); + + if app.status.is_none() { + return Err("Daemon not running.".into()); + } + + loop { + terminal + .draw(|frame| render(frame, &app)) + .map_err(|e| format!("draw: {}", e))?; + + if event::poll(Duration::from_millis(250)).map_err(|e| format!("poll: {}", e))? { + if let Event::Key(key) = event::read().map_err(|e| format!("read: {}", e))? { + match key.code { + KeyCode::Char('q') => return Ok(()), + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { + return Ok(()) + } + KeyCode::Char('c') => { + let _ = send_rpc("consolidate"); + app.last_poll = Instant::now() - POLL_INTERVAL; + } + KeyCode::Char('r') => { + // Run specific agent type if on an agent tab + if let Tab::Agent(ref name) = app.current_tab().clone() { + let count = app.count_prefix.unwrap_or(1); + let cmd = format!("run-agent {} {}", name, count); + let _ = send_rpc(&cmd); + app.flash_msg = Some(( + format!("Queued {} {} run{}", count, name, + if count > 1 { "s" } else { "" }), + Instant::now(), + )); + app.count_prefix = None; + app.last_poll = Instant::now() - POLL_INTERVAL; + } + } + KeyCode::Tab => { app.count_prefix = None; app.next_tab(); } + KeyCode::BackTab => { app.count_prefix = None; app.prev_tab(); } + // Number keys: if on agent tab, accumulate as count prefix; + // otherwise switch tabs + KeyCode::Char(c @ '1'..='9') => { + if matches!(app.current_tab(), Tab::Agent(_)) { + let digit = (c as usize) - ('0' as usize); + app.count_prefix = Some( + app.count_prefix.unwrap_or(0) * 10 + digit + ); + } else { + let idx = (c as usize) - ('1' as usize); + if idx < app.tabs.len() { + app.tab_idx = idx; + app.scroll = 0; + } + } + } + KeyCode::Down | KeyCode::Char('j') => { + app.scroll = app.scroll.saturating_add(1); + } + KeyCode::Up | KeyCode::Char('k') => { + app.scroll = app.scroll.saturating_sub(1); + } + KeyCode::PageDown => { + app.scroll = app.scroll.saturating_add(20); + } + KeyCode::PageUp => { + app.scroll = app.scroll.saturating_sub(20); + } + KeyCode::Home => { + app.scroll = 0; + } + KeyCode::Esc => { + app.count_prefix = None; + } + _ => {} + } + } + + // Drain remaining events + while event::poll(Duration::ZERO).unwrap_or(false) { + let _ = event::read(); + } + } + + app.poll(); + } +}