mod app;
mod format;
mod pane;
mod report;
mod sql;
mod theme;
mod trace;
mod ui;
mod update;
use agent_top_core::{Collector, CollectorOptions, Snapshot};
use anyhow::{Context, Result};
use clap::{CommandFactory, Parser, Subcommand};
use clap_complete::Shell;
use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
use std::path::PathBuf;
use std::time::{Duration, Instant};
pub(crate) const CHANGELOG_URL: &str = "https://github.com/kannandreams/agent-top/blob/main/CHANGELOG.md";
pub(crate) const VERSION: &str = env!("CARGO_PKG_VERSION");
const CHANGELOG: &str = include_str!(concat!(env!("OUT_DIR"), "/changelog.md"));
fn print_whats_new() {
println!("agent-top {VERSION}\n");
let mut shown = 0;
for line in CHANGELOG.lines() {
if line.starts_with("## [") {
shown += 1;
if shown > 4 {
break;
}
}
if shown >= 1 {
println!("{line}");
}
}
println!("\nNewer versions may exist; this is only what this build shipped with.");
println!("Full and current changelog: {CHANGELOG_URL}");
}
#[derive(Parser, Debug)]
#[command(name = "agent-top", version, about = "htop for local coding agents", long_about = None,
after_help = "Upgrade: brew upgrade agent-top | cargo install agent-top\nWhat's new: agent-top --whats-new")]
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)]
prices: bool,
#[arg(long, value_name = "SHELL")]
completions: Option<Shell>,
#[arg(long, value_name = "FILE")]
replay: Option<PathBuf>,
#[arg(long)]
whats_new: bool,
#[arg(long, value_enum, default_value_t = theme::ThemeChoice::Auto, value_name = "THEME")]
theme: theme::ThemeChoice,
#[command(subcommand)]
command: Option<Command>,
}
#[derive(Subcommand, Debug)]
enum Command {
Trace {
#[arg(long, value_name = "ID|FILE")]
session: String,
#[arg(long, value_enum, default_value_t = trace::Format::Chrome)]
format: trace::Format,
#[arg(short, long, value_name = "FILE")]
output: Option<PathBuf>,
#[arg(long, value_name = "URL")]
endpoint: Option<String>,
},
Report {
#[arg(long, default_value = "30d", value_name = "WHEN")]
since: String,
#[arg(long, value_enum, default_value_t = report::GroupBy::Harness)]
by: report::GroupBy,
#[arg(long)]
json: bool,
#[arg(long, value_name = "FILE", conflicts_with = "no_store")]
db: Option<PathBuf>,
#[arg(long)]
no_store: bool,
},
Sync {
#[arg(long, default_value = "all", value_name = "WHEN")]
since: String,
#[arg(long, value_name = "FILE")]
db: Option<PathBuf>,
#[arg(long)]
json: bool,
},
Sql {
#[arg(value_name = "QUERY", required_unless_present = "schema")]
query: Option<String>,
#[arg(long, conflicts_with = "query")]
schema: bool,
#[arg(long, value_name = "FILE")]
db: Option<PathBuf>,
#[arg(long, conflicts_with = "csv")]
json: bool,
#[arg(long)]
csv: bool,
},
Slow,
Fails,
Advice,
Mcp,
}
impl Command {
fn panel(&self) -> Option<app::Panel> {
match self {
Command::Slow => Some(app::Panel::SlowTools),
Command::Fails => Some(app::Panel::FailedTools),
Command::Advice => Some(app::Panel::Advice),
Command::Mcp => Some(app::Panel::Mcp),
Command::Trace { .. } | Command::Report { .. } | Command::Sync { .. } | Command::Sql { .. } => None,
}
}
}
fn forwarded_args(cli: &Cli) -> Vec<String> {
let mut v = Vec::new();
if cli.interval_ms != 1000 {
v.extend(["--interval-ms".to_string(), cli.interval_ms.to_string()]);
}
if cli.stopped_window_min != 30 {
v.extend(["--stopped-window-min".to_string(), cli.stopped_window_min.to_string()]);
}
if let Some(path) = &cli.replay {
let abs = std::path::absolute(path).unwrap_or_else(|_| path.clone());
v.extend(["--replay".to_string(), abs.to_string_lossy().into_owned()]);
}
if let Some(theme) = cli.theme.flag() {
v.extend(["--theme".to_string(), theme.to_string()]);
}
v
}
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 store_path(db: Option<&std::path::Path>) -> Result<PathBuf> {
match db {
Some(p) => Ok(p.to_path_buf()),
None => agent_top_store::default_path().context("no store path: set HOME, XDG_DATA_HOME or AGENT_TOP_DB, or pass --db"),
}
}
fn sync(since: &str, db: Option<&std::path::Path>, json: bool) -> Result<()> {
let since = report::parse_since(since)?;
let path = store_path(db)?;
let mut store = agent_top_store::Store::open(&path)?;
let stats = store.sync((since > std::time::UNIX_EPOCH).then_some(since))?;
let sessions = store.session_count()?;
if json {
let failed: Vec<_> = stats.failed.iter().map(|(p, why)| serde_json::json!({ "path": p.to_string_lossy(), "error": why })).collect();
let doc = serde_json::json!({
"db": path.to_string_lossy(),
"seen": stats.seen,
"stored": stats.stored,
"unchanged": stats.unchanged,
"outside_window": stats.outside_window,
"kept": stats.kept,
"failed": failed,
"sessions": sessions,
});
println!("{}", serde_json::to_string_pretty(&doc)?);
return Ok(());
}
for (p, why) in &stats.failed {
eprintln!("agent-top: could not read {}: {why}", p.display());
}
let mut line = format!("{} transcripts: {} stored, {} unchanged", stats.seen, stats.stored, stats.unchanged);
for (n, what) in
[(stats.outside_window, "outside the window"), (stats.kept, "kept (read as empty)"), (stats.failed.len() as u64, "failed")]
{
if n > 0 {
line.push_str(&format!(", {n} {what}"));
}
}
println!("{line}");
println!("{} holds {sessions} sessions", path.display());
Ok(())
}
fn main() -> Result<()> {
let cli = Cli::parse();
if let Some(shell) = cli.completions {
let mut cmd = Cli::command();
let name = cmd.get_name().to_string();
clap_complete::generate(shell, &mut cmd, name, &mut std::io::stdout());
return Ok(());
}
if cli.prices {
print!("{}", format::price_table(agent_top_core::pricing::table()));
return Ok(());
}
if cli.whats_new {
print_whats_new();
return Ok(());
}
if let Some(Command::Trace { session, format, output, endpoint }) = &cli.command {
return export_trace(session, *format, output.as_deref(), endpoint.as_deref());
}
if let Some(Command::Report { since, by, json, db, no_store }) = &cli.command {
for w in &agent_top_core::pricing::table().warnings {
eprintln!("agent-top: {w}");
}
let since = report::parse_since(since)?;
let path = if *no_store { None } else { Some(store_path(db.as_deref())?) };
let reader = match path.as_deref().filter(|p| p.exists()) {
Some(p) => match agent_top_store::Reader::open(p).and_then(|r| r.sessions()) {
Ok(rows) => Some((p.to_path_buf(), rows)),
Err(e) => {
eprintln!("agent-top: not reading the local store: {e:#}");
None
}
},
None => None,
};
let hint = path.is_some() && reader.is_none();
let rep = report::build(since, *by, reader, hint);
if *json {
println!("{}", serde_json::to_string_pretty(&rep.to_json())?);
} else {
print!("{}", rep.to_plain());
}
return Ok(());
}
if let Some(Command::Sql { query, schema, db, json, csv }) = &cli.command {
let reader = agent_top_store::Reader::open(&store_path(db.as_deref())?)?;
if *schema {
print!("{}", sql::schema(&reader.describe()?));
return Ok(());
}
let result = reader.query(query.as_deref().unwrap_or_default())?;
if *json {
println!("{}", serde_json::to_string_pretty(&sql::to_json(&result))?);
} else if *csv {
print!("{}", sql::to_csv(&result));
} else {
print!("{}", sql::to_table(&result));
}
return Ok(());
}
if let Some(Command::Sync { since, db, json }) = &cli.command {
for w in &agent_top_core::pricing::table().warnings {
eprintln!("agent-top: {w}");
}
return sync(since, db.as_deref(), *json);
}
for w in &agent_top_core::pricing::table().warnings {
eprintln!("agent-top: {w}");
}
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 start = Start {
interval: Duration::from_millis(cli.interval_ms.max(100)),
pinned: cli.command.as_ref().and_then(Command::panel),
forwarded: forwarded_args(&cli),
};
let theme = theme::Theme::detect(cli.theme);
let mut terminal = ratatui::init();
let result = run(&mut terminal, &mut source, &start, &theme);
ratatui::restore();
match result? {
Exit::Quit => Ok(()),
Exit::Upgrade(latest) => update::upgrade(&latest),
}
}
enum Exit {
Quit,
Upgrade(String),
}
struct Start {
interval: Duration,
pinned: Option<app::Panel>,
forwarded: Vec<String>,
}
fn run(terminal: &mut ratatui::DefaultTerminal, source: &mut Source, start: &Start, theme: &theme::Theme) -> Result<Exit> {
let interval = start.interval;
let mut app = app::App::new(source.collect());
if let Some(p) = start.pinned {
app.standalone = true;
app.pin(p);
}
app.multiplexer = pane::Multiplexer::detect();
if matches!(source, Source::Live(_)) {
app.update = update::start();
app.update_dismissed = update::dismissed();
app.installer = update::Installer::detect();
app.maybe_prompt_update();
}
let mut last_tick = Instant::now();
loop {
terminal.draw(|f| ui::draw(f, &mut app, theme))?;
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('c') if ctrl => return Ok(Exit::Quit),
_ if app.on_key(key.code) => return Ok(Exit::Quit),
_ => {}
}
if let Some(latest) = app.upgrade_requested.take() {
return Ok(Exit::Upgrade(latest));
}
if let (Some(p), Some(mux)) = (app.open_requested.take(), app.multiplexer) {
match pane::open(mux, &start.forwarded, p.command()) {
Ok(msg) | Err(msg) => app.notify(msg),
}
}
}
if last_tick.elapsed() >= interval {
if !app.paused {
app.update(source.collect());
}
last_tick = Instant::now();
}
}
}
fn export_trace(session: &str, format: trace::Format, output: Option<&std::path::Path>, endpoint: Option<&str>) -> Result<()> {
if endpoint.is_some() && format != trace::Format::Otlp {
anyhow::bail!("--endpoint posts OTLP; add --format otlp");
}
let src = trace::resolve(session)?;
let summary = trace::read(&src)?;
let doc = serde_json::to_string(&trace::render(&src, &summary, format))?;
if let Some(url) = endpoint {
let status = trace::post(url, &doc)?;
eprintln!("agent-top: {url} accepted the trace ({status})");
}
let count = |k: agent_top_core::SpanKind| summary.spans.iter().filter(|s| s.kind == k).count();
let open = summary.spans.iter().filter(|s| s.kind == agent_top_core::SpanKind::Tool && s.is_open()).count();
let still_open = if open > 0 { format!(" ({open} never returned)") } else { String::new() };
match output {
Some(path) if path != std::path::Path::new("-") => {
std::fs::write(path, doc).with_context(|| format!("writing {}", path.display()))?;
eprintln!(
"agent-top: wrote {} tool calls{still_open}, {} inferences, {} turns from the {} session to {}",
count(agent_top_core::SpanKind::Tool),
count(agent_top_core::SpanKind::Inference),
count(agent_top_core::SpanKind::Turn),
src.harness.label(),
path.display()
);
}
None if endpoint.is_some() => {}
_ => println!("{doc}"),
}
Ok(())
}