use super::{App, Sort, COL_CPU, COL_DANGER, COL_MEM, COL_PID, COL_SUB, COL_TOK, COL_UPTIME};
use crate::format::{bytes, dur, pct, shorten, si};
use crate::model::{Agent, Status};
use crate::theme;
use ratatui::{
layout::{Alignment, Constraint, Rect},
style::{Modifier, Style},
text::{Line, Span},
widgets::{
Block, BorderType, Borders, Paragraph, Row,
Scrollbar, ScrollbarOrientation, ScrollbarState,
Table,
},
Frame,
};
pub(super) 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
}
pub(super) 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;
let mut header_meta: Vec<(usize, Line<'_>, usize)> = Vec::new();
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.clone()]).height(1)
.style(Style::default().bg(theme::group_tint(group_index)));
header_meta.push((rows.len(), header_line, 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, app.compact_rows, app.cols));
if app.tree_mode {
push_child_rows(&mut rows, a, 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.compact_rows, app.cols));
if app.tree_mode {
push_child_rows(&mut rows, a, 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));
}
let offset = app.agents_state.offset();
app.clickable_rows.clear();
for (i, ri) in agent_row_indices.iter().enumerate() {
if let Some(pid) = pid_order.get(i) {
let row_idx = *ri;
if row_idx < offset { continue; }
let y_local = (row_idx - offset) as u16;
if y_local >= inner.height { continue; }
app.clickable_rows.push((inner.y + y_local, *pid));
}
}
let selected_table_idx = app.selected_pid
.and_then(|p| pid_order.iter().position(|x| *x == p))
.and_then(|i| agent_row_indices.get(i).copied());
app.agents_state.select(selected_table_idx);
app.agents_total_rows = rows.len();
let total_rows = rows.len();
let body_area = Rect {
x: inner.x,
y: inner.y,
width: inner.width.saturating_sub(1),
height: inner.height,
};
let table = Table::new(rows, [Constraint::Percentage(100)])
.column_spacing(0);
f.render_stateful_widget(table, body_area, &mut app.agents_state);
if total_rows > inner.height as usize {
let mut sbs = ScrollbarState::default()
.content_length(total_rows)
.viewport_content_length(inner.height as usize)
.position(app.agents_state.offset());
let sb = Scrollbar::new(ScrollbarOrientation::VerticalRight)
.begin_symbol(None)
.end_symbol(None)
.track_symbol(Some("│"))
.thumb_symbol("█")
.track_style(Style::default().fg(theme::border_dim()))
.thumb_style(Style::default().fg(theme::border()).add_modifier(Modifier::BOLD));
f.render_stateful_widget(sb, inner, &mut sbs);
}
if app.grouped && offset > 0 && !header_meta.is_empty() {
let pinned = header_meta.iter().rev()
.find(|(idx, _, _)| *idx <= offset);
if let Some((idx, line, gi)) = pinned {
if *idx < offset {
let bar = Rect {
x: body_area.x,
y: body_area.y,
width: body_area.width,
height: 1,
};
f.render_widget(ratatui::widgets::Clear, bar);
let bg = Style::default().bg(theme::group_tint(*gi));
f.render_widget(
Paragraph::new(line.clone()).style(bg),
bar,
);
}
}
}
}
fn push_child_rows<'a>(rows: &mut Vec<Row<'a>>, parent: &'a Agent,
group_bg: Option<ratatui::style::Color>) {
let mut style = Style::default().fg(theme::fg_dim());
if let Some(bg) = group_bg { style = style.bg(bg); }
for (i, (cpid, comm)) in parent.children.iter().take(5).enumerate() {
let last = i + 1 == parent.children.len().min(5);
let glyph = if last { "└─" } else { "├─" };
let line = Line::from(Span::styled(
format!(" {} {} ({})", glyph, comm, cpid),
style,
));
let mut r = Row::new(vec![line]);
if let Some(bg) = group_bg { r = r.style(Style::default().bg(bg)); }
rows.push(r);
}
}
pub(super) fn agent_row<'a>(a: &'a Agent, selected: bool, group_bg: Option<ratatui::style::Color>, compact: bool, cols: u32) -> Row<'a> {
let cols = if compact { COL_CPU | COL_MEM | COL_DANGER } else { cols };
let show = |c: u32| (cols & c) != 0;
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 show(COL_DANGER) && 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(" "));
if show(COL_PID) {
spans.push(Span::styled(format!("{:>7}", a.pid),
Style::default().fg(theme::fg_dim())));
spans.push(Span::raw(" "));
}
if show(COL_CPU) {
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(" "));
}
if show(COL_MEM) {
spans.push(Span::styled(format!("{:>5}", bytes(a.rss)),
Style::default().fg(theme::c_chart_mem())));
spans.push(Span::raw(" "));
}
if show(COL_UPTIME) {
spans.push(Span::styled(format!("{:>6}", dur(a.uptime_sec)),
Style::default().fg(theme::fg_dim())));
}
if show(COL_SUB) {
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 show(COL_TOK) {
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()))
}