use std::collections::{HashMap, HashSet, VecDeque};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::widgets::TableState;
use crate::action::Action;
use crate::config::Config;
use crate::process::{
build_forest, collect_expansion, flatten_visible, preserve_expansion, process_kind,
toggle_expand, ActivityState, FlatEntry, ProcessInfo, ProcessKind, ProcessNode, SubtreeStats,
SystemStats,
};
use crate::ui::styles::{GraphStyle, Palette, Theme};
const HISTORY_LEN: usize = 300;
pub const IDLE_CPU_THRESHOLD: f32 = 0.5;
pub const IDLE_SAMPLE_WINDOW: usize = 5;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SortColumn {
#[default]
Pid,
Name,
Cpu,
Memory,
Status,
Uptime,
}
impl SortColumn {
const ALL: [SortColumn; 6] = [
Self::Pid,
Self::Name,
Self::Cpu,
Self::Memory,
Self::Status,
Self::Uptime,
];
pub fn next(self) -> Self {
let idx = Self::ALL
.iter()
.position(|&c| c == self)
.expect("SortColumn variant missing from ALL array");
Self::ALL[(idx + 1) % Self::ALL.len()]
}
pub fn prev(self) -> Self {
let idx = Self::ALL
.iter()
.position(|&c| c == self)
.expect("SortColumn variant missing from ALL array");
Self::ALL[(idx + Self::ALL.len() - 1) % Self::ALL.len()]
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SortDirection {
Ascending,
#[default]
Descending,
}
impl SortDirection {
pub fn toggle(self) -> Self {
match self {
Self::Ascending => Self::Descending,
Self::Descending => Self::Ascending,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct AgentSummary {
pub claude_count: usize,
pub codex_count: usize,
pub total_cpu: f32,
pub total_memory: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum ActiveView {
#[default]
Tree,
Detail,
}
#[derive(Debug, Clone, Default)]
pub struct ConfigPopupState {
pub cursor: usize,
}
impl ConfigPopupState {
pub const SECTIONS: &'static [(&'static str, usize)] = &[
("Graph Style", GraphStyle::ALL.len()),
("Theme", Theme::ALL.len()),
];
pub fn total_rows() -> usize {
Self::SECTIONS.iter().map(|(_, n)| n).sum()
}
pub fn move_up(&mut self) {
let total = Self::total_rows();
self.cursor = (self.cursor + total - 1) % total;
}
pub fn move_down(&mut self) {
let total = Self::total_rows();
self.cursor = (self.cursor + 1) % total;
}
}
pub struct KeyContext<'a> {
pub active_view: &'a ActiveView,
pub confirming_kill: bool,
pub config_open: bool,
pub filter_active: bool,
}
#[derive(Debug, Default)]
pub struct App {
pub should_quit: bool,
pub active_view: ActiveView,
pub forest: Vec<ProcessNode>,
pub flat_list: Vec<FlatEntry>,
pub table_state: TableState,
pub selected_detail: Option<ProcessInfo>,
pub selected_detail_subtree: Option<SubtreeStats>,
pub cpu_history: HashMap<u32, VecDeque<f32>>,
pub mem_history: HashMap<u32, VecDeque<u64>>,
pub sort_column: SortColumn,
pub sort_direction: SortDirection,
pub confirm_kill_pid: Option<u32>,
pub kill_result: Option<String>,
pub system_stats: SystemStats,
pub theme: Theme,
pub palette: Palette,
pub graph_style: GraphStyle,
pub config_popup: Option<ConfigPopupState>,
pub agent_summary: AgentSummary,
pub activity_state: HashMap<u32, ActivityState>,
pub aggregate_cpu_history: HashMap<u32, VecDeque<f32>>,
pub filter_active: bool,
pub filter_text: String,
}
impl App {
pub fn new() -> Self {
let mut table_state = TableState::default();
table_state.select(Some(0));
let config = Config::load();
let palette = Palette::from_theme(config.theme);
Self {
table_state,
theme: config.theme,
graph_style: config.graph_style,
palette,
..Default::default()
}
}
fn persist_config(&self) {
Config {
theme: self.theme,
graph_style: self.graph_style,
}
.save();
}
pub fn handle_action(&mut self, action: Action) {
if !matches!(
action,
Action::KillRequest | Action::ConfirmKill | Action::CancelKill
) {
self.kill_result = None;
}
match action {
Action::Quit => self.should_quit = true,
Action::MoveUp => self.move_selection(-1),
Action::MoveDown => self.move_selection(1),
Action::ToggleExpand => {
if let Some(idx) = self.table_state.selected() {
if let Some(entry) = self.flat_list.get(idx) {
let pid = entry.info.pid;
toggle_expand(&mut self.forest, pid);
self.rebuild_flat_list();
}
}
}
Action::SelectProcess => {
if let Some(idx) = self.table_state.selected() {
if let Some(entry) = self.flat_list.get(idx) {
self.selected_detail = Some(entry.info.clone());
self.selected_detail_subtree = Some(entry.subtree_stats);
self.active_view = ActiveView::Detail;
}
}
}
Action::BackToTree => {
self.active_view = ActiveView::Tree;
}
Action::SortNext => {
self.sort_column = self.sort_column.next();
self.rebuild_flat_list();
}
Action::SortPrev => {
self.sort_column = self.sort_column.prev();
self.rebuild_flat_list();
}
Action::SortToggleDirection => {
self.sort_direction = self.sort_direction.toggle();
self.rebuild_flat_list();
}
Action::KillRequest => {
let pid = self.selected_pid();
if pid.is_some() {
self.confirm_kill_pid = pid;
self.kill_result = None;
}
}
Action::ConfirmKill => {
if let Some(pid) = self.confirm_kill_pid.take() {
self.kill_result = Some(kill_process(pid));
}
}
Action::CancelKill => {
self.confirm_kill_pid = None;
}
Action::ToggleConfig => {
if self.config_popup.is_some() {
self.config_popup = None;
} else {
self.config_popup = Some(ConfigPopupState::default());
}
}
Action::ConfigUp => {
if let Some(popup) = self.config_popup.as_mut() {
popup.move_up();
}
}
Action::ConfigDown => {
if let Some(popup) = self.config_popup.as_mut() {
popup.move_down();
}
}
Action::ConfigSelect => {
if let Some(popup) = self.config_popup.as_ref() {
self.apply_config_selection(popup.cursor);
}
}
Action::CloseConfig => {
self.config_popup = None;
}
Action::EnterFilter => {
self.filter_active = true;
}
Action::ClearFilter => {
self.filter_active = false;
self.filter_text.clear();
self.rebuild_flat_list();
}
Action::FilterInput(c) => {
self.filter_text.push(c);
self.rebuild_flat_list();
}
Action::FilterBackspace => {
self.filter_text.pop();
if self.filter_text.is_empty() {
self.filter_active = false;
}
self.rebuild_flat_list();
}
}
}
fn apply_config_selection(&mut self, cursor: usize) {
let mut offset = 0;
let graph_count = GraphStyle::ALL.len();
if cursor < offset + graph_count {
self.graph_style = GraphStyle::ALL[cursor - offset];
self.persist_config();
return;
}
offset += graph_count;
let theme_count = Theme::ALL.len();
if cursor < offset + theme_count {
let theme = Theme::ALL[cursor - offset];
self.theme = theme;
self.palette = Palette::from_theme(theme);
self.persist_config();
}
}
fn move_selection(&mut self, delta: i32) {
let len = self.flat_list.len();
if len == 0 {
return;
}
let current = self.table_state.selected().unwrap_or(0) as i32;
let next = (current + delta).clamp(0, (len as i32) - 1) as usize;
self.table_state.select(Some(next));
}
pub fn update_processes(&mut self, processes: Vec<ProcessInfo>, stats: SystemStats) {
self.system_stats = stats;
let old_expansion = collect_expansion(&self.forest);
self.update_history(&processes);
let live_pids: HashSet<u32> = processes.iter().map(|p| p.pid).collect();
self.cpu_history.retain(|pid, _| live_pids.contains(pid));
self.mem_history.retain(|pid, _| live_pids.contains(pid));
self.forest = build_forest(&processes);
preserve_expansion(&mut self.forest, &old_expansion);
self.update_activity_states();
self.aggregate_cpu_history
.retain(|pid, _| live_pids.contains(pid));
self.activity_state.retain(|pid, _| live_pids.contains(pid));
if let Some(ref mut detail) = self.selected_detail {
if let Some(updated) = processes.iter().find(|p| p.pid == detail.pid) {
*detail = updated.clone();
}
}
self.rebuild_flat_list();
if let Some(ref detail) = self.selected_detail {
let pid = detail.pid;
self.selected_detail_subtree = self
.flat_list
.iter()
.find(|e| e.info.pid == pid)
.map(|e| e.subtree_stats);
}
self.agent_summary = self.compute_agent_summary();
}
fn sort_flat_list(&mut self) {
sort_forest(
&mut self.forest,
self.sort_column,
self.sort_direction,
true,
);
self.flat_list = flatten_visible(&self.forest);
}
pub fn compute_agent_summary(&self) -> AgentSummary {
let mut summary = AgentSummary::default();
for root in &self.forest {
match process_kind(&root.info) {
Some(ProcessKind::Claude) => summary.claude_count += 1,
Some(ProcessKind::Codex) => summary.codex_count += 1,
None => {}
}
summary.total_cpu += root.subtree_stats.total_cpu;
summary.total_memory += root.subtree_stats.total_memory;
}
summary
}
fn rebuild_flat_list(&mut self) {
self.sort_flat_list();
for entry in &mut self.flat_list {
if entry.is_root {
entry.activity = self.activity_state.get(&entry.info.pid).copied();
}
}
self.apply_filter();
self.clamp_selection();
}
fn update_activity_states(&mut self) {
for root in &self.forest {
let pid = root.info.pid;
let buf = self.aggregate_cpu_history.entry(pid).or_default();
if buf.len() == HISTORY_LEN {
buf.pop_front();
}
buf.push_back(root.info.cpu_usage);
let state = if buf.len() < IDLE_SAMPLE_WINDOW {
ActivityState::Unknown
} else {
let window_start = buf.len() - IDLE_SAMPLE_WINDOW;
let all_idle = buf
.iter()
.skip(window_start)
.all(|&s| s < IDLE_CPU_THRESHOLD);
if all_idle {
ActivityState::Idle
} else {
ActivityState::Active
}
};
self.activity_state.insert(pid, state);
}
}
fn apply_filter(&mut self) {
if self.filter_text.is_empty() {
return;
}
let query = self.filter_text.to_lowercase();
let n = self.flat_list.len();
let mut keep = vec![false; n];
for (i, entry) in self.flat_list.iter().enumerate() {
keep[i] = entry_matches_filter(entry, &query);
}
for i in (0..n).rev() {
if !keep[i] {
continue;
}
let child_depth = self.flat_list[i].depth;
if child_depth == 0 {
continue;
}
for j in (0..i).rev() {
if self.flat_list[j].depth == child_depth - 1 {
keep[j] = true;
break;
}
}
}
let mut idx = 0;
self.flat_list.retain(|_| {
let keep_entry = keep[idx];
idx += 1;
keep_entry
});
}
fn selected_pid(&self) -> Option<u32> {
match self.active_view {
ActiveView::Tree => {
let idx = self.table_state.selected()?;
Some(self.flat_list.get(idx)?.info.pid)
}
ActiveView::Detail => self.selected_detail.as_ref().map(|d| d.pid),
}
}
fn clamp_selection(&mut self) {
let len = self.flat_list.len();
if len == 0 {
self.table_state.select(None);
return;
}
let clamped = self.table_state.selected().unwrap_or(0).min(len - 1);
self.table_state.select(Some(clamped));
}
fn update_history(&mut self, processes: &[ProcessInfo]) {
for proc in processes {
let cpu_buf = self.cpu_history.entry(proc.pid).or_default();
if cpu_buf.len() == HISTORY_LEN {
cpu_buf.pop_front();
}
cpu_buf.push_back(proc.cpu_usage);
let mem_buf = self.mem_history.entry(proc.pid).or_default();
if mem_buf.len() == HISTORY_LEN {
mem_buf.pop_front();
}
mem_buf.push_back(proc.memory_bytes);
}
}
pub fn map_key_to_action(key: KeyEvent, ctx: &KeyContext<'_>) -> Option<Action> {
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
return Some(Action::Quit);
}
if ctx.config_open {
return match key.code {
KeyCode::Up | KeyCode::Char('k') => Some(Action::ConfigUp),
KeyCode::Down | KeyCode::Char('j') => Some(Action::ConfigDown),
KeyCode::Enter => Some(Action::ConfigSelect),
KeyCode::Esc | KeyCode::Char('c') => Some(Action::CloseConfig),
KeyCode::Char('q') => Some(Action::Quit),
_ => None,
};
}
if ctx.confirming_kill {
return match key.code {
KeyCode::Char('y') => Some(Action::ConfirmKill),
KeyCode::Char('n') | KeyCode::Esc => Some(Action::CancelKill),
_ => None,
};
}
if ctx.filter_active {
return match key.code {
KeyCode::Esc => Some(Action::ClearFilter),
KeyCode::Backspace => Some(Action::FilterBackspace),
KeyCode::Up | KeyCode::Char('k') => Some(Action::MoveUp),
KeyCode::Down | KeyCode::Char('j') => Some(Action::MoveDown),
KeyCode::Enter => Some(Action::SelectProcess),
KeyCode::Char(c) => Some(Action::FilterInput(c)),
_ => None,
};
}
match ctx.active_view {
ActiveView::Tree => match key.code {
KeyCode::Char('q') => Some(Action::Quit),
KeyCode::Up | KeyCode::Char('k') => Some(Action::MoveUp),
KeyCode::Down | KeyCode::Char('j') => Some(Action::MoveDown),
KeyCode::Char(' ') => Some(Action::ToggleExpand),
KeyCode::Enter => Some(Action::SelectProcess),
KeyCode::Tab => Some(Action::SortNext),
KeyCode::BackTab => Some(Action::SortPrev),
KeyCode::Char('s') => Some(Action::SortToggleDirection),
KeyCode::Char('x') => Some(Action::KillRequest),
KeyCode::Char('c') => Some(Action::ToggleConfig),
KeyCode::Char('/') => Some(Action::EnterFilter),
_ => None,
},
ActiveView::Detail => match key.code {
KeyCode::Char('q') => Some(Action::Quit),
KeyCode::Esc => Some(Action::BackToTree),
KeyCode::Char('x') => Some(Action::KillRequest),
KeyCode::Char('c') => Some(Action::ToggleConfig),
_ => None,
},
}
}
}
fn kill_process(pid: u32) -> String {
let pid_i32 = pid as i32;
let result = unsafe { libc::kill(pid_i32, libc::SIGTERM) };
if result == 0 {
return format!("Sent SIGTERM to PID {}", pid);
}
let err = std::io::Error::last_os_error();
match err.raw_os_error() {
Some(libc::ESRCH) => format!("PID {} not found", pid),
Some(libc::EPERM) => format!("Permission denied for PID {}", pid),
_ => format!("Failed to kill PID {}: {}", pid, err),
}
}
fn sort_forest(
nodes: &mut [ProcessNode],
column: SortColumn,
direction: SortDirection,
use_aggregate: bool,
) {
nodes.sort_by(|a, b| {
let cmp = compare_nodes(a, b, column, use_aggregate);
match direction {
SortDirection::Ascending => cmp,
SortDirection::Descending => cmp.reverse(),
}
});
for node in nodes.iter_mut() {
sort_forest(&mut node.children, column, direction, false);
}
}
fn compare_nodes(
a: &ProcessNode,
b: &ProcessNode,
column: SortColumn,
use_aggregate: bool,
) -> std::cmp::Ordering {
if use_aggregate {
match column {
SortColumn::Cpu => {
return a
.subtree_stats
.total_cpu
.partial_cmp(&b.subtree_stats.total_cpu)
.unwrap_or(std::cmp::Ordering::Equal);
}
SortColumn::Memory => {
return a
.subtree_stats
.total_memory
.cmp(&b.subtree_stats.total_memory);
}
_ => {}
}
}
compare_by_column(&a.info, &b.info, column)
}
pub fn entry_matches_filter(entry: &FlatEntry, query: &str) -> bool {
use crate::process::display_name;
if display_name(&entry.info).to_lowercase().contains(query) {
return true;
}
if entry.info.pid.to_string().contains(query) {
return true;
}
if let Some(ref cwd) = entry.info.cwd {
let basename = cwd.rsplit('/').next().unwrap_or(cwd.as_str());
if basename.to_lowercase().contains(query) {
return true;
}
}
let cmd = entry.info.cmd.join(" ");
cmd.to_lowercase().contains(query)
}
fn compare_by_column(a: &ProcessInfo, b: &ProcessInfo, column: SortColumn) -> std::cmp::Ordering {
match column {
SortColumn::Pid => a.pid.cmp(&b.pid),
SortColumn::Name => a.name.cmp(&b.name),
SortColumn::Cpu => a
.cpu_usage
.partial_cmp(&b.cpu_usage)
.unwrap_or(std::cmp::Ordering::Equal),
SortColumn::Memory => a.memory_bytes.cmp(&b.memory_bytes),
SortColumn::Status => a.status.cmp(&b.status),
SortColumn::Uptime => a.run_time.cmp(&b.run_time),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::process::{build_forest, flatten_visible, ProcessInfo};
fn make_proc(pid: u32, parent: Option<u32>, name: &str, cpu: f32, mem: u64) -> ProcessInfo {
ProcessInfo {
pid,
parent_pid: parent,
name: name.to_string(),
cmd: vec![name.to_string()],
exe_path: None,
cwd: None,
cpu_usage: cpu,
memory_bytes: mem,
status: "Run".to_string(),
environ_count: 0,
start_time: 0,
run_time: 0,
}
}
#[test]
fn test_agent_summary_empty() {
let app = App {
forest: build_forest(&[]),
..Default::default()
};
let summary = app.compute_agent_summary();
assert_eq!(summary.claude_count, 0);
assert_eq!(summary.codex_count, 0);
assert_eq!(summary.total_memory, 0);
assert!((summary.total_cpu - 0.0).abs() < 1e-4);
}
#[test]
fn test_agent_summary_mixed() {
let procs = vec![
make_proc(1, None, "claude", 1.0, 100),
make_proc(2, Some(1), "node", 1.0, 100), make_proc(3, None, "claude", 2.0, 200),
make_proc(4, Some(3), "node", 2.0, 200), make_proc(5, None, "codex", 3.0, 300),
make_proc(6, Some(5), "node", 3.0, 300), ];
let app = App {
forest: build_forest(&procs),
..Default::default()
};
let summary = app.compute_agent_summary();
assert_eq!(summary.claude_count, 2);
assert_eq!(summary.codex_count, 1);
assert!(
(summary.total_cpu - 12.0).abs() < 1e-3,
"total cpu: {}",
summary.total_cpu
);
assert_eq!(summary.total_memory, 1200);
}
fn app_with_procs(procs: Vec<ProcessInfo>) -> App {
let mut app = App::new();
app.forest = build_forest(&procs);
app.flat_list = flatten_visible(&app.forest);
app
}
fn make_proc_simple(pid: u32, parent: Option<u32>, name: &str, cpu: f32) -> ProcessInfo {
make_proc(pid, parent, name, cpu, 0)
}
#[test]
fn test_activity_unknown_fewer_than_window() {
let mut app = App::new();
let pid = 1u32;
let procs = vec![make_proc_simple(pid, None, "claude", 0.0)];
app.forest = build_forest(&procs);
let buf = app.aggregate_cpu_history.entry(pid).or_default();
for _ in 0..(IDLE_SAMPLE_WINDOW - 2) {
buf.push_back(0.0);
}
app.update_activity_states();
assert_eq!(app.activity_state.get(&pid), Some(&ActivityState::Unknown));
}
#[test]
fn test_activity_idle() {
let mut app = App::new();
let pid = 1u32;
let procs = vec![make_proc_simple(pid, None, "claude", 0.1)];
app.forest = build_forest(&procs);
let buf = app.aggregate_cpu_history.entry(pid).or_default();
for _ in 0..(IDLE_SAMPLE_WINDOW - 1) {
buf.push_back(0.1);
}
app.update_activity_states();
assert_eq!(app.activity_state.get(&pid), Some(&ActivityState::Idle));
}
#[test]
fn test_activity_active() {
let mut app = App::new();
let pid = 1u32;
let procs = vec![make_proc_simple(pid, None, "claude", 50.0)];
app.forest = build_forest(&procs);
let buf = app.aggregate_cpu_history.entry(pid).or_default();
for _ in 0..(IDLE_SAMPLE_WINDOW - 1) {
buf.push_back(0.1);
}
app.update_activity_states();
assert_eq!(app.activity_state.get(&pid), Some(&ActivityState::Active));
}
#[test]
fn test_filter_matches_name() {
let procs = vec![
make_proc_simple(1, None, "claude", 0.0),
make_proc_simple(2, None, "codex", 0.0),
];
let mut app = app_with_procs(procs);
app.filter_text = "claude".to_string();
app.apply_filter();
assert_eq!(app.flat_list.len(), 1);
assert_eq!(app.flat_list[0].info.name, "claude");
}
#[test]
fn test_filter_preserves_parents() {
let procs = vec![
make_proc_simple(1, None, "claude", 0.0),
make_proc_simple(2, Some(1), "node", 0.0),
];
let mut app = app_with_procs(procs);
app.filter_text = "node".to_string();
app.apply_filter();
assert_eq!(app.flat_list.len(), 2);
assert!(app.flat_list.iter().any(|e| e.info.pid == 1));
assert!(app.flat_list.iter().any(|e| e.info.pid == 2));
}
#[test]
fn test_filter_case_insensitive() {
let procs = vec![make_proc_simple(1, None, "claude", 0.0)];
let mut app = app_with_procs(procs);
app.filter_text = "CLAUDE".to_string();
app.apply_filter();
assert_eq!(app.flat_list.len(), 1);
}
#[test]
fn test_filter_clears() {
let procs = vec![
make_proc_simple(1, None, "claude", 0.0),
make_proc_simple(2, None, "codex", 0.0),
];
let mut app = app_with_procs(procs);
app.filter_text = "claude".to_string();
app.apply_filter();
assert_eq!(app.flat_list.len(), 1);
app.handle_action(Action::ClearFilter);
assert_eq!(app.flat_list.len(), 2);
}
}