use agent_top_core::{Agent, AgentState, Snapshot};
use ratatui::crossterm::event::KeyCode;
use std::collections::VecDeque;
use std::time::{Duration, Instant};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SortKey {
State,
Name,
Tokens,
Cost,
Cpu,
Mem,
Age,
}
impl SortKey {
pub fn label(self) -> &'static str {
match self {
SortKey::State => "state",
SortKey::Name => "name",
SortKey::Tokens => "tokens",
SortKey::Cost => "cost",
SortKey::Cpu => "cpu",
SortKey::Mem => "mem",
SortKey::Age => "age",
}
}
fn next(self) -> SortKey {
match self {
SortKey::State => SortKey::Name,
SortKey::Name => SortKey::Tokens,
SortKey::Tokens => SortKey::Cost,
SortKey::Cost => SortKey::Cpu,
SortKey::Cpu => SortKey::Mem,
SortKey::Mem => SortKey::Age,
SortKey::Age => SortKey::State,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DetailView {
Tree,
Trace,
}
impl DetailView {
pub fn label(self) -> &'static str {
match self {
DetailView::Tree => "tree",
DetailView::Trace => "trace",
}
}
fn next(self) -> DetailView {
match self {
DetailView::Tree => DetailView::Trace,
DetailView::Trace => DetailView::Tree,
}
}
}
const HISTORY: usize = 120;
const RATE_WINDOW: Duration = Duration::from_secs(10);
const BURN_WINDOW: Duration = Duration::from_secs(60);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Panel {
SlowTools,
FailedTools,
Advice,
Mcp,
}
impl Panel {
pub const ALL: [Panel; 4] = [Panel::SlowTools, Panel::FailedTools, Panel::Advice, Panel::Mcp];
pub fn key(self) -> char {
match self {
Panel::SlowTools => 'l',
Panel::FailedTools => 'f',
Panel::Advice => 'a',
Panel::Mcp => 'm',
}
}
pub fn command(self) -> &'static str {
match self {
Panel::SlowTools => "slow",
Panel::FailedTools => "fails",
Panel::Advice => "advice",
Panel::Mcp => "mcp",
}
}
pub fn title(self) -> &'static str {
match self {
Panel::SlowTools => "slowest tools",
Panel::FailedTools => "failed tool calls",
Panel::Advice => "advice",
Panel::Mcp => "mcp servers",
}
}
fn from_key(c: char) -> Option<Panel> {
Panel::ALL.into_iter().find(|p| p.key() == c)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Overlay {
None,
Help,
Panel(Panel),
Update,
}
const NOTICE_FOR: Duration = Duration::from_secs(5);
pub struct App {
pub snapshot: Snapshot,
pub rows: Vec<Agent>,
pub selected_id: Option<String>,
pub selected: usize,
pub sort: SortKey,
pub sort_desc: bool,
pub show_detail: bool,
pub detail: DetailView,
pub overlay: Overlay,
pub pinned: Option<Panel>,
pub standalone: bool,
pub scroll: u16,
pub multiplexer: Option<crate::pane::Multiplexer>,
pub open_requested: Option<Panel>,
pub notice: Option<(String, Instant)>,
pub show_stopped: bool,
pub paused: bool,
pub cpu_history: Vec<u64>,
pub output_rate: Vec<u64>,
pub cost_history: Vec<u64>,
rate_samples: VecDeque<(Instant, u64)>,
cost_samples: VecDeque<(Instant, u64)>,
pub burn_per_hour: f64,
pub update: std::sync::Arc<std::sync::Mutex<Option<String>>>,
pub update_dismissed: Option<String>,
update_prompted: bool,
pub installer: crate::update::Installer,
pub upgrade_requested: Option<String>,
}
impl App {
pub fn new(snapshot: Snapshot) -> Self {
let mut app = App {
snapshot,
rows: Vec::new(),
selected_id: None,
selected: 0,
sort: SortKey::State,
sort_desc: false,
show_detail: true,
detail: DetailView::Tree,
overlay: Overlay::None,
pinned: None,
standalone: false,
scroll: 0,
multiplexer: None,
open_requested: None,
notice: None,
show_stopped: true,
paused: false,
cpu_history: Vec::new(),
output_rate: Vec::new(),
cost_history: Vec::new(),
rate_samples: VecDeque::new(),
cost_samples: VecDeque::new(),
burn_per_hour: 0.0,
update: std::sync::Arc::new(std::sync::Mutex::new(None)),
update_dismissed: None,
update_prompted: false,
installer: crate::update::Installer::Unknown,
upgrade_requested: None,
};
app.rebuild_rows();
app
}
pub fn latest(&self) -> Option<String> {
self.update.lock().ok().and_then(|g| g.clone())
}
pub fn maybe_prompt_update(&mut self) {
if self.update_prompted || self.overlay != Overlay::None || self.standalone {
return;
}
let Some(latest) = self.latest() else { return };
if self.update_dismissed.as_deref() == Some(latest.as_str()) {
return;
}
self.update_prompted = true;
self.overlay = Overlay::Update;
}
pub fn close_overlay(&mut self) {
if self.overlay == Overlay::Update
&& let Some(latest) = self.latest()
{
self.update_dismissed = Some(latest.clone());
crate::update::dismiss(&latest);
}
self.overlay = Overlay::None;
}
pub fn update(&mut self, snapshot: Snapshot) {
self.update_at(snapshot, Instant::now());
}
pub fn update_at(&mut self, snapshot: Snapshot, now: Instant) {
push(&mut self.cpu_history, snapshot.host.cpu_percent.round() as u64);
let output: u64 = snapshot.agents.iter().map(|a| a.usage.output).sum();
self.rate_samples.push_back((now, output));
while self.rate_samples.len() > 2 && self.rate_samples[1].0 + RATE_WINDOW <= now {
self.rate_samples.pop_front();
}
push(&mut self.output_rate, output_per_second(&self.rate_samples));
push(&mut self.cost_history, (snapshot.totals.cost_usd * 100.0) as u64);
let cost_micros = (snapshot.totals.cost_usd * 1_000_000.0) as u64;
self.cost_samples.push_back((now, cost_micros));
while self.cost_samples.len() > 2 && self.cost_samples[1].0 + BURN_WINDOW <= now {
self.cost_samples.pop_front();
}
self.burn_per_hour = burn_per_hour(&self.cost_samples);
self.snapshot = snapshot;
self.rebuild_rows();
self.maybe_prompt_update();
}
pub fn rebuild_rows(&mut self) {
let mut rows: Vec<Agent> =
self.snapshot.agents.iter().filter(|a| self.show_stopped || a.state != AgentState::Stopped).cloned().collect();
let key = self.sort;
rows.sort_by(|a, b| {
let ord = match key {
SortKey::State => a.state.cmp(&b.state).then_with(|| b.usage.total().cmp(&a.usage.total())),
SortKey::Name => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
SortKey::Tokens => b.usage.total().cmp(&a.usage.total()),
SortKey::Cost => b.cost_usd.partial_cmp(&a.cost_usd).unwrap_or(std::cmp::Ordering::Equal),
SortKey::Cpu => b.cpu_percent.partial_cmp(&a.cpu_percent).unwrap_or(std::cmp::Ordering::Equal),
SortKey::Mem => b.rss_bytes.cmp(&a.rss_bytes),
SortKey::Age => b.age_secs.cmp(&a.age_secs),
};
if self.sort_desc { ord.reverse() } else { ord }
});
self.rows = rows;
if let Some(id) = &self.selected_id
&& let Some(i) = self.rows.iter().position(|a| &a.id == id)
{
self.selected = i;
}
if self.rows.is_empty() {
self.selected = 0;
} else if self.selected >= self.rows.len() {
self.selected = self.rows.len() - 1;
}
self.selected_id = self.rows.get(self.selected).map(|a| a.id.clone());
}
pub fn selected_agent(&self) -> Option<&Agent> {
self.rows.get(self.selected)
}
fn select(&mut self, i: usize) {
if self.rows.is_empty() {
return;
}
self.selected = i.min(self.rows.len() - 1);
self.selected_id = Some(self.rows[self.selected].id.clone());
}
pub fn pin(&mut self, p: Panel) {
self.pinned = Some(p);
self.overlay = Overlay::None;
self.scroll = 0;
}
fn unpin(&mut self) {
if !self.standalone {
self.pinned = None;
self.scroll = 0;
}
}
pub fn notify(&mut self, text: impl Into<String>) {
self.notice = Some((text.into(), Instant::now()));
}
pub fn current_notice(&self) -> Option<&str> {
self.notice.as_ref().filter(|(_, at)| at.elapsed() < NOTICE_FOR).map(|(t, _)| t.as_str())
}
fn panel_key(&mut self, p: Panel) {
match self.overlay {
Overlay::Panel(open) if open == p => self.overlay = Overlay::None,
Overlay::Panel(_) => self.overlay = Overlay::Panel(p),
_ if self.pinned == Some(p) => self.unpin(),
_ => self.overlay = Overlay::Panel(p),
}
}
fn panel_in_front(&self) -> Option<Panel> {
match self.overlay {
Overlay::Panel(p) => Some(p),
Overlay::None => self.pinned,
_ => None,
}
}
fn scroll_or_select(&mut self, code: KeyCode) {
let page: u16 = 10;
if self.pinned.is_some() && self.overlay == Overlay::None {
self.scroll = match code {
KeyCode::Char('j') | KeyCode::Down => self.scroll.saturating_add(1),
KeyCode::Char('k') | KeyCode::Up => self.scroll.saturating_sub(1),
KeyCode::PageDown => self.scroll.saturating_add(page),
KeyCode::PageUp => self.scroll.saturating_sub(page),
KeyCode::Char('g') | KeyCode::Home => 0,
KeyCode::Char('G') | KeyCode::End => u16::MAX,
_ => self.scroll,
};
return;
}
match code {
KeyCode::Char('j') | KeyCode::Down => self.select(self.selected + 1),
KeyCode::Char('k') | KeyCode::Up => self.select(self.selected.saturating_sub(1)),
KeyCode::Char('g') | KeyCode::Home => self.select(0),
KeyCode::Char('G') | KeyCode::End => self.select(usize::MAX),
KeyCode::PageDown => self.select(self.selected + page as usize),
KeyCode::PageUp => self.select(self.selected.saturating_sub(page as usize)),
_ => {}
}
}
pub fn on_key(&mut self, code: KeyCode) -> bool {
if self.overlay == Overlay::Update {
match code {
KeyCode::Char('u') | KeyCode::Char('y') | KeyCode::Enter => {
if self.installer.steps().is_some() {
self.upgrade_requested = self.latest();
}
return false;
}
KeyCode::Char('n') | KeyCode::Esc => {
self.close_overlay();
return false;
}
KeyCode::Char('q') => return true,
_ => {}
}
}
match code {
KeyCode::Char('j' | 'k' | 'g' | 'G')
| KeyCode::Down
| KeyCode::Up
| KeyCode::Home
| KeyCode::End
| KeyCode::PageDown
| KeyCode::PageUp => self.scroll_or_select(code),
KeyCode::Char('s') => {
self.sort = self.sort.next();
self.rebuild_rows();
}
KeyCode::Char('r') => {
self.sort_desc = !self.sort_desc;
self.rebuild_rows();
}
KeyCode::Enter if matches!(self.overlay, Overlay::Panel(_)) => {
if let Overlay::Panel(p) = self.overlay {
self.pin(p);
}
}
KeyCode::Char('t') | KeyCode::Enter if self.pinned.is_none() => self.show_detail = !self.show_detail,
KeyCode::Char('o') => {
if let (Some(_), Some(p)) = (self.multiplexer, self.panel_in_front()) {
self.open_requested = Some(p);
if self.overlay == Overlay::Panel(p) {
self.overlay = Overlay::None;
} else {
self.unpin();
}
}
}
KeyCode::Tab | KeyCode::Char('v') if self.pinned.is_none() => {
if self.show_detail {
self.detail = self.detail.next();
} else {
self.show_detail = true;
}
}
KeyCode::Char('x') => {
self.show_stopped = !self.show_stopped;
self.rebuild_rows();
}
KeyCode::Char('p') | KeyCode::Char(' ') => self.paused = !self.paused,
KeyCode::Char('h') | KeyCode::Char('?') | KeyCode::F(1) => self.toggle(Overlay::Help),
KeyCode::Char(c) if Panel::from_key(c).is_some() => {
if let Some(p) = Panel::from_key(c) {
self.panel_key(p);
}
}
KeyCode::Esc => {
if self.overlay != Overlay::None {
self.close_overlay();
} else if self.pinned.is_some() && !self.standalone {
self.unpin();
} else if self.pinned.is_none() {
return true;
}
}
KeyCode::Char('q') => {
if self.overlay != Overlay::None {
self.close_overlay();
} else {
return true;
}
}
_ => {}
}
false
}
fn toggle(&mut self, o: Overlay) {
self.overlay = if self.overlay == o { Overlay::None } else { o };
}
}
fn output_per_second(samples: &VecDeque<(Instant, u64)>) -> u64 {
let (Some((t0, n0)), Some((t1, n1))) = (samples.front(), samples.back()) else { return 0 };
let secs = t1.duration_since(*t0).as_secs_f64();
if secs <= 0.0 {
return 0;
}
(n1.saturating_sub(*n0) as f64 / secs).round() as u64
}
fn burn_per_hour(samples: &VecDeque<(Instant, u64)>) -> f64 {
let (Some((t0, c0)), Some((t1, c1))) = (samples.front(), samples.back()) else { return 0.0 };
let secs = t1.duration_since(*t0).as_secs_f64();
if secs < 3.0 {
return 0.0;
}
let micros = c1.saturating_sub(*c0) as f64;
micros / 1_000_000.0 / secs * 3600.0
}
fn push(v: &mut Vec<u64>, x: u64) {
v.push(x);
if v.len() > HISTORY {
let drop = v.len() - HISTORY;
v.drain(..drop);
}
}
#[cfg(test)]
mod tests {
use super::*;
use agent_top_core::{Activity, Attribution, Harness, HostStats, TokenUsage, Totals};
use std::time::SystemTime;
fn snapshot(output: u64) -> Snapshot {
let agent = Agent {
id: "pid:1".into(),
name: "claude".into(),
harness: Harness::Claude,
state: AgentState::Running,
activity: Activity::Working,
pid: Some(1),
session_id: None,
session_path: None,
cwd: None,
model: None,
harness_version: None,
usage: TokenUsage { input: 10, cache_write_5m: 0, cache_write_1h: 0, cache_read: 500_000, output },
cost_usd: 0.0,
cost_breakdown: Default::default(),
price_source: None,
unpriced_tokens: 0,
turns: 1,
subagent_turns: 0,
tool_calls: 0,
web_searches: 0,
spans: Vec::new(),
age_secs: 0,
idle_secs: None,
cpu_percent: 0.0,
rss_bytes: 0,
process_count: 1,
mcp_count: 0,
mcp_servers: Vec::new(),
context: Vec::new(),
tree: None,
attribution: Attribution::HarnessRegistry,
shares_process: false,
parse_warning: None,
rate_limit: None,
};
let mut s = Snapshot {
schema_version: agent_top_core::SNAPSHOT_SCHEMA_VERSION,
taken_at: SystemTime::UNIX_EPOCH,
host: HostStats::default(),
agents: vec![agent],
orphans: Vec::new(),
orphan_origins: Vec::new(),
advice: Vec::new(),
totals: Totals::default(),
};
s.compute_totals();
s
}
#[test]
fn the_upgrade_question_is_asked_once_and_remembers_no() {
let mut app = App::new(snapshot(0));
app.maybe_prompt_update();
assert_eq!(app.overlay, Overlay::None, "nothing known yet");
*app.update.lock().unwrap() = Some("9.9.9".into());
app.overlay = Overlay::Help;
app.maybe_prompt_update();
assert_eq!(app.overlay, Overlay::Help, "never over another popup");
app.overlay = Overlay::None;
app.update_at(snapshot(0), Instant::now());
assert_eq!(app.overlay, Overlay::Update, "a tick asks");
app.on_key(KeyCode::Char('u'));
assert_eq!(app.upgrade_requested, None);
app.installer = crate::update::Installer::CargoInstall;
app.on_key(KeyCode::Char('u'));
assert_eq!(app.upgrade_requested.as_deref(), Some("9.9.9"));
let mut app = App::new(snapshot(0));
*app.update.lock().unwrap() = Some("9.9.9".into());
app.maybe_prompt_update();
assert_eq!(app.overlay, Overlay::Update);
app.update_dismissed = None;
app.overlay = Overlay::Update;
app.update_dismissed = Some("9.9.9".into());
app.overlay = Overlay::None;
app.update_prompted = false;
app.maybe_prompt_update();
assert_eq!(app.overlay, Overlay::None, "a declined version is not asked again");
*app.update.lock().unwrap() = Some("10.0.0".into());
app.maybe_prompt_update();
assert_eq!(app.overlay, Overlay::Update);
}
#[test]
fn q_on_the_update_popup_quits_without_declining_the_version() {
let mut app = App::new(snapshot(0));
*app.update.lock().unwrap() = Some("9.9.9".into());
app.maybe_prompt_update();
assert_eq!(app.overlay, Overlay::Update);
assert!(app.on_key(KeyCode::Char('q')), "q quits");
assert_eq!(app.update_dismissed, None, "quitting is not a decline");
app.overlay = Overlay::Update;
assert!(!app.on_key(KeyCode::Esc));
assert_eq!(app.update_dismissed.as_deref(), Some("9.9.9"));
let mut app = App::new(snapshot(0));
app.overlay = Overlay::Help;
assert!(!app.on_key(KeyCode::Char('q')));
assert_eq!(app.overlay, Overlay::None);
assert!(app.on_key(KeyCode::Char('q')));
}
#[test]
fn a_panel_is_peeked_pinned_and_left_with_the_same_key() {
let mut app = App::new(snapshot(0));
app.on_key(KeyCode::Char('m'));
assert_eq!(app.overlay, Overlay::Panel(Panel::Mcp), "m peeks");
app.on_key(KeyCode::Char('m'));
assert_eq!(app.overlay, Overlay::None, "m again closes the peek");
app.on_key(KeyCode::Char('m'));
assert!(!app.on_key(KeyCode::Enter));
assert_eq!(app.pinned, Some(Panel::Mcp), "Enter pins the peek");
assert_eq!(app.overlay, Overlay::None, "the peek closed: it is the same panel, bigger");
app.on_key(KeyCode::Char('l'));
assert_eq!(app.overlay, Overlay::Panel(Panel::SlowTools), "another key peeks over the pinned panel");
assert_eq!(app.pinned, Some(Panel::Mcp));
assert!(!app.on_key(KeyCode::Esc), "Esc closes the peek first");
assert_eq!(app.overlay, Overlay::None);
assert_eq!(app.pinned, Some(Panel::Mcp));
assert!(!app.on_key(KeyCode::Esc), "Esc then leaves the pinned panel");
assert_eq!(app.pinned, None);
assert!(app.on_key(KeyCode::Esc), "Esc with nothing open quits");
app.pin(Panel::Advice);
app.on_key(KeyCode::Char('a'));
assert_eq!(app.pinned, None);
app.pin(Panel::Mcp);
app.on_key(KeyCode::Char('j'));
app.on_key(KeyCode::PageDown);
assert_eq!(app.scroll, 11);
app.on_key(KeyCode::Char('g'));
assert_eq!(app.scroll, 0);
assert_eq!(app.selected, 0, "the table did not move");
let detail = app.show_detail;
app.on_key(KeyCode::Enter);
app.on_key(KeyCode::Tab);
assert_eq!(app.show_detail, detail);
assert!(app.on_key(KeyCode::Char('q')), "q quits from a pinned panel");
}
#[test]
fn a_standalone_panel_has_nowhere_to_go_back_to() {
let mut app = App::new(snapshot(0));
app.standalone = true;
app.pin(Panel::Mcp);
assert!(!app.on_key(KeyCode::Esc));
assert_eq!(app.pinned, Some(Panel::Mcp));
app.on_key(KeyCode::Char('m'));
assert_eq!(app.pinned, Some(Panel::Mcp));
app.on_key(KeyCode::Char('a'));
assert_eq!(app.overlay, Overlay::Panel(Panel::Advice), "other panels still peek");
app.on_key(KeyCode::Esc);
*app.update.lock().unwrap() = Some("9.9.9".into());
app.maybe_prompt_update();
assert_eq!(app.overlay, Overlay::None, "no upgrade question in a pane");
assert!(app.on_key(KeyCode::Char('q')));
}
#[test]
fn o_asks_for_a_pane_only_inside_a_multiplexer() {
let mut app = App::new(snapshot(0));
app.on_key(KeyCode::Char('l'));
app.on_key(KeyCode::Char('o'));
assert_eq!(app.open_requested, None, "a plain terminal has no panes");
assert_eq!(app.overlay, Overlay::Panel(Panel::SlowTools), "and the peek stays");
app.multiplexer = Some(crate::pane::Multiplexer::Tmux);
app.on_key(KeyCode::Char('o'));
assert_eq!(app.open_requested.take(), Some(Panel::SlowTools));
assert_eq!(app.overlay, Overlay::None, "the peek moved into the pane");
app.on_key(KeyCode::Char('o'));
assert_eq!(app.open_requested, None, "nothing in front, nothing to open");
app.pin(Panel::Mcp);
app.on_key(KeyCode::Char('o'));
assert_eq!(app.open_requested.take(), Some(Panel::Mcp));
assert_eq!(app.pinned, None, "a pinned panel moves into the pane and the table returns");
app.notify("opened");
assert_eq!(app.current_notice(), Some("opened"));
}
#[test]
fn burn_rate_is_dollars_per_hour_over_the_window() {
use std::collections::VecDeque;
let t0 = Instant::now();
let mut s: VecDeque<(Instant, u64)> = VecDeque::new();
s.push_back((t0, 1_000_000)); s.push_back((t0 + Duration::from_secs(60), 1_600_000)); assert!((burn_per_hour(&s) - 36.0).abs() < 1e-6, "{}", burn_per_hour(&s));
let mut s: VecDeque<(Instant, u64)> = VecDeque::new();
s.push_back((t0, 1_000_000));
s.push_back((t0 + Duration::from_secs(1), 2_000_000));
assert_eq!(burn_per_hour(&s), 0.0);
}
#[test]
fn output_rate_is_per_second_over_the_window_not_per_tick() {
let t0 = Instant::now();
let mut app = App::new(snapshot(0));
app.update_at(snapshot(0), t0);
app.update_at(snapshot(1000), t0 + Duration::from_secs(1));
assert_eq!(app.output_rate.last(), Some(&1000), "one second, one thousand tokens");
for i in 2..=10 {
app.update_at(snapshot(1000), t0 + Duration::from_secs(i));
}
assert_eq!(app.output_rate.last(), Some(&100), "1000 tokens over the 10 s window");
for i in 11..=21 {
app.update_at(snapshot(1000), t0 + Duration::from_secs(i));
}
assert_eq!(app.output_rate.last(), Some(&0));
assert!(app.rate_samples.len() <= 12, "samples outside the window are dropped");
}
#[test]
fn a_falling_total_reads_as_zero_and_a_half_second_tick_is_scaled() {
let t0 = Instant::now();
let mut app = App::new(snapshot(0));
app.update_at(snapshot(500), t0);
app.update_at(snapshot(0), t0 + Duration::from_secs(1));
assert_eq!(app.output_rate.last(), Some(&0));
let mut app = App::new(snapshot(0));
app.update_at(snapshot(0), t0);
app.update_at(snapshot(50), t0 + Duration::from_millis(500));
assert_eq!(app.output_rate.last(), Some(&100), "50 tokens in half a second");
}
}