use crate::cli::Args;
use crate::collector::Collector;
use crate::format::{bytes, dur, pct, project_basename, shorten, si, sparkline};
use crate::pricing::format_cost;
use crate::model::{ActivityKind, Agent, Snapshot, Status};
use crate::theme;
use anyhow::Result;
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent,
KeyModifiers, MouseButton, MouseEvent, MouseEventKind},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
backend::CrosstermBackend,
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Modifier, Style},
text::{Line, Span},
widgets::{
Block, BorderType, Borders, List, ListItem, Paragraph, Row, Sparkline, Table, Wrap,
},
Frame, Terminal,
};
use std::io::{self, stdout};
use std::time::{Duration, Instant};
#[derive(Copy, Clone, PartialEq, Eq)]
enum Sort { Smart, Cpu, Mem, Uptime, Tokens, Agent }
impl Sort {
fn cycle(self) -> Self {
match self {
Sort::Smart => Sort::Cpu,
Sort::Cpu => Sort::Mem,
Sort::Mem => Sort::Tokens,
Sort::Tokens => Sort::Uptime,
Sort::Uptime => Sort::Agent,
Sort::Agent => Sort::Smart,
}
}
fn label(self) -> &'static str {
match self {
Sort::Smart => "smart",
Sort::Cpu => "cpu",
Sort::Mem => "mem",
Sort::Tokens => "tokens",
Sort::Uptime => "uptime",
Sort::Agent => "agent",
}
}
}
struct App {
collector: Collector,
snap: Snapshot,
last_tick: Instant,
interval: Duration,
paused: bool,
grouped: bool,
sort: Sort,
filter: String,
typing_filter: bool,
show_help: bool,
show_detail: bool,
selected_pid: Option<u32>,
visible_pid_order: Vec<u32>,
clickable_rows: Vec<(u16, u32)>,
quit: bool,
}
pub fn run(collector: Collector, args: Args) -> Result<()> {
let prev = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let _ = disable_raw_mode();
let _ = execute!(stdout(), LeaveAlternateScreen, DisableMouseCapture);
prev(info);
}));
enable_raw_mode()?;
let mut out = stdout();
execute!(out, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(out);
let mut terminal = Terminal::new(backend)?;
let interval = Duration::from_millis((args.interval.max(0.1) * 1000.0) as u64);
let initial_sort = match args.sort.as_str() {
"cpu" => Sort::Cpu,
"mem" => Sort::Mem,
"tokens" => Sort::Tokens,
"uptime" => Sort::Uptime,
"agent" => Sort::Agent,
_ => Sort::Smart,
};
let mut app = App {
collector,
snap: Snapshot::default(),
last_tick: Instant::now() - interval,
interval,
paused: false,
grouped: true,
sort: initial_sort,
filter: args.filter.unwrap_or_default(),
typing_filter: false,
show_help: false,
show_detail: false,
selected_pid: None,
visible_pid_order: Vec::new(),
clickable_rows: Vec::new(),
quit: false,
};
let res = main_loop(&mut terminal, &mut app);
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture)?;
terminal.show_cursor()?;
res
}
fn main_loop<B: ratatui::backend::Backend + io::Write>(
terminal: &mut Terminal<B>,
app: &mut App,
) -> Result<()> {
while !app.quit {
if !app.paused && app.last_tick.elapsed() >= app.interval {
app.snap = app.collector.snapshot();
app.last_tick = Instant::now();
}
terminal.draw(|f| draw(f, app)).map_err(|e| anyhow::anyhow!("ratatui draw failed: {e}"))?;
let timeout = Duration::from_millis(100);
if event::poll(timeout)? {
match event::read()? {
Event::Key(key) => handle_key(app, key),
Event::Mouse(m) => handle_mouse(app, m),
_ => {}
}
}
}
Ok(())
}
fn handle_key(app: &mut App, key: KeyEvent) {
const FILTER_MAX: usize = 256;
if app.typing_filter {
match key.code {
KeyCode::Esc => {
app.typing_filter = false;
app.filter.clear();
}
KeyCode::Enter => app.typing_filter = false,
KeyCode::Backspace => { app.filter.pop(); }
KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => app.filter.clear(),
KeyCode::Char('w') if key.modifiers.contains(KeyModifiers::CONTROL) => {
while matches!(app.filter.chars().last(), Some(c) if c.is_whitespace()) { app.filter.pop(); }
while matches!(app.filter.chars().last(), Some(c) if !c.is_whitespace()) { app.filter.pop(); }
}
KeyCode::Char(c) if !c.is_control() && app.filter.len() < FILTER_MAX => {
app.filter.push(c);
}
_ => {}
}
return;
}
if app.show_detail || app.show_help {
match key.code {
KeyCode::Esc | KeyCode::Char('q') | KeyCode::Enter => {
app.show_detail = false;
app.show_help = false;
}
KeyCode::Char('?') | KeyCode::Char('h') => app.show_help = !app.show_help,
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => app.quit = true,
_ => {}
}
return;
}
match key.code {
KeyCode::Char('q') => app.quit = true,
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => app.quit = true,
KeyCode::Char('?') | KeyCode::Char('h') => app.show_help = true,
KeyCode::Enter => app.show_detail = true,
KeyCode::Char('p') | KeyCode::Char(' ') => app.paused = !app.paused,
KeyCode::Char('r') => {
app.snap = app.collector.snapshot();
app.last_tick = Instant::now();
}
KeyCode::Char('s') => app.sort = app.sort.cycle(),
KeyCode::Char('g') => app.grouped = !app.grouped,
KeyCode::Char('/') | KeyCode::Char('f') => {
app.typing_filter = true;
app.filter.clear();
}
KeyCode::Esc => app.filter.clear(),
KeyCode::Down | KeyCode::Char('j') => move_sel(app, 1),
KeyCode::Up | KeyCode::Char('k') => move_sel(app, -1),
KeyCode::PageDown => move_sel(app, 10),
KeyCode::PageUp => move_sel(app, -10),
KeyCode::Home => move_sel(app, i32::MIN / 2),
KeyCode::End => move_sel(app, i32::MAX / 2),
_ => {}
}
}
fn handle_mouse(app: &mut App, m: MouseEvent) {
match m.kind {
MouseEventKind::Down(MouseButton::Left) => {
if let Some((_, pid)) = app.clickable_rows.iter().find(|(y, _)| *y == m.row) {
if app.selected_pid == Some(*pid) {
app.show_detail = true;
} else {
app.selected_pid = Some(*pid);
}
}
}
MouseEventKind::ScrollUp => move_sel(app, -3),
MouseEventKind::ScrollDown => move_sel(app, 3),
_ => {}
}
}
fn move_sel(app: &mut App, delta: i32) {
if app.visible_pid_order.is_empty() {
return;
}
let cur_idx = app.selected_pid
.and_then(|p| app.visible_pid_order.iter().position(|x| *x == p))
.unwrap_or(0) as i32;
let n = app.visible_pid_order.len() as i32;
let next = (cur_idx + delta).max(0).min(n - 1);
app.selected_pid = Some(app.visible_pid_order[next as usize]);
}
fn draw(f: &mut Frame, app: &mut App) {
let area = f.area();
if area.height < 16 || area.width < 60 {
let p = Paragraph::new(format!(
" agtop needs at least 60×16 (have {}×{}).\n Resize the terminal or use `agtop --once`.",
area.width, area.height
)).style(Style::default().fg(theme::FG_DIM));
f.render_widget(p, area);
return;
}
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), Constraint::Min(0), Constraint::Length(1), ])
.split(area);
draw_header(f, chunks[0], &app.snap, app);
let body = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(58), Constraint::Percentage(42)])
.split(chunks[1]);
let left = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(8), Constraint::Length(10)])
.split(body[0]);
let right = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(10), Constraint::Length(10), Constraint::Length(8), Constraint::Length(8), Constraint::Min(6), ])
.split(body[1]);
draw_agents(f, left[0], app);
draw_left_bottom(f, left[1], &app.snap);
draw_cpu_panel(f, right[0], &app.snap);
draw_memory_panel(f, right[1], &app.snap);
draw_tokens_panel(f, right[2], &app.snap, app.interval);
draw_status_distribution(f, right[3], &app.snap);
draw_sessions(f, right[4], &app.snap);
draw_footer(f, chunks[2], app);
if app.show_help {
draw_help(f, area);
} else if app.show_detail {
draw_detail(f, area, app);
} else if app.typing_filter {
draw_filter_input(f, area, &app.filter);
}
}
fn draw_detail(f: &mut Frame, area: Rect, app: &App) {
let agent = app.snap.agents.iter().find(|a| Some(a.pid) == app.selected_pid);
let Some(a) = agent else { return; };
let w = 100.min(area.width.saturating_sub(4));
let h = 32.min(area.height.saturating_sub(2));
let x = area.x + (area.width.saturating_sub(w)) / 2;
let y = area.y + (area.height.saturating_sub(h)) / 2;
let r = Rect { x, y, width: w, height: h };
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme::BORDER))
.title(Line::from(vec![
Span::styled(format!(" {} {} ", a.status.glyph(), a.status.label()),
theme::status_style(a.status)),
Span::styled(format!("{} ", a.label),
Style::default().fg(theme::agent_color(&a.label)).add_modifier(Modifier::BOLD)),
Span::styled(format!("pid {} ", a.pid), Style::default().fg(theme::FG_DIM)),
Span::styled(format!("· {} ", a.project),
Style::default().fg(theme::BORDER).add_modifier(Modifier::BOLD)),
]));
let inner = block.inner(r);
f.render_widget(ratatui::widgets::Clear, r);
f.render_widget(block, r);
let dim = |s: String| Span::styled(s, Style::default().fg(theme::FG_DIM));
let lab = |s: &str| Span::styled(format!("{:<10}", s), Style::default().fg(theme::FG_DIM));
let val = |s: String, c: ratatui::style::Color| Span::styled(s, Style::default().fg(c).add_modifier(Modifier::BOLD));
let cpu_spark = sparkline(&a.cpu_history, 100.0, 24);
let mut lines: Vec<Line> = Vec::new();
lines.push(Line::from(vec![lab("model"),
a.model.as_deref().map(|m| val(m.into(), theme::C_CHART_TOK)).unwrap_or_else(|| dim("(unknown)".into()))]));
lines.push(Line::from(vec![lab("cpu"),
val(pct(a.cpu), theme::cpu_color(a.cpu)), Span::raw(" "),
Span::styled(cpu_spark, Style::default().fg(theme::cpu_color(a.cpu)))]));
lines.push(Line::from(vec![lab("memory"),
val(bytes(a.rss), theme::C_CHART_MEM), dim(format!(" rss · {} vsize", bytes(a.vsize)))]));
let mut uptime_spans = vec![lab("uptime"), val(dur(a.uptime_sec), theme::FG)];
if a.session_started_ms > 0 {
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64).unwrap_or(0);
let session_age_s = now_ms.saturating_sub(a.session_started_ms) / 1000;
let resumed = session_age_s > a.uptime_sec.saturating_add(60);
let suffix = if resumed { " (resumed)" } else { "" };
uptime_spans.push(dim(format!(" · session {}{}", dur(session_age_s), suffix)));
}
lines.push(Line::from(uptime_spans));
let mut thread_spans = vec![lab("threads"), val(a.threads.to_string(), theme::FG)];
let ppid_label = if a.ppid_name.is_empty() {
format!(" state {} ppid {}", a.state, a.ppid)
} else {
format!(" state {} ppid {} ({})", a.state, a.ppid, a.ppid_name)
};
thread_spans.push(dim(ppid_label));
lines.push(Line::from(thread_spans));
if a.dangerous && !a.dangerous_flag.is_empty() {
lines.push(Line::from(vec![
lab("dangerous"),
val(a.dangerous_flag.clone(), theme::C_WAIT),
]));
}
lines.push(Line::from(vec![lab("tokens"),
val(si(a.tokens_total), theme::C_CHART_TOK),
dim(format!(" ({} in / {} out)", si(a.tokens_input), si(a.tokens_output)))]));
match a.cost_basis.as_str() {
"api" if a.cost_usd > 0.0 => {
lines.push(Line::from(vec![
lab("cost"),
val(format_cost(a.cost_usd), theme::C_WAIT),
dim(format!(" api · prices as of {}", crate::pricing::prices_updated())),
]));
}
"local" => {
lines.push(Line::from(vec![
lab("cost"),
val("local".to_string(), theme::C_IDLE),
dim(" no API cost — model runs on this machine".to_string()),
]));
}
"unknown" if a.model.is_some() => {
lines.push(Line::from(vec![
lab("cost"),
val("unknown".to_string(), theme::C_IDLE),
dim(format!(
" no price for `{}` — pass --prices to set one",
a.model.as_deref().unwrap_or(""),
)),
]));
}
_ => {}
}
if a.tokens_cache_read > 0 && a.tokens_input > 0 {
let hit_pct = (a.tokens_cache_read as f64 / a.tokens_input as f64) * 100.0;
let mut spans = vec![lab("cache"),
val(format!("{:.0}% hit", hit_pct), theme::C_CHART_TOK),
dim(format!(" ({} of {} input tok cached)",
si(a.tokens_cache_read), si(a.tokens_input)))];
if let Some(model) = a.model.as_deref() {
if let Some(p) = app.collector.pricing().lookup(model) {
let saved = (a.tokens_cache_read as f64 / 1_000_000.0)
* p.input_per_mtok * 0.90;
if saved >= 0.01 {
spans.push(dim(format!(" · saved {} vs uncached", format_cost(saved))));
}
}
}
lines.push(Line::from(spans));
}
if a.context_used > 0 && a.context_limit > 0 {
let pct_used = (a.context_used as f64 / a.context_limit as f64).clamp(0.0, 1.0);
let pct_int = (pct_used * 100.0) as u32;
let bar_w = 24usize;
let filled = ((pct_used * bar_w as f64).round() as usize).min(bar_w);
let empty = bar_w.saturating_sub(filled);
let bar_color = if pct_used >= 0.90 { theme::C_BUSY }
else if pct_used >= 0.70 { theme::C_WAIT }
else { theme::C_ACTIVE };
let mut spans = vec![lab("context")];
spans.push(Span::styled("█".repeat(filled), Style::default().fg(bar_color)));
spans.push(Span::styled("░".repeat(empty), Style::default().fg(theme::FG_DIM)));
spans.push(Span::styled(
format!(" {}% ", pct_int),
Style::default().fg(bar_color).add_modifier(Modifier::BOLD),
));
spans.push(dim(format!("({} / {} tok)", si(a.context_used), si(a.context_limit))));
if pct_used >= 0.90 {
spans.push(dim(" · approaching auto-compaction".to_string()));
}
if let Some(secs) = app.collector.time_to_compaction_secs(a.pid, a.context_limit) {
let rate = app.collector.context_growth_per_min(a.pid).unwrap_or(0);
spans.push(dim(format!(" · ≈{} to compaction (+{}/min)",
dur(secs), si(rate))));
}
lines.push(Line::from(spans));
}
if a.label == "claude" {
if a.loaded_skills.is_empty() {
lines.push(Line::from(vec![
lab("skills"),
dim("0 loaded — drop one in ~/.claude/skills/<name>/SKILL.md".to_string()),
]));
} else {
lines.push(Line::from(vec![
lab("skills"),
val(format!("{} loaded", a.loaded_skills.len()), theme::C_CHART_TOK),
dim(format!(" {}", shorten(&a.loaded_skills.join(", "),
(w as usize).saturating_sub(28)))),
]));
}
}
if a.subagents > 0 {
lines.push(Line::from(vec![lab("subagents"),
val(format!("{} in flight", a.subagents), theme::C_SPAWN)]));
for s in a.in_flight_subagents.iter().take(8) {
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled("· ", Style::default().fg(theme::C_SPAWN)),
Span::styled(shorten(s, (w as usize).saturating_sub(14)),
Style::default().fg(theme::FG)),
]));
}
}
let tools_in_flight = a.subagents == 0 && a.session_id.is_some()
&& matches!(a.status, crate::model::Status::Busy);
if tools_in_flight {
lines.push(Line::from(vec![lab("tools"),
val("running".to_string(), theme::C_SPAWN)]));
}
if let Some(sid) = &a.session_id {
lines.push(Line::from(vec![lab("session"), dim(sid.clone())]));
}
if !a.tool_counts.is_empty() {
let parts: Vec<String> = a.tool_counts.iter().take(5)
.map(|(name, n)| format!("{} {}", name, n))
.collect();
lines.push(Line::from(vec![
lab("tools"),
Span::styled(parts.join(" · "), Style::default().fg(theme::C_CHART_TOK)),
]));
}
lines.push(Line::raw(""));
lines.push(Line::from(vec![lab("bin"), Span::styled(shorten(&a.exe, (w as usize).saturating_sub(12)),
Style::default().fg(theme::FG))]));
lines.push(Line::from(vec![lab("cwd"), Span::styled(shorten(&a.cwd, (w as usize).saturating_sub(12)),
Style::default().fg(theme::FG))]));
lines.push(Line::from(vec![lab("cmd"), Span::styled(shorten(&a.cmdline, (w as usize).saturating_sub(12)),
Style::default().fg(theme::FG_DIM))]));
if let Some(tool) = &a.current_tool {
lines.push(Line::from(vec![lab("tool"),
val(tool.clone(), theme::C_SPAWN)]));
}
if let Some(task) = &a.current_task {
lines.push(Line::from(vec![lab("task"),
Span::styled(shorten(task, (w as usize).saturating_sub(12)),
Style::default().fg(theme::FG))]));
}
if !a.writing_files.is_empty() {
lines.push(Line::raw(""));
lines.push(Line::from(lab("writing")));
for f in a.writing_files.iter().take(4) {
lines.push(Line::from(vec![Span::raw(" "),
Span::styled(shorten(f, (w as usize).saturating_sub(6)),
Style::default().fg(theme::FG_DIM))]));
}
}
lines.push(Line::raw(""));
lines.push(Line::from(Span::styled(
" ─ Live preview ".to_string() + &"─".repeat((w as usize).saturating_sub(20)),
Style::default().fg(theme::BORDER_DIM))));
if a.recent_activity.is_empty() {
let has_session = a.session_id.is_some();
let has_reader = matches!(a.label.as_str(),
"claude" | "codex" | "goose" | "aider" | "gemini");
let hint: &str = if has_session {
" (no recent activity in this session)"
} else if has_reader {
match a.label.as_str() {
"claude" => " (no Claude session found for this cwd — `ls ~/.claude/projects/` to inspect)",
"codex" => " (no Codex rollout found for this cwd in ~/.codex/sessions/)",
"goose" => " (no Goose session found in ~/.config/goose/sessions/)",
"aider" => " (no .aider.chat.history.md in this cwd yet)",
"gemini" => " (no Gemini session found in ~/.gemini/sessions/)",
_ => " (no session found)",
}
} else {
" (no transcript reader for this agent type — see /proc fields above)"
};
lines.push(Line::from(Span::styled(hint.to_string(),
Style::default().fg(theme::FG_DIM))));
} else {
let cap = ((h as usize).saturating_sub(lines.len() + 3)).min(8);
for ev in a.recent_activity.iter().rev().take(cap).rev() {
let glyph_col = if ev.starts_with("› ") { theme::FG }
else if ev.starts_with("→ ") { theme::C_SPAWN }
else if ev.starts_with("← ") { theme::C_ACTIVE }
else { theme::FG_DIM };
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(shorten(ev, (w as usize).saturating_sub(4)),
Style::default().fg(glyph_col)),
]));
}
}
lines.push(Line::raw(""));
lines.push(Line::from(Span::styled(" Esc / Enter to close", Style::default().fg(theme::FG_DIM))));
f.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner);
}
fn draw_header(f: &mut Frame, area: Rect, snap: &Snapshot, app: &App) {
let a = &snap.aggregates;
let mem_used = snap.mem_total.saturating_sub(snap.mem_available);
let mut spans: Vec<Span> = vec![
Span::styled(" agtop ",
Style::default().fg(theme::BORDER).add_modifier(Modifier::BOLD)),
Span::styled(format!("v{} ", env!("CARGO_PKG_VERSION")), Style::default().fg(theme::FG_DIM)),
];
let mut chip = |label: &str, value: String, color: ratatui::style::Color| {
spans.push(Span::styled(format!(" {} ", value),
Style::default().fg(color).add_modifier(Modifier::BOLD)));
spans.push(Span::styled(format!("{} ", label), Style::default().fg(theme::FG_DIM)));
};
chip("busy", a.busy.to_string(), theme::C_BUSY);
if a.cost_usd > 0.0 {
chip("cost", format_cost(a.cost_usd), theme::C_WAIT);
}
if a.tokens_total > 0 {
chip("tokens", si(a.tokens_total), theme::C_CHART_TOK);
}
chip("cpu", pct(a.cpu), theme::C_CHART_CPU);
chip("mem", format!("{}/{}", bytes(mem_used), bytes(snap.mem_total)),
theme::C_CHART_MEM);
chip("active", a.active.to_string(), theme::C_ACTIVE);
chip("waiting", a.waiting.to_string(), theme::C_WAIT);
chip("done", a.completed.to_string(), theme::C_DONE);
chip("subagents", a.subagents.to_string(), theme::C_SPAWN);
chip("projects", a.project_count.to_string(), theme::FG);
spans.push(Span::styled(format!(" sort:{} group:{} ", app.sort.label(), if app.grouped {"on"} else {"off"}),
Style::default().fg(theme::FG_DIM)));
if !app.filter.is_empty() {
spans.push(Span::styled(format!("filter:{} ", app.filter),
Style::default().fg(theme::C_WAIT)));
}
if app.paused {
spans.push(Span::styled(" PAUSED ",
Style::default().bg(theme::C_WAIT).fg(ratatui::style::Color::Black).add_modifier(Modifier::BOLD)));
}
if let Some(note) = snap.note.as_deref() {
spans.push(Span::styled(format!(" ⓘ {} ", note),
Style::default().fg(theme::FG_DIM)));
}
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme::BORDER));
let p = Paragraph::new(Line::from(spans)).block(block).wrap(Wrap { trim: true });
f.render_widget(p, area);
}
fn filter_match(a: &Agent, f: &str) -> bool {
if f.is_empty() { return true; }
let f = f.to_lowercase();
a.label.to_lowercase().contains(&f)
|| a.cmdline.to_lowercase().contains(&f)
|| a.cwd.to_lowercase().contains(&f)
|| a.project.to_lowercase().contains(&f)
|| a.pid.to_string() == f
}
fn draw_agents(f: &mut Frame, area: Rect, app: &mut App) {
let snap = &app.snap;
let mut agents: Vec<&Agent> = snap.agents.iter().filter(|a| filter_match(a, &app.filter)).collect();
match app.sort {
Sort::Smart => {} Sort::Cpu => agents.sort_by(|a, b| b.cpu.partial_cmp(&a.cpu).unwrap_or(std::cmp::Ordering::Equal)),
Sort::Mem => agents.sort_by(|a, b| b.rss.cmp(&a.rss)),
Sort::Tokens => agents.sort_by(|a, b| b.tokens_total.cmp(&a.tokens_total)),
Sort::Uptime => agents.sort_by(|a, b| b.uptime_sec.cmp(&a.uptime_sec)),
Sort::Agent => agents.sort_by(|a, b| a.label.cmp(&b.label)),
}
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme::BORDER))
.title(Span::styled(" Agents ", Style::default().fg(theme::FG).add_modifier(Modifier::BOLD)))
.title_alignment(Alignment::Left);
let inner = block.inner(area);
f.render_widget(block, area);
let mut pid_order: Vec<u32> = Vec::new();
let mut rows: Vec<Row> = Vec::new();
let mut agent_row_indices: Vec<usize> = Vec::new();
let mut group_index: usize = 0;
if app.grouped {
let mut by_proj: Vec<(String, Vec<&Agent>)> = Vec::new();
for a in agents.iter() {
if let Some((_, list)) = by_proj.iter_mut().find(|(p, _)| p == &a.project) {
list.push(*a);
} else {
by_proj.push((a.project.clone(), vec![*a]));
}
}
by_proj.sort_by(|(p1, l1), (p2, l2)| {
let r1 = l1.first().map(|a| a.status.rank()).unwrap_or(99);
let r2 = l2.first().map(|a| a.status.rank()).unwrap_or(99);
r1.cmp(&r2).then(p1.cmp(p2))
});
for (proj, list) in by_proj {
let total_cpu: f64 = list.iter().map(|a| a.cpu).sum();
let total_mem: u64 = list.iter().map(|a| a.rss).sum();
let total_sub: u32 = list.iter().map(|a| a.subagents).sum();
let total_tok: u64 = list.iter().map(|a| a.tokens_total).sum();
let mut header_spans: Vec<Span> = Vec::new();
header_spans.push(Span::styled("● ", Style::default().fg(theme::BORDER)));
header_spans.push(Span::styled(proj.clone(),
Style::default().fg(theme::BORDER).add_modifier(Modifier::BOLD)));
header_spans.push(Span::styled(
format!(" {} agent{} · {} cpu · {} mem",
list.len(),
if list.len() == 1 {""} else {"s"},
pct(total_cpu),
bytes(total_mem)),
Style::default().fg(theme::FG_DIM)));
if total_sub > 0 {
header_spans.push(Span::styled(format!(" +{}", total_sub),
Style::default().fg(theme::C_SPAWN).add_modifier(Modifier::BOLD)));
header_spans.push(Span::styled(" sub", Style::default().fg(theme::FG_DIM)));
}
if total_tok > 0 {
header_spans.push(Span::styled(format!(" {}", si(total_tok)),
Style::default().fg(theme::C_CHART_TOK).add_modifier(Modifier::BOLD)));
header_spans.push(Span::styled(" tok", Style::default().fg(theme::FG_DIM)));
}
let header_line = Line::from(header_spans);
let header_row = Row::new(vec![header_line]).height(1)
.style(Style::default().bg(theme::group_tint(group_index)));
rows.push(header_row);
let tint = Some(theme::group_tint(group_index));
group_index += 1;
for a in list {
pid_order.push(a.pid);
agent_row_indices.push(rows.len());
rows.push(agent_row(a, app.selected_pid == Some(a.pid), tint));
}
}
} else {
let mut last_proj: Option<&str> = None;
for a in agents.iter() {
if last_proj != Some(a.project.as_str()) {
group_index += 1;
last_proj = Some(a.project.as_str());
}
let tint = Some(theme::group_tint(group_index));
pid_order.push(a.pid);
agent_row_indices.push(rows.len());
rows.push(agent_row(a, app.selected_pid == Some(a.pid), tint));
}
}
app.visible_pid_order = pid_order.clone();
if app.selected_pid.is_none() {
app.selected_pid = pid_order.first().copied();
} else if let Some(p) = app.selected_pid {
if !pid_order.contains(&p) {
if app.show_detail { app.show_detail = false; }
app.selected_pid = pid_order.first().copied();
}
}
if rows.is_empty() {
let msg = if snap.agents.is_empty() {
" (no agents detected — try `agtop --list-builtins` or set $AGTOP_MATCH)".to_string()
} else if !app.filter.is_empty() {
format!(" (no agents match \"{}\" — Esc to clear)", app.filter)
} else {
" (no rows)".into()
};
rows.push(Row::new(vec![Line::from(Span::styled(msg,
Style::default().fg(theme::FG_DIM)))]).height(1));
}
app.clickable_rows.clear();
for (i, ri) in agent_row_indices.iter().enumerate() {
if let Some(pid) = pid_order.get(i) {
let y = inner.y + (*ri as u16);
if y < inner.y + inner.height {
app.clickable_rows.push((y, *pid));
}
}
}
let table = Table::new(rows, [Constraint::Percentage(100)])
.column_spacing(0);
f.render_widget(table, inner);
}
fn agent_row<'a>(a: &'a Agent, selected: bool, group_bg: Option<ratatui::style::Color>) -> Row<'a> {
let mut spans: Vec<Span> = Vec::new();
spans.push(Span::raw(" "));
spans.push(Span::styled(
format!("{} {:<4}", a.status.glyph(), a.status.label()),
theme::status_style(a.status),
));
spans.push(Span::raw(" "));
if a.dangerous {
spans.push(Span::styled("▍",
Style::default().fg(ratatui::style::Color::Rgb(240, 175, 95))
.add_modifier(Modifier::BOLD)));
} else {
spans.push(Span::raw(" "));
}
spans.push(Span::styled(format!("{:<9}", shorten(&a.label, 9)),
Style::default().fg(theme::agent_color(&a.label)).add_modifier(Modifier::BOLD)));
spans.push(Span::raw(" "));
spans.push(Span::styled(format!("{:>7}", a.pid),
Style::default().fg(theme::FG_DIM)));
spans.push(Span::raw(" "));
let cpu_col = theme::cpu_color(a.cpu);
spans.push(Span::styled(format!("{:>5}", pct(a.cpu)),
Style::default().fg(cpu_col).add_modifier(Modifier::BOLD)));
spans.push(Span::raw(" "));
let bar_cells = (a.cpu / 100.0 * 4.0).round().clamp(0.0, 4.0) as usize;
spans.push(Span::styled("█".repeat(bar_cells),
Style::default().fg(cpu_col).add_modifier(Modifier::BOLD)));
spans.push(Span::styled("·".repeat(4 - bar_cells),
Style::default().fg(theme::BORDER_DIM)));
spans.push(Span::raw(" "));
spans.push(Span::styled(format!("{:>5}", bytes(a.rss)),
Style::default().fg(theme::C_CHART_MEM)));
spans.push(Span::raw(" "));
spans.push(Span::styled(format!("{:>6}", dur(a.uptime_sec)),
Style::default().fg(theme::FG_DIM)));
if a.subagents > 0 {
spans.push(Span::styled(format!(" +{:<2}", a.subagents.min(99)),
Style::default().fg(theme::C_SPAWN).add_modifier(Modifier::BOLD)));
} else {
spans.push(Span::raw(" "));
}
if a.tokens_total > 0 {
let s = si(a.tokens_total);
let clipped: String = s.chars().take(5).collect();
spans.push(Span::styled(format!(" {:>5}", clipped),
Style::default().fg(theme::C_CHART_TOK)));
} else {
spans.push(Span::raw(" "));
}
spans.push(Span::raw(" "));
spans.push(describe_doing_span(a));
let line = Line::from(spans);
let mut row = Row::new(vec![line]).height(1);
if selected {
row = row.style(Style::default().bg(theme::HL_BG).add_modifier(Modifier::BOLD));
} else if let Some(bg) = group_bg {
row = row.style(Style::default().bg(bg));
}
row
}
fn describe_doing_span(a: &Agent) -> Span<'static> {
if let Some(tool) = &a.current_tool {
let suffix = a.current_task.as_deref().map(|t|
format!(": {}", shorten(t, 60))
).unwrap_or_default();
return Span::styled(format!("{}{}", tool, suffix),
Style::default().fg(theme::C_SPAWN).add_modifier(Modifier::BOLD));
}
if let Some(t) = &a.current_task {
return Span::styled(shorten(t, 70).to_string(),
Style::default().fg(theme::FG));
}
if a.status == Status::Idle {
if let Some(age) = a.session_age_ms {
return Span::styled(format!("(idle {})", dur(age / 1000)),
Style::default().fg(theme::FG_DIM));
}
}
if a.status == Status::Waiting {
return Span::styled("(awaiting input)".to_string(), Style::default().fg(theme::C_WAIT));
}
if a.status == Status::Completed {
return Span::styled("(session ended)".to_string(), Style::default().fg(theme::C_DONE));
}
Span::styled(shorten(&a.cmdline, 80).to_string(),
Style::default().fg(theme::FG_DIM))
}
fn draw_left_bottom(f: &mut Frame, area: Rect, snap: &Snapshot) {
let inner = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(area);
draw_projects(f, inner[0], snap);
draw_activity(f, inner[1], snap);
}
fn draw_projects(f: &mut Frame, area: Rect, snap: &Snapshot) {
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme::BORDER))
.title(Span::styled(" Projects ", Style::default().fg(theme::FG).add_modifier(Modifier::BOLD)));
let inner = block.inner(area);
f.render_widget(block, area);
let max_lines = inner.height as usize;
let mut items: Vec<ListItem> = Vec::new();
for (i, p) in snap.projects.iter().take(max_lines).enumerate() {
let row_bg = theme::group_tint(i);
let dominant = if *p.statuses.get("busy").unwrap_or(&0) > 0 { Status::Busy }
else if *p.statuses.get("spawning").unwrap_or(&0) > 0 { Status::Spawning }
else if *p.statuses.get("active").unwrap_or(&0) > 0 { Status::Active }
else if *p.statuses.get("idle").unwrap_or(&0) > 0 { Status::Idle }
else { Status::Stale };
let bar_w: usize = ((p.cpu / 100.0) * 12.0).round().max(0.0) as usize;
let bar_w = bar_w.min(12);
let bar = "█".repeat(bar_w);
let bar_pad = " ".repeat(12usize.saturating_sub(bar_w));
let mut spans: Vec<Span> = Vec::new();
spans.push(Span::styled(format!("{} ", dominant.glyph()), theme::status_style(dominant)));
spans.push(Span::styled(format!("{:<14}", shorten(&p.project, 14)),
Style::default().fg(theme::FG)));
spans.push(Span::styled(format!("{:>2}", p.agents), Style::default().fg(theme::FG_DIM)));
spans.push(Span::raw(" "));
spans.push(Span::styled(format!("{:>6}", pct(p.cpu)),
Style::default().fg(theme::cpu_color(p.cpu))));
spans.push(Span::raw(" "));
spans.push(Span::styled(bar, Style::default().fg(theme::cpu_color(p.cpu))));
spans.push(Span::styled(bar_pad, Style::default().fg(theme::BORDER_DIM)));
if p.subagents > 0 {
spans.push(Span::styled(format!(" +{}", p.subagents),
Style::default().fg(theme::C_SPAWN).add_modifier(Modifier::BOLD)));
}
items.push(ListItem::new(Line::from(spans)).style(Style::default().bg(row_bg)));
}
if items.is_empty() {
items.push(ListItem::new(Line::from(Span::styled(" (no projects)", Style::default().fg(theme::FG_DIM)))));
}
let list = List::new(items);
f.render_widget(list, inner);
}
fn draw_activity(f: &mut Frame, area: Rect, snap: &Snapshot) {
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme::BORDER))
.title(Span::styled(" Activity ", Style::default().fg(theme::FG).add_modifier(Modifier::BOLD)));
let inner = block.inner(area);
f.render_widget(block, area);
let mut items: Vec<ListItem> = Vec::new();
for (i, e) in snap.activity.iter().take(inner.height as usize).enumerate() {
let row_bg = theme::group_tint(i);
let secs = e.t / 1000;
let nd = chrono::DateTime::<chrono::Local>::from(
std::time::UNIX_EPOCH + std::time::Duration::from_secs(secs));
let t = nd.format("%H:%M:%S").to_string();
let (glyph, glyph_style) = match e.kind {
ActivityKind::Spawn => ("●", Style::default().fg(theme::C_BUSY).add_modifier(Modifier::BOLD)),
ActivityKind::Exit => ("◌", Style::default().fg(theme::FG_DIM)),
};
let kind = match e.kind { ActivityKind::Spawn => "spawn", ActivityKind::Exit => "exit " };
let cwd = e.cwd.as_deref().map(project_basename).unwrap_or_default();
let mut spans: Vec<Span> = vec![
Span::styled(t, Style::default().fg(theme::FG_DIM)),
Span::raw(" "),
Span::styled(glyph.to_string(), glyph_style),
Span::raw(" "),
Span::styled(kind.to_string(), Style::default().fg(theme::FG_DIM)),
Span::raw(" "),
Span::styled(format!("{:<12}", shorten(&e.label, 12)),
Style::default().fg(theme::agent_color(&e.label))),
Span::raw(" "),
Span::styled(format!("pid {:<7}", e.pid),
Style::default().fg(theme::FG_DIM)),
];
if !cwd.is_empty() {
spans.push(Span::styled(format!(" {}", cwd),
Style::default().fg(theme::FG)));
}
items.push(ListItem::new(Line::from(spans)).style(Style::default().bg(row_bg)));
}
if items.is_empty() {
items.push(ListItem::new(Line::from(Span::styled(" (no recent events)", Style::default().fg(theme::FG_DIM)))));
}
let list = List::new(items);
f.render_widget(list, inner);
}
fn draw_cpu_panel(f: &mut Frame, area: Rect, snap: &Snapshot) {
let peak = snap.history.cpu.iter().copied().fold(0.0_f64, f64::max);
let avg = if !snap.history.cpu.is_empty() {
snap.history.cpu.iter().sum::<f64>() / snap.history.cpu.len() as f64
} else { 0.0 };
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme::BORDER))
.title(Line::from(vec![
Span::styled(" CPU ", Style::default().fg(theme::FG).add_modifier(Modifier::BOLD)),
Span::styled(format!("{} cores · ", snap.sys_cpus),
Style::default().fg(theme::FG_DIM)),
Span::styled("now ", Style::default().fg(theme::FG_DIM)),
Span::styled(pct(snap.aggregates.cpu),
Style::default().fg(theme::cpu_color(snap.aggregates.cpu)).add_modifier(Modifier::BOLD)),
Span::styled(" · peak ", Style::default().fg(theme::FG_DIM)),
Span::styled(pct(peak),
Style::default().fg(theme::C_CHART_CPU).add_modifier(Modifier::BOLD)),
Span::styled(" · avg ", Style::default().fg(theme::FG_DIM)),
Span::styled(pct(avg),
Style::default().fg(theme::FG).add_modifier(Modifier::BOLD)),
Span::styled(" ", Style::default().fg(theme::FG_DIM)),
]));
let inner = block.inner(area);
f.render_widget(block, area);
let split = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(2), Constraint::Min(0)])
.split(inner);
let want = split[0].width as usize;
let spark_data: Vec<u64> = snap.history.cpu.iter()
.rev().take(want).rev()
.map(|v| (v * 10.0) as u64).collect();
let spark_max = ((peak.max(snap.aggregates.cpu).max(10.0) / 10.0).ceil() * 10.0 * 10.0) as u64;
let sparkline = Sparkline::default()
.data(&spark_data)
.max(spark_max)
.style(Style::default().fg(theme::C_CHART_CPU));
f.render_widget(sparkline, split[0]);
let mut agents: Vec<&Agent> = snap.agents.iter().collect();
agents.sort_by(|a, b| b.cpu.partial_cmp(&a.cpu).unwrap_or(std::cmp::Ordering::Equal));
let bar_basis = agents.first().map(|a| a.cpu).unwrap_or(0.0).max(snap.aggregates.cpu).max(1.0);
let bar_width = (split[1].width as usize).saturating_sub(34).max(8);
let mut items: Vec<ListItem> = Vec::new();
let take = (split[1].height as usize).saturating_sub(0);
for a in agents.iter().take(take) {
let frac = (a.cpu / bar_basis).max(0.0);
let filled = (frac * bar_width as f64).round() as usize;
let filled = filled.min(bar_width);
let bar_full = "█".repeat(filled);
let bar_empty = "·".repeat(bar_width - filled);
let cpu_col = theme::cpu_color(a.cpu);
let mut spans: Vec<Span> = Vec::new();
spans.push(Span::styled(format!("{} ", a.status.glyph()), theme::status_style(a.status)));
spans.push(Span::styled(format!("{:<10}", shorten(&a.project, 10)),
Style::default().fg(theme::FG)));
spans.push(Span::raw(" "));
spans.push(Span::styled(format!("{:<8}", shorten(&a.label, 8)),
Style::default().fg(theme::agent_color(&a.label))));
spans.push(Span::raw(" "));
spans.push(Span::styled(bar_full, Style::default().fg(cpu_col).add_modifier(Modifier::BOLD)));
spans.push(Span::styled(bar_empty, Style::default().fg(theme::BORDER_DIM)));
spans.push(Span::raw(" "));
spans.push(Span::styled(format!("{:>6}", pct(a.cpu)),
Style::default().fg(cpu_col).add_modifier(Modifier::BOLD)));
items.push(ListItem::new(Line::from(spans)));
}
if items.is_empty() {
items.push(ListItem::new(Line::from(Span::styled(
" (no agents)", Style::default().fg(theme::FG_DIM)))));
}
f.render_widget(List::new(items), split[1]);
}
fn draw_memory_panel(f: &mut Frame, area: Rect, snap: &Snapshot) {
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme::BORDER))
.title(Line::from(vec![
Span::styled(" Memory by agent ", Style::default().fg(theme::FG).add_modifier(Modifier::BOLD)),
Span::styled(format!("{} across {} agent{}",
bytes(snap.aggregates.mem_bytes),
snap.aggregates.active,
if snap.aggregates.active == 1 {""} else {"s"}),
Style::default().fg(theme::FG_DIM)),
]));
let inner = block.inner(area);
f.render_widget(block, area);
let split = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(0), Constraint::Length(2)])
.split(inner);
let mut agents: Vec<&Agent> = snap.agents.iter().collect();
agents.sort_by(|a, b| b.rss.cmp(&a.rss));
let max_rss = agents.iter().map(|a| a.rss).max().unwrap_or(1).max(1);
let bar_width = (split[0].width as usize).saturating_sub(34).max(8);
let mut items: Vec<ListItem> = Vec::new();
let take = (split[0].height as usize).saturating_sub(0);
for a in agents.iter().take(take) {
let frac = a.rss as f64 / max_rss as f64;
let filled = (frac * bar_width as f64).round() as usize;
let filled = filled.min(bar_width);
let bar_full = "█".repeat(filled);
let bar_empty = "·".repeat(bar_width - filled);
let mut spans: Vec<Span> = Vec::new();
spans.push(Span::styled(format!("{} ", a.status.glyph()), theme::status_style(a.status)));
spans.push(Span::styled(format!("{:<10}", shorten(&a.project, 10)),
Style::default().fg(theme::FG)));
spans.push(Span::raw(" "));
spans.push(Span::styled(format!("{:<8}", shorten(&a.label, 8)),
Style::default().fg(theme::agent_color(&a.label))));
spans.push(Span::raw(" "));
spans.push(Span::styled(bar_full, Style::default().fg(theme::C_CHART_MEM).add_modifier(Modifier::BOLD)));
spans.push(Span::styled(bar_empty, Style::default().fg(theme::BORDER_DIM)));
spans.push(Span::raw(" "));
spans.push(Span::styled(format!("{:>7}", bytes(a.rss)),
Style::default().fg(theme::C_CHART_MEM)));
items.push(ListItem::new(Line::from(spans)));
}
if items.is_empty() {
items.push(ListItem::new(Line::from(Span::styled(
" (no agents)", Style::default().fg(theme::FG_DIM)))));
}
f.render_widget(List::new(items), split[0]);
let total = snap.mem_total.max(1);
let avail = snap.mem_available;
let used = total.saturating_sub(avail);
let agent_mem = snap.aggregates.mem_bytes.min(used);
let other = used.saturating_sub(agent_mem);
let w = split[1].width as usize;
if w > 4 {
let cells = (w as i64) - 2;
let agent_cells = ((agent_mem as f64 / total as f64) * cells as f64).round() as i64;
let other_cells = ((other as f64 / total as f64) * cells as f64).round() as i64;
let free_cells = (cells - agent_cells - other_cells).max(0);
let line = Line::from(vec![
Span::raw(" "),
Span::styled("█".repeat(agent_cells.max(0) as usize),
Style::default().fg(theme::C_GAUGE_AGENT)),
Span::styled("█".repeat(other_cells.max(0) as usize),
Style::default().fg(theme::C_GAUGE_USED)),
Span::styled("░".repeat(free_cells.max(0) as usize),
Style::default().fg(theme::C_GAUGE_FREE)),
]);
let label = Line::from(vec![
Span::styled(" agents ", Style::default().fg(theme::FG_DIM)),
Span::styled(bytes(agent_mem),
Style::default().fg(theme::C_GAUGE_AGENT).add_modifier(Modifier::BOLD)),
Span::styled(" other ", Style::default().fg(theme::FG_DIM)),
Span::styled(bytes(other),
Style::default().fg(theme::C_GAUGE_USED).add_modifier(Modifier::BOLD)),
Span::styled(" free ", Style::default().fg(theme::FG_DIM)),
Span::styled(bytes(avail),
Style::default().fg(theme::FG).add_modifier(Modifier::BOLD)),
Span::styled(format!(" / {}", bytes(total)),
Style::default().fg(theme::FG_DIM)),
]);
f.render_widget(Paragraph::new(vec![line, label]), split[1]);
}
}
fn draw_tokens_panel(f: &mut Frame, area: Rect, snap: &Snapshot, interval: Duration) {
let recent: Vec<f64> = snap.history.tokens_rate.iter().rev().take(20).copied().collect();
let rate_per_tick = if !recent.is_empty() {
recent.iter().sum::<f64>() / recent.len() as f64
} else { 0.0 };
let tick_secs = interval.as_secs_f64().max(0.1);
let rate_per_min = rate_per_tick * 60.0 / tick_secs;
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme::BORDER))
.title(Line::from(vec![
Span::styled(" Tokens ", Style::default().fg(theme::FG).add_modifier(Modifier::BOLD)),
Span::styled("total ", Style::default().fg(theme::FG_DIM)),
Span::styled(si(snap.aggregates.tokens_total),
Style::default().fg(theme::C_CHART_TOK).add_modifier(Modifier::BOLD)),
Span::styled(" rate ", Style::default().fg(theme::FG_DIM)),
Span::styled(format!("{}/min", si(rate_per_min as u64)),
Style::default().fg(theme::C_CHART_TOK).add_modifier(Modifier::BOLD)),
Span::styled(" ", Style::default().fg(theme::FG_DIM)),
]));
let inner = block.inner(area);
f.render_widget(block, area);
let split = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(2), Constraint::Min(0)])
.split(inner);
let want = split[0].width as usize;
let spark_data: Vec<u64> = snap.history.tokens_rate.iter()
.rev().take(want).rev()
.map(|v| *v as u64).collect();
let sparkline = Sparkline::default()
.data(&spark_data)
.style(Style::default().fg(theme::C_CHART_TOK));
f.render_widget(sparkline, split[0]);
let mut agents: Vec<&Agent> = snap.agents.iter().filter(|a| a.tokens_total > 0).collect();
agents.sort_by(|a, b| b.tokens_total.cmp(&a.tokens_total));
let max = agents.first().map(|a| a.tokens_total).unwrap_or(1).max(1);
let bar_width = (split[1].width as usize).saturating_sub(34).max(8);
let mut items: Vec<ListItem> = Vec::new();
let take = (split[1].height as usize).saturating_sub(0);
for a in agents.iter().take(take) {
let frac = a.tokens_total as f64 / max as f64;
let filled = ((frac * bar_width as f64).round() as usize).min(bar_width);
let bar_full = "█".repeat(filled);
let bar_empty = "·".repeat(bar_width - filled);
let mut spans: Vec<Span> = Vec::new();
spans.push(Span::styled(format!("{} ", a.status.glyph()), theme::status_style(a.status)));
spans.push(Span::styled(format!("{:<10}", shorten(&a.project, 10)),
Style::default().fg(theme::FG)));
spans.push(Span::raw(" "));
spans.push(Span::styled(format!("{:<8}", shorten(&a.label, 8)),
Style::default().fg(theme::agent_color(&a.label))));
spans.push(Span::raw(" "));
spans.push(Span::styled(bar_full, Style::default().fg(theme::C_CHART_TOK).add_modifier(Modifier::BOLD)));
spans.push(Span::styled(bar_empty, Style::default().fg(theme::BORDER_DIM)));
spans.push(Span::raw(" "));
spans.push(Span::styled(format!("{:>6}", si(a.tokens_total)),
Style::default().fg(theme::C_CHART_TOK).add_modifier(Modifier::BOLD)));
items.push(ListItem::new(Line::from(spans)));
}
if items.is_empty() {
items.push(ListItem::new(Line::from(Span::styled(
" (no token usage detected — open a Claude/Codex session)",
Style::default().fg(theme::FG_DIM)))));
}
f.render_widget(List::new(items), split[1]);
}
fn draw_status_distribution(f: &mut Frame, area: Rect, snap: &Snapshot) {
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme::BORDER))
.title(Line::from(vec![
Span::styled(" Status distribution ", Style::default().fg(theme::FG).add_modifier(Modifier::BOLD)),
Span::styled(format!("{} live agent{} · {} session{}",
snap.aggregates.active,
if snap.aggregates.active == 1 {""} else {"s"},
snap.sessions.waiting + snap.sessions.completed + snap.sessions.active,
""),
Style::default().fg(theme::FG_DIM)),
]));
let inner = block.inner(area);
f.render_widget(block, area);
let live = snap.aggregates.active;
let waiting = snap.sessions.waiting;
let done = snap.sessions.completed;
let total = (live + waiting + done).max(1);
let mut counts: std::collections::HashMap<&'static str, u32> = std::collections::HashMap::new();
for a in &snap.agents {
*counts.entry(status_key(a.status)).or_insert(0) += 1;
}
let row = |name: &str, count: u32, status: Status| {
let bar_w = (inner.width as usize).saturating_sub(28).max(8);
let frac = count as f64 / total as f64;
let filled = (frac * bar_w as f64).round() as usize;
let filled = filled.min(bar_w);
let pct_v = frac * 100.0;
let mut spans: Vec<Span> = Vec::new();
spans.push(Span::styled(format!(" {} {:<6} ", status.glyph(), name),
theme::status_style(status)));
spans.push(Span::styled(format!("{:>3} ", count),
Style::default().fg(theme::FG).add_modifier(Modifier::BOLD)));
spans.push(Span::styled("█".repeat(filled),
Style::default().fg(theme::status_color(status)).add_modifier(Modifier::BOLD)));
spans.push(Span::styled("·".repeat(bar_w - filled),
Style::default().fg(theme::BORDER_DIM)));
spans.push(Span::styled(format!(" {:>4.1}%", pct_v),
Style::default().fg(theme::FG_DIM)));
Line::from(spans)
};
let lines = vec![
row("BUSY", *counts.get("busy").unwrap_or(&0), Status::Busy),
row("SPWN", *counts.get("spawning").unwrap_or(&0), Status::Spawning),
row("ACTV", *counts.get("active").unwrap_or(&0), Status::Active),
row("idle", *counts.get("idle").unwrap_or(&0), Status::Idle),
row("WAIT", waiting, Status::Waiting),
row("DONE", done, Status::Completed),
];
f.render_widget(Paragraph::new(lines), inner);
}
fn draw_sessions(f: &mut Frame, area: Rect, snap: &Snapshot) {
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme::BORDER))
.title(Span::styled(" Claude sessions — recent tasks ",
Style::default().fg(theme::FG).add_modifier(Modifier::BOLD)));
let inner = block.inner(area);
f.render_widget(block, area);
let s = &snap.sessions;
let a = &snap.aggregates;
let mut lines: Vec<Line> = Vec::new();
if a.subagents > 0 {
lines.push(Line::from(vec![
Span::styled(format!(" {} ", a.subagents),
Style::default().fg(theme::C_SPAWN).add_modifier(Modifier::BOLD)),
Span::styled(format!("Task subagent{} in flight ",
if a.subagents == 1 {""} else {"s"}),
Style::default().fg(theme::FG_DIM)),
]));
}
if s.recent_tasks.is_empty() {
lines.push(Line::from(Span::styled(" (no tasks in last 24h)",
Style::default().fg(theme::FG_DIM))));
}
let cap = (inner.height as usize).saturating_sub(if a.subagents > 0 { 1 } else { 0 });
for t in s.recent_tasks.iter().take(cap) {
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(t.status.glyph().to_string(), theme::status_style(t.status)),
Span::raw(" "),
Span::styled(format!("{:<14}", shorten(&t.project_short, 14)),
Style::default().fg(theme::FG).add_modifier(Modifier::BOLD)),
Span::raw(" "),
Span::styled(shorten(&t.task, 80).to_string(),
Style::default().fg(theme::FG_DIM)),
]));
}
let p = Paragraph::new(lines).wrap(Wrap { trim: false });
f.render_widget(p, inner);
}
fn status_key(s: Status) -> &'static str {
match s {
Status::Busy => "busy",
Status::Spawning => "spawning",
Status::Active => "active",
Status::Idle => "idle",
Status::Waiting => "waiting",
Status::Completed => "completed",
Status::Stale => "stale",
}
}
fn draw_footer(f: &mut Frame, area: Rect, app: &App) {
let s = format!(
" q quit · ? help · s sort({}) · g group({}) · / filter · p {} · r refresh · ↑↓ select",
app.sort.label(),
if app.grouped {"on"} else {"off"},
if app.paused {"resume"} else {"pause"},
);
let p = Paragraph::new(Span::styled(s, Style::default().fg(theme::FG_DIM)));
f.render_widget(p, area);
}
fn draw_filter_input(f: &mut Frame, area: Rect, filter: &str) {
let r = Rect {
x: area.x,
y: area.y + area.height.saturating_sub(2),
width: area.width,
height: 1,
};
let p = Paragraph::new(Line::from(vec![
Span::styled(" filter: ", Style::default().bg(theme::BORDER).fg(ratatui::style::Color::Black).add_modifier(Modifier::BOLD)),
Span::raw(" "),
Span::styled(filter.to_string(), Style::default().fg(theme::FG)),
Span::styled("█", Style::default().fg(theme::C_BUSY)),
]));
f.render_widget(p, r);
}
fn draw_help(f: &mut Frame, area: Rect) {
let w = 70.min(area.width);
let h = 22.min(area.height);
let x = area.x + (area.width.saturating_sub(w)) / 2;
let y = area.y + (area.height.saturating_sub(h)) / 2;
let r = Rect { x, y, width: w, height: h };
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme::BORDER))
.title(Span::styled(" Help ", Style::default().fg(theme::FG).add_modifier(Modifier::BOLD)));
let inner = block.inner(r);
f.render_widget(ratatui::widgets::Clear, r);
f.render_widget(block.clone(), r);
let _ = block;
let line = |spans: Vec<Span<'static>>| Line::from(spans);
let dim = |s: &str| Span::styled(s.to_string(), Style::default().fg(theme::FG_DIM));
let hdr = |s: &str| Span::styled(s.to_string(), Style::default().fg(theme::FG).add_modifier(Modifier::BOLD));
let key = |s: &str| Span::styled(s.to_string(), Style::default().fg(theme::C_SPAWN).add_modifier(Modifier::BOLD));
let lines: Vec<Line> = vec![
line(vec![hdr("agtop"), dim(&format!(" v{} — agent monitor", env!("CARGO_PKG_VERSION")))]),
line(vec![dim(&format!(" prices as of {} ({}) — `--prices PATH` to override",
crate::pricing::prices_updated(),
crate::pricing::prices_source()))]),
Line::raw(""),
line(vec![key(" q, Ctrl-C "), dim("quit (closes popup first if open)")]),
line(vec![key(" ?, h "), dim("toggle this help")]),
line(vec![key(" p, Space "), dim("pause / resume refresh")]),
line(vec![key(" r "), dim("refresh now")]),
line(vec![key(" s "), dim("cycle sort (smart / cpu / mem / tokens / uptime / agent)")]),
line(vec![key(" g "), dim("toggle project grouping")]),
line(vec![key(" /, f "), dim("filter (Ctrl-U clears, Ctrl-W deletes word)")]),
line(vec![key(" Esc "), dim("close popup, clear filter")]),
line(vec![key(" j/k, ↓/↑ "), dim("move selection")]),
line(vec![key(" PgUp/PgDn "), dim("move 10 rows")]),
line(vec![key(" Home/End "), dim("first / last agent")]),
line(vec![key(" Enter "), dim("open / close detail popup for selected agent")]),
line(vec![key(" Mouse "), dim("click row → select; double-click → detail; wheel → scroll")]),
Line::raw(""),
line(vec![hdr(" Status legend:")]),
line(vec![Span::styled(" ● BUSY ", Style::default().fg(theme::C_BUSY).add_modifier(Modifier::BOLD)),
dim("process active and writing in last 5s")]),
line(vec![Span::styled(" ● SPWN ", Style::default().fg(theme::C_SPAWN).add_modifier(Modifier::BOLD)),
dim("Task subagents currently in flight")]),
line(vec![Span::styled(" ● ACTV ", Style::default().fg(theme::C_ACTIVE)),
dim("process running recently")]),
line(vec![Span::styled(" ○ idle ", Style::default().fg(theme::C_IDLE)),
dim("process up but quiet for >60s")]),
line(vec![Span::styled(" ◌ WAIT ", Style::default().fg(theme::C_WAIT)),
dim("no live process, recent session activity")]),
line(vec![Span::styled(" ✓ DONE ", Style::default().fg(theme::C_DONE)),
dim("session ended (stop_reason)")]),
];
f.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner);
}