use crate::cli::Args;
use crate::collector::Collector;
use crate::model::Snapshot;
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::{Constraint, Direction, Layout, Rect},
style::Style,
widgets::{Paragraph, TableState},
Frame, Terminal,
};
use std::io::{self, stdout};
use std::time::{Duration, Instant};
mod popup;
mod panels;
mod agents;
pub(super) const COL_PID: u32 = 1 << 0; pub(super) const COL_CPU: u32 = 1 << 1; pub(super) const COL_MEM: u32 = 1 << 2; pub(super) const COL_UPTIME: u32 = 1 << 3; pub(super) const COL_SUB: u32 = 1 << 4; pub(super) const COL_TOK: u32 = 1 << 5; pub(super) const COL_DANGER: u32 = 1 << 6;
#[derive(Copy, Clone, PartialEq, Eq)]
pub(super) 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,
}
}
pub(super) 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",
}
}
}
pub(super) struct App {
pub(super) collector: Collector,
pub(super) snap: Snapshot,
pub(super) last_tick: Instant,
pub(super) interval: Duration,
pub(super) paused: bool,
pub(super) grouped: bool,
pub(super) sort: Sort,
pub(super) filter: String,
pub(super) typing_filter: bool,
pub(super) show_help: bool,
pub(super) show_detail: bool,
pub(super) detail_scroll: u16,
pub(super) detail_scroll_by_pid: std::collections::HashMap<u32, u16>,
pub(super) detail_tail: bool,
pub(super) popup_filter: String,
pub(super) popup_filter_typing: bool,
pub(super) popup_sections: Vec<u16>,
pub(super) popup_total_lines: u16,
pub(super) confirm_kill: Option<u32>,
pub(super) tree_mode: bool,
pub(super) selected_pid: Option<u32>,
pub(super) visible_pid_order: Vec<u32>,
pub(super) clickable_rows: Vec<(u16, u32)>,
pub(super) agents_state: TableState,
pub(super) sessions_scroll: u16,
pub(super) sessions_rect: Rect,
pub(super) agents_total_rows: usize,
pub(super) compact_rows: bool,
pub(super) cols: u32,
pub(super) 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 pre_pid = args.pid;
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: pre_pid.is_some(),
detail_scroll: 0,
detail_scroll_by_pid: std::collections::HashMap::new(),
detail_tail: true,
popup_filter: String::new(),
popup_filter_typing: false,
popup_sections: Vec::new(),
popup_total_lines: 0,
confirm_kill: None,
tree_mode: false,
selected_pid: pre_pid,
visible_pid_order: Vec::new(),
clickable_rows: Vec::new(),
agents_state: TableState::default(),
sessions_scroll: 0,
sessions_rect: Rect::default(),
agents_total_rows: 0,
compact_rows: false,
cols: COL_PID | COL_CPU | COL_MEM | COL_UPTIME | COL_SUB | COL_TOK | COL_DANGER,
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.confirm_kill.is_some() {
match key.code {
KeyCode::Char('y') | KeyCode::Char('Y') => {
if let Some(pid) = app.confirm_kill.take() {
let still_present = app.snap.agents.iter().any(|a| a.pid == pid);
if still_present {
send_sigterm(pid);
}
}
}
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
app.confirm_kill = None;
}
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => app.quit = true,
_ => {}
}
return;
}
if app.show_detail || app.show_help {
if app.popup_filter_typing {
match key.code {
KeyCode::Esc => {
app.popup_filter_typing = false;
app.popup_filter.clear();
}
KeyCode::Enter => app.popup_filter_typing = false,
KeyCode::Backspace => { app.popup_filter.pop(); }
KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => app.popup_filter.clear(),
KeyCode::Char(c) if !c.is_control() && app.popup_filter.len() < 256 => {
app.popup_filter.push(c);
}
_ => {}
}
return;
}
match key.code {
KeyCode::Esc | KeyCode::Char('q')
| KeyCode::Enter | KeyCode::Char(' ') => {
if app.show_detail {
if let Some(pid) = app.selected_pid {
app.detail_scroll_by_pid.insert(pid, app.detail_scroll);
}
}
app.show_detail = false;
app.show_help = false;
app.detail_scroll = 0;
app.popup_filter.clear();
}
KeyCode::Char('?') => app.show_help = !app.show_help,
KeyCode::Down | KeyCode::Char('j') => {
app.detail_scroll = app.detail_scroll.saturating_add(1);
app.detail_tail = false;
}
KeyCode::Up | KeyCode::Char('k') => {
app.detail_scroll = app.detail_scroll.saturating_sub(1);
app.detail_tail = false;
}
KeyCode::PageDown => {
app.detail_scroll = app.detail_scroll.saturating_add(10);
app.detail_tail = false;
}
KeyCode::PageUp => {
app.detail_scroll = app.detail_scroll.saturating_sub(10);
app.detail_tail = false;
}
KeyCode::Char('g') => {
app.detail_scroll = 0;
app.detail_tail = false;
}
KeyCode::Char('G') | KeyCode::End => {
app.detail_scroll = u16::MAX;
app.detail_tail = true;
}
KeyCode::Home => {
app.detail_scroll = 0;
app.detail_tail = false;
}
KeyCode::Char('n') if app.show_detail => {
let pos = app.detail_scroll;
let next = app.popup_sections.iter().copied()
.find(|&s| s > pos)
.or_else(|| app.popup_sections.first().copied());
if let Some(s) = next {
app.detail_scroll = s;
app.detail_tail = false;
}
}
KeyCode::Char('N') if app.show_detail => {
let pos = app.detail_scroll;
let prev = app.popup_sections.iter().rev().copied()
.find(|&s| s < pos)
.or_else(|| app.popup_sections.last().copied());
if let Some(s) = prev {
app.detail_scroll = s;
app.detail_tail = false;
}
}
KeyCode::Char('/') if app.show_detail => {
app.popup_filter_typing = true;
app.popup_filter.clear();
}
KeyCode::Char('y') if app.show_detail => {
if let Some(pid) = app.selected_pid {
if let Some(a) = app.snap.agents.iter().find(|a| a.pid == pid) {
let payload = format!(
"agent={} pid={} cwd={} cmd={} session={}",
a.label, a.pid, a.cwd, a.cmdline,
a.session_id.as_deref().unwrap_or("-"),
);
copy_via_osc52(&payload);
}
}
}
KeyCode::Char('h') if app.show_help => 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 | KeyCode::Char(' ') => {
app.show_detail = true;
app.detail_scroll = app.selected_pid
.and_then(|p| app.detail_scroll_by_pid.get(&p).copied())
.unwrap_or(0);
app.popup_filter.clear();
app.detail_tail = false;
}
KeyCode::Char('p') => 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('t') => app.tree_mode = !app.tree_mode,
KeyCode::Char('C') => app.compact_rows = !app.compact_rows,
KeyCode::Char('1') => app.cols ^= COL_PID,
KeyCode::Char('2') => app.cols ^= COL_CPU,
KeyCode::Char('3') => app.cols ^= COL_MEM,
KeyCode::Char('4') => app.cols ^= COL_UPTIME,
KeyCode::Char('5') => app.cols ^= COL_SUB,
KeyCode::Char('6') => app.cols ^= COL_TOK,
KeyCode::Char('7') => app.cols ^= COL_DANGER,
KeyCode::Char('K') => {
if let Some(pid) = app.selected_pid {
app.confirm_kill = Some(pid);
}
}
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 copy_via_osc52(payload: &str) {
use std::io::Write;
const A: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let bytes = payload.as_bytes();
let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
for chunk in bytes.chunks(3) {
let b0 = chunk[0] as u32;
let b1 = chunk.get(1).copied().unwrap_or(0) as u32;
let b2 = chunk.get(2).copied().unwrap_or(0) as u32;
let v = (b0 << 16) | (b1 << 8) | b2;
out.push(A[((v >> 18) & 0x3F) as usize] as char);
out.push(A[((v >> 12) & 0x3F) as usize] as char);
out.push(if chunk.len() > 1 { A[((v >> 6) & 0x3F) as usize] as char } else { '=' });
out.push(if chunk.len() > 2 { A[(v & 0x3F) as usize] as char } else { '=' });
}
let mut so = stdout();
let _ = write!(so, "\x1b]52;c;{}\x07", out);
let _ = so.flush();
}
#[cfg(unix)]
fn send_sigterm(pid: u32) {
unsafe { libc::kill(pid as libc::pid_t, libc::SIGTERM); }
}
#[cfg(not(unix))]
fn send_sigterm(_pid: u32) {
}
fn handle_mouse(app: &mut App, m: MouseEvent) {
if app.show_detail || app.show_help {
match m.kind {
MouseEventKind::ScrollUp => {
app.detail_scroll = app.detail_scroll.saturating_sub(3);
app.detail_tail = false;
}
MouseEventKind::ScrollDown => {
app.detail_scroll = app.detail_scroll.saturating_add(3);
if app.detail_scroll >= app.popup_total_lines {
app.detail_tail = true;
}
}
_ => {}
}
return;
}
if matches!(m.kind, MouseEventKind::ScrollUp | MouseEventKind::ScrollDown)
&& rect_contains(app.sessions_rect, m.column, m.row)
{
match m.kind {
MouseEventKind::ScrollUp => app.sessions_scroll = app.sessions_scroll.saturating_sub(2),
MouseEventKind::ScrollDown => app.sessions_scroll = app.sessions_scroll.saturating_add(2),
_ => {}
}
return;
}
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;
app.detail_scroll = app.detail_scroll_by_pid.get(pid).copied().unwrap_or(0);
app.popup_filter.clear();
app.detail_tail = false;
} 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);
panels::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]);
agents::draw_agents(f, left[0], app);
panels::draw_left_bottom(f, left[1], &app.snap);
panels::draw_cpu_panel(f, right[0], &app.snap);
panels::draw_memory_panel(f, right[1], &app.snap);
panels::draw_tokens_panel(f, right[2], &app.snap, app.interval);
panels::draw_status_distribution(f, right[3], &app.snap);
app.sessions_rect = right[4];
panels::draw_sessions(f, right[4], &app.snap, &mut app.sessions_scroll);
panels::draw_footer(f, chunks[2], app);
if let Some(pid) = app.confirm_kill {
popup::draw_confirm_kill(f, area, &app.snap, pid);
} else if app.show_help {
popup::draw_help(f, area, app);
} else if app.show_detail {
popup::draw_detail(f, area, app);
} else if app.typing_filter {
popup::draw_filter_input(f, area, &app.filter);
}
}
fn rect_contains(r: Rect, col: u16, row: u16) -> bool {
col >= r.x && col < r.x.saturating_add(r.width)
&& row >= r.y && row < r.y.saturating_add(r.height)
}
pub(super) fn centered_rect(width: u16, height: u16, area: Rect) -> Rect {
let w = width.min(area.width.saturating_sub(2));
let h = height.min(area.height.saturating_sub(2));
Rect {
x: area.x + (area.width.saturating_sub(w)) / 2,
y: area.y + (area.height.saturating_sub(h)) / 2,
width: w,
height: h,
}
}