poc-memory status: add ratatui TUI dashboard

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.
This commit is contained in:
ProofOfConcept 2026-03-10 00:41:29 -04:00
parent 06df66cf4c
commit ef760f0053
6 changed files with 1247 additions and 2 deletions

286
Cargo.lock generated
View file

@ -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"

View file

@ -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"

View file

@ -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<Choir>,
last_daily: &Arc<Mutex<Option<chrono::NaiveDate>>>,
graph_health: &Arc<Mutex<Option<GraphHealth>>>,
llm: &Arc<ResourcePool>,
) {
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);

View file

@ -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::{

View file

@ -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)),
}
}

907
poc-memory/src/tui.rs Normal file
View file

@ -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
// <agent> — 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<TaskInfo>,
#[serde(default)]
#[allow(dead_code)]
last_daily: Option<String>,
#[serde(default)]
graph_health: Option<GraphHealth>,
}
fn fetch_status() -> Option<DaemonStatus> {
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<LogEntry> {
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::<Vec<_>>()
.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>,
tab_idx: usize,
status: Option<DaemonStatus>,
log_entries: Vec<LogEntry>,
last_poll: Instant,
scroll: usize,
count_prefix: Option<usize>, // 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<DaemonStatus>, log_entries: &[LogEntry]) -> Vec<Tab> {
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<Line> = 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<Line> = 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::<Vec<_>>()
.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<Row> = 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<Line> = 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<Line> = 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<String> {
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();
}
}