Add distill_count to ConsolidationPlan, daemon health metrics, and TUI display. Distill agent now participates in the consolidation budget alongside replay, linker, separator, transfer, organize, and connector. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
891 lines
28 KiB
Rust
891 lines
28 KiB
Rust
// 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::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", "rename", "split",
|
||
];
|
||
|
||
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 json = jobkit_daemon::socket::send_rpc(&crate::config::get().data_dir, "")?;
|
||
serde_json::from_str(&json).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 + gh.plan_distill + 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 {}d +health)",
|
||
gh.plan_replay, gh.plan_linker, gh.plan_separator, gh.plan_transfer, gh.plan_distill
|
||
)),
|
||
]);
|
||
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> {
|
||
jobkit_daemon::socket::send_rpc(&crate::config::get().data_dir, cmd)
|
||
}
|
||
|
||
// --- 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();
|
||
}
|
||
}
|