mod app;
mod format;
mod ui;
use agent_top_core::{Collector, CollectorOptions, Snapshot};
use anyhow::{Context, Result};
use clap::Parser;
use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
use std::path::PathBuf;
use std::time::{Duration, Instant};
#[derive(Parser, Debug)]
#[command(name = "agent-top", version, about = "htop for local coding agents", long_about = None)]
struct Cli {
#[arg(long)]
json: bool,
#[arg(long)]
once: bool,
#[arg(long, default_value_t = 1000)]
interval_ms: u64,
#[arg(long, default_value_t = 30)]
stopped_window_min: u64,
#[arg(long, value_name = "FILE")]
replay: Option<PathBuf>,
}
enum Source {
Live(Box<Collector>),
Replay(Box<Snapshot>),
}
impl Source {
fn collect(&mut self) -> Snapshot {
match self {
Source::Live(c) => c.collect(),
Source::Replay(s) => (**s).clone(),
}
}
}
fn main() -> Result<()> {
let cli = Cli::parse();
let mut source = match &cli.replay {
Some(path) => {
let text = std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
let snap: Snapshot =
serde_json::from_str(&text).with_context(|| format!("{} is not an agent-top --json snapshot", path.display()))?;
Source::Replay(Box::new(snap))
}
None => {
let opts = CollectorOptions { stopped_window: Duration::from_secs(cli.stopped_window_min * 60), ..Default::default() };
Source::Live(Box::new(Collector::new(opts)))
}
};
let mut settled = || {
if let Source::Live(_) = source {
let _ = source.collect();
std::thread::sleep(Duration::from_millis(250));
}
source.collect()
};
if cli.json {
println!("{}", serde_json::to_string_pretty(&settled())?);
return Ok(());
}
if cli.once {
print!("{}", format::plain_table(&settled()));
return Ok(());
}
let mut terminal = ratatui::init();
let result = run(&mut terminal, &mut source, Duration::from_millis(cli.interval_ms.max(100)));
ratatui::restore();
result
}
fn run(terminal: &mut ratatui::DefaultTerminal, source: &mut Source, interval: Duration) -> Result<()> {
let mut app = app::App::new(source.collect());
let mut last_tick = Instant::now();
loop {
terminal.draw(|f| ui::draw(f, &mut app))?;
let timeout = interval.saturating_sub(last_tick.elapsed());
if event::poll(timeout)?
&& let Event::Key(key) = event::read()?
{
if key.kind != KeyEventKind::Press {
continue;
}
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
match key.code {
KeyCode::Char('q') | KeyCode::Esc => {
if app.show_help {
app.show_help = false;
} else {
return Ok(());
}
}
KeyCode::Char('c') if ctrl => return Ok(()),
_ => app.on_key(key.code),
}
}
if last_tick.elapsed() >= interval {
if !app.paused {
app.update(source.collect());
}
last_tick = Instant::now();
}
}
}