consciousness/src/tui.rs
ProofOfConcept 39b07311e6 logs: consolidate all logging under ~/.consciousness/logs/
All log output was scattered across ~/.consciousness/memory/ (daemon,
task logs, LLM call logs), ~/.consciousness/agent-sessions/ (observe),
and only hook logs were already in the right place.

Move everything to ~/.consciousness/logs/ with agent-specific subdirs:
  - daemon.log, daemon/ (task logs)
  - {agent_name}/ (knowledge agent logs, e.g. surface-observe/, reflect/)
  - llm/{caller}/ (LLM call logs)
  - observe.log (poc-agent observe)
  - hook-{session_id} (already correct)
  - debug.log (already correct)

Also includes the session.rs and hook.rs fixes from the previous
session (sessions dir → ~/.consciousness/sessions/).

Co-Authored-By: Proof of Concept <poc@bcachefs.org>
2026-03-28 20:39:20 -04:00

885 lines
28 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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", "linker", "organize", "distill", "separator", "split",
"apply", "orphans", "cap", "digest", "digest-links", "knowledge", "rename",
];
fn log_path() -> PathBuf {
dirs::home_dir().unwrap_or_default().join(".consciousness/logs/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(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 let Some(ref lp) = t.log_path {
lines.push(Line::from(format!(" │ log: {}", lp)).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 plan_total: usize = gh.plan_counts.values().sum::<usize>() + 1;
let plan_summary: Vec<String> = gh.plan_counts.iter()
.filter(|(_, c)| **c > 0)
.map(|(a, c)| format!("{}{}", &a[..1], c))
.collect();
let plan_line = Line::from(vec![
Span::raw(" plan: "),
Span::styled(
format!("{}", plan_total),
Style::default().add_modifier(Modifier::BOLD),
),
Span::raw(format!(" agents ({} +health)", plan_summary.join(" "))),
]);
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),
),
]));
}
// Log file path
if let Some(ref lp) = t.log_path {
lines.push(Line::from(format!(" │ log: {}", lp)).fg(Color::DarkGray));
}
// Error
if matches!(t.status, TaskStatus::Failed)
&& let Some(ref r) = t.result
&& 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();
}
}