use crate::collector::{read_rate_limits, McpServer, MultiCollector};
use crate::host_info::{AgentAggregate, HostMetrics, HostSampler};
use crate::model::{AgentSession, OrphanPort, RateLimitInfo, SessionStatus};
use crate::theme::Theme;
use std::collections::{HashMap, HashSet, VecDeque};
use std::path::PathBuf;
use std::sync::mpsc;
use std::time::Instant;
const GRAPH_HISTORY_LEN: usize = 200;
const MAX_SUMMARY_JOBS: usize = 3;
const MAX_SUMMARY_RETRIES: u32 = 2;
fn sanitize_fallback(prompt: &str, max_len: usize) -> String {
prompt
.chars()
.filter(|c| !c.is_control() || *c == ' ')
.take(max_len)
.collect()
}
pub enum JumpOutcome {
Jumped,
Failed(String),
NoOp,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum NarrowTab {
Work,
Usage,
System,
}
impl NarrowTab {
pub const ALL: [Self; 3] = [Self::Work, Self::Usage, Self::System];
pub fn label(self) -> &'static str {
match self {
Self::Work => "Work",
Self::Usage => "Usage",
Self::System => "System",
}
}
pub fn shortcut(self) -> char {
match self {
Self::Work => 'w',
Self::Usage => 'u',
Self::System => 's',
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum NarrowSection {
Sessions,
Projects,
Context,
Quota,
Tokens,
Ports,
Mcp,
}
impl NarrowSection {
pub fn tab(self) -> NarrowTab {
match self {
Self::Sessions | Self::Projects => NarrowTab::Work,
Self::Context | Self::Quota | Self::Tokens => NarrowTab::Usage,
Self::Ports | Self::Mcp => NarrowTab::System,
}
}
}
pub struct App {
pub sessions: Vec<AgentSession>,
pub selected: usize,
pub should_quit: bool,
pub token_rates: VecDeque<f64>,
pub rate_limits: Vec<RateLimitInfo>,
prev_tokens: HashMap<(String, String), u64>,
rate_limit_counter: u32,
collector: MultiCollector,
pub summaries: HashMap<String, String>,
pending_summaries: HashSet<String>,
summary_retries: HashMap<String, u32>,
summary_rx: mpsc::Receiver<(String, String, Option<String>)>,
summary_tx: mpsc::Sender<(String, String, Option<String>)>,
pub orphan_ports: Vec<OrphanPort>,
pub status_msg: Option<(String, Instant)>,
kill_confirm: Option<(usize, Instant)>,
pub theme: Theme,
pub show_context: bool,
pub show_quota: bool,
pub show_tokens: bool,
pub show_projects: bool,
pub show_ports: bool,
pub show_sessions: bool,
pub show_mcp: bool,
pub narrow_tab: NarrowTab,
pub active_narrow_section: Option<NarrowSection>,
pub maximized_narrow_section: Option<NarrowSection>,
pub mcp_servers: Vec<McpServer>,
pub mcp_suppress_sessions: bool,
pub config_open: bool,
pub config_selected: usize,
pub tree_view: bool,
pub filter_text: String,
pub filter_active: bool,
pub show_timeline: bool,
pub timeline_scroll: usize,
pub show_file_audit: bool,
host_sampler: HostSampler,
pub host_metrics: Option<HostMetrics>,
pub agent_aggregate: AgentAggregate,
pub help_open: bool,
pub view_open: bool,
}
impl App {
#[cfg(test)]
pub fn new_with_config(
theme: Theme,
hidden_agents: &[String],
panels: crate::config::PanelVisibility,
) -> Self {
Self::new_with_config_and_claude_dirs(theme, hidden_agents, panels, &[])
}
pub fn new_with_config_and_claude_dirs(
theme: Theme,
hidden_agents: &[String],
panels: crate::config::PanelVisibility,
claude_config_dirs: &[PathBuf],
) -> Self {
let (tx, rx) = mpsc::channel();
let summaries = load_summary_cache();
let mut collector =
MultiCollector::with_hidden_and_claude_config_dirs(hidden_agents, claude_config_dirs);
collector.set_mcp_suppress(true);
Self {
sessions: Vec::new(),
selected: 0,
should_quit: false,
token_rates: VecDeque::with_capacity(GRAPH_HISTORY_LEN),
rate_limits: Vec::new(),
prev_tokens: HashMap::new(),
rate_limit_counter: 5,
collector,
summaries,
pending_summaries: HashSet::new(),
summary_retries: HashMap::new(),
summary_rx: rx,
summary_tx: tx,
orphan_ports: Vec::new(),
status_msg: None,
kill_confirm: None,
theme,
show_context: panels.context,
show_quota: panels.quota,
show_tokens: panels.tokens,
show_projects: panels.projects,
show_ports: panels.ports,
show_sessions: panels.sessions,
show_mcp: panels.mcp,
narrow_tab: NarrowTab::Work,
active_narrow_section: Some(NarrowSection::Sessions),
maximized_narrow_section: None,
mcp_servers: Vec::new(),
mcp_suppress_sessions: true,
config_open: false,
config_selected: 0,
tree_view: false,
filter_text: String::new(),
filter_active: false,
show_timeline: false,
timeline_scroll: 0,
show_file_audit: false,
host_sampler: HostSampler::new(),
host_metrics: None,
agent_aggregate: AgentAggregate::default(),
help_open: false,
view_open: false,
}
}
pub fn toggle_help(&mut self) {
self.help_open = !self.help_open;
if self.help_open {
self.view_open = false;
}
}
pub fn toggle_view_menu(&mut self) {
self.view_open = !self.view_open;
if self.view_open {
self.help_open = false;
}
}
pub fn toggle_panel(&mut self, panel: u8) {
match panel {
1 => self.show_context = !self.show_context,
2 => self.show_quota = !self.show_quota,
3 => self.show_tokens = !self.show_tokens,
4 => self.show_projects = !self.show_projects,
5 => self.show_ports = !self.show_ports,
6 => self.show_sessions = !self.show_sessions,
7 => self.show_mcp = !self.show_mcp,
_ => return,
}
self.persist_panel_visibility();
self.clamp_narrow_tab();
}
pub fn toggle_mcp_session_suppression(&mut self) {
self.mcp_suppress_sessions = !self.mcp_suppress_sessions;
let label = if self.mcp_suppress_sessions {
"on"
} else {
"off"
};
self.set_status(format!("mcp session suppression: {}", label));
}
fn persist_panel_visibility(&mut self) {
let panels = crate::config::PanelVisibility {
context: self.show_context,
quota: self.show_quota,
tokens: self.show_tokens,
projects: self.show_projects,
ports: self.show_ports,
sessions: self.show_sessions,
mcp: self.show_mcp,
};
if let Err(e) = crate::config::save_panel_visibility(&panels) {
self.set_status(format!("panels save failed: {}", e));
}
}
pub fn toggle_file_audit(&mut self) {
self.show_file_audit = !self.show_file_audit;
}
pub fn toggle_config(&mut self) {
self.config_open = !self.config_open;
if self.config_open {
self.config_selected = 0;
}
}
pub fn config_item_count(&self) -> usize {
8 }
pub fn config_select_next(&mut self) {
if self.config_selected + 1 < self.config_item_count() {
self.config_selected += 1;
}
}
pub fn config_select_prev(&mut self) {
self.config_selected = self.config_selected.saturating_sub(1);
}
pub fn config_toggle_selected(&mut self) {
match self.config_selected {
0 => {
self.cycle_theme();
return;
}
1 => self.show_context = !self.show_context,
2 => self.show_quota = !self.show_quota,
3 => self.show_tokens = !self.show_tokens,
4 => self.show_projects = !self.show_projects,
5 => self.show_ports = !self.show_ports,
6 => self.show_sessions = !self.show_sessions,
7 => self.show_mcp = !self.show_mcp,
_ => return,
}
self.persist_panel_visibility();
self.clamp_narrow_tab();
}
pub fn narrow_tab_visible(&self, tab: NarrowTab) -> bool {
match tab {
NarrowTab::Work => self.show_sessions || self.show_projects,
NarrowTab::Usage => self.show_context || self.show_quota || self.show_tokens,
NarrowTab::System => self.show_ports || self.show_mcp,
}
}
pub fn visible_narrow_tabs(&self) -> Vec<NarrowTab> {
NarrowTab::ALL
.into_iter()
.filter(|&tab| self.narrow_tab_visible(tab))
.collect()
}
pub fn active_narrow_tab(&self) -> Option<NarrowTab> {
if self.narrow_tab_visible(self.narrow_tab) {
Some(self.narrow_tab)
} else {
NarrowTab::ALL
.into_iter()
.find(|&tab| self.narrow_tab_visible(tab))
}
}
pub fn set_narrow_tab(&mut self, tab: NarrowTab) {
if self.narrow_tab_visible(tab) {
self.narrow_tab = tab;
self.clamp_narrow_section();
}
}
pub fn select_next_narrow_tab(&mut self) {
let tabs = self.visible_narrow_tabs();
if tabs.is_empty() {
return;
}
let current = self.active_narrow_tab().unwrap_or(tabs[0]);
let pos = tabs.iter().position(|&tab| tab == current).unwrap_or(0);
self.narrow_tab = tabs[(pos + 1) % tabs.len()];
self.clamp_narrow_section();
}
pub fn select_prev_narrow_tab(&mut self) {
let tabs = self.visible_narrow_tabs();
if tabs.is_empty() {
return;
}
let current = self.active_narrow_tab().unwrap_or(tabs[0]);
let pos = tabs.iter().position(|&tab| tab == current).unwrap_or(0);
self.narrow_tab = tabs[(pos + tabs.len() - 1) % tabs.len()];
self.clamp_narrow_section();
}
fn clamp_narrow_tab(&mut self) {
if let Some(tab) = self.active_narrow_tab() {
self.narrow_tab = tab;
}
self.clamp_narrow_section();
}
pub fn narrow_section_visible(&self, section: NarrowSection) -> bool {
match section {
NarrowSection::Sessions => self.show_sessions,
NarrowSection::Projects => self.show_projects,
NarrowSection::Context => self.show_context,
NarrowSection::Quota => self.show_quota,
NarrowSection::Tokens => self.show_tokens,
NarrowSection::Ports => self.show_ports,
NarrowSection::Mcp => self.show_mcp,
}
}
pub fn visible_narrow_sections(&self, tab: NarrowTab) -> Vec<NarrowSection> {
let sections: &[NarrowSection] = match tab {
NarrowTab::Work => &[NarrowSection::Sessions, NarrowSection::Projects],
NarrowTab::Usage => &[
NarrowSection::Context,
NarrowSection::Quota,
NarrowSection::Tokens,
],
NarrowTab::System => &[NarrowSection::Ports, NarrowSection::Mcp],
};
sections
.iter()
.copied()
.filter(|§ion| self.narrow_section_visible(section))
.collect()
}
pub fn active_narrow_section(&self) -> Option<NarrowSection> {
let tab = self.active_narrow_tab()?;
if let Some(section) = self.active_narrow_section {
if section.tab() == tab && self.narrow_section_visible(section) {
return Some(section);
}
}
self.visible_narrow_sections(tab).into_iter().next()
}
pub fn set_active_narrow_section(&mut self, section: NarrowSection) {
if self.narrow_section_visible(section) {
self.narrow_tab = section.tab();
self.active_narrow_section = Some(section);
self.clamp_narrow_section();
}
}
pub fn maximized_narrow_section(&self) -> Option<NarrowSection> {
let section = self.maximized_narrow_section?;
if self.active_narrow_tab() == Some(section.tab()) && self.narrow_section_visible(section) {
Some(section)
} else {
None
}
}
pub fn toggle_narrow_section_zoom(&mut self, section: NarrowSection) {
if !self.narrow_section_visible(section) {
return;
}
self.set_active_narrow_section(section);
self.maximized_narrow_section = if self.maximized_narrow_section() == Some(section) {
None
} else {
Some(section)
};
}
pub fn maximize_active_narrow_section(&mut self) {
if let Some(section) = self.active_narrow_section() {
self.maximized_narrow_section = Some(section);
}
}
pub fn restore_narrow_sections(&mut self) {
self.maximized_narrow_section = None;
}
fn clamp_narrow_section(&mut self) {
self.active_narrow_section = self.active_narrow_section();
if self.maximized_narrow_section().is_none() {
self.maximized_narrow_section = None;
}
}
pub fn toggle_timeline(&mut self) {
self.show_timeline = !self.show_timeline;
self.timeline_scroll = 0;
}
pub fn cycle_theme(&mut self) {
let names = crate::theme::THEME_NAMES;
let current = names
.iter()
.position(|&n| n == self.theme.name)
.unwrap_or(0);
let next = (current + 1) % names.len();
self.theme = Theme::by_name(names[next]).unwrap_or_default();
if let Err(e) = crate::config::save_theme(names[next]) {
self.set_status(format!("theme: {} (save failed: {})", names[next], e));
} else {
self.set_status(format!("theme: {}", names[next]));
}
}
pub fn set_status(&mut self, msg: String) {
self.status_msg = Some((msg, Instant::now()));
}
pub fn tick(&mut self) {
self.tick_no_summaries();
self.drain_and_retry_summaries();
}
pub fn tick_no_summaries(&mut self) {
self.collector.set_mcp_suppress(self.mcp_suppress_sessions);
self.sessions = self.collector.collect();
self.orphan_ports = self.collector.orphan_ports.clone();
self.mcp_servers = self.collector.mcp_servers.clone();
self.host_metrics = self.host_sampler.sample();
self.agent_aggregate = AgentAggregate::from_sessions(&self.sessions);
if self.selected >= self.sessions.len() && !self.sessions.is_empty() {
self.selected = self.sessions.len() - 1;
}
self.clamp_selection_to_visible();
let mut rate: f64 = 0.0;
for s in &self.sessions {
let key = (s.agent_cli.to_string(), s.session_id.clone());
let total = s.active_tokens();
let prev = self.prev_tokens.get(&key).copied().unwrap_or(total);
rate += total.saturating_sub(prev) as f64;
self.prev_tokens.insert(key, total);
}
self.token_rates.push_back(rate);
if self.token_rates.len() > GRAPH_HISTORY_LEN {
self.token_rates.pop_front();
}
if self.rate_limits.is_empty() || self.rate_limit_counter >= 5 {
self.rate_limit_counter = 0;
let extra_dirs = self.collector.all_config_dirs();
self.rate_limits = read_rate_limits(&extra_dirs);
self.rate_limits.extend(self.collector.agent_rate_limits());
} else {
self.rate_limit_counter += 1;
}
promote_waiting_to_rate_limited(&mut self.sessions, &self.rate_limits);
}
pub fn drain_and_retry_summaries(&mut self) {
while let Ok((sid, prompt, maybe_summary)) = self.summary_rx.try_recv() {
self.pending_summaries.remove(&sid);
match maybe_summary {
Some(summary) => {
self.summary_retries.remove(&sid);
self.summaries.insert(sid, summary);
save_summary_cache(&self.summaries);
}
None => {
let count = self.summary_retries.entry(sid.clone()).or_insert(0);
*count += 1;
if *count >= MAX_SUMMARY_RETRIES {
self.summaries.insert(sid, sanitize_fallback(&prompt, 80));
save_summary_cache(&self.summaries);
}
}
}
}
for s in &self.sessions {
let retries = self
.summary_retries
.get(&s.session_id)
.copied()
.unwrap_or(0);
let has_input = !s.initial_prompt.is_empty() || !s.first_assistant_text.is_empty();
if has_input
&& !self.summaries.contains_key(&s.session_id)
&& !self.pending_summaries.contains(&s.session_id)
&& self.pending_summaries.len() < MAX_SUMMARY_JOBS
&& retries < MAX_SUMMARY_RETRIES
{
self.pending_summaries.insert(s.session_id.clone());
let sid = s.session_id.clone();
let prompt = s.initial_prompt.clone();
let assistant_text = s.first_assistant_text.clone();
let tx = self.summary_tx.clone();
std::thread::spawn(move || {
let result = generate_summary(&prompt, &assistant_text);
let fallback_text = if prompt.is_empty() {
assistant_text
} else {
prompt
};
let _ = tx.send((sid, fallback_text, result));
});
}
}
}
pub fn has_pending_summaries(&self) -> bool {
!self.pending_summaries.is_empty()
}
pub fn has_retryable_summaries(&self) -> bool {
self.sessions.iter().any(|s| {
(!s.initial_prompt.is_empty() || !s.first_assistant_text.is_empty())
&& !self.summaries.contains_key(&s.session_id)
&& !self.pending_summaries.contains(&s.session_id)
&& self
.summary_retries
.get(&s.session_id)
.copied()
.unwrap_or(0)
< MAX_SUMMARY_RETRIES
})
}
pub fn visible_indices(&self) -> Vec<usize> {
if self.filter_text.is_empty() {
return (0..self.sessions.len()).collect();
}
let query = self.filter_text.to_lowercase();
self.sessions
.iter()
.enumerate()
.filter(|(_, s)| Self::session_matches(s, &query))
.map(|(i, _)| i)
.collect()
}
fn session_matches(s: &AgentSession, query: &str) -> bool {
s.project_name.to_lowercase().contains(query)
|| s.model.to_lowercase().contains(query)
|| s.session_id.to_lowercase().contains(query)
|| s.initial_prompt.to_lowercase().contains(query)
|| s.cwd.to_lowercase().contains(query)
|| format!("{:?}", s.status).to_lowercase().contains(query)
}
fn clamp_selection_to_visible(&mut self) {
let visible = self.visible_indices();
if visible.is_empty() {
return;
}
if !visible.contains(&self.selected) {
self.selected = visible[0];
}
}
pub fn filter_push(&mut self, c: char) {
self.filter_text.push(c);
self.clamp_selection_to_visible();
}
pub fn filter_pop(&mut self) {
self.filter_text.pop();
self.clamp_selection_to_visible();
}
pub fn clear_filter(&mut self) {
self.filter_active = false;
self.filter_text.clear();
}
pub fn select_next(&mut self) {
let visible = self.visible_indices();
if visible.is_empty() {
return;
}
if let Some(pos) = visible.iter().position(|&i| i == self.selected) {
if pos + 1 < visible.len() {
self.selected = visible[pos + 1];
}
} else {
self.selected = visible[0];
}
}
pub fn select_prev(&mut self) {
let visible = self.visible_indices();
if visible.is_empty() {
return;
}
if let Some(pos) = visible.iter().position(|&i| i == self.selected) {
if pos > 0 {
self.selected = visible[pos - 1];
}
} else {
self.selected = *visible.last().unwrap();
}
}
pub fn select_session(&mut self, index: usize) {
if index < self.sessions.len() && self.visible_indices().contains(&index) {
self.selected = index;
}
}
pub fn kill_selected(&mut self) {
if self.sessions.is_empty() {
return;
}
let session = &self.sessions[self.selected];
if matches!(session.status, SessionStatus::Done | SessionStatus::Unknown) {
return;
}
if let Some((idx, ts)) = self.kill_confirm.take() {
if idx == self.selected && ts.elapsed().as_secs() < 2 {
let pid = session.pid;
let verified = std::process::Command::new("ps")
.args(["-p", &pid.to_string(), "-o", "command="])
.output()
.ok()
.map(|output| {
let cmd = String::from_utf8_lossy(&output.stdout).trim().to_string();
is_killable_agent_command(&cmd)
})
.unwrap_or(false);
if !verified {
self.set_status(format!("PID {} is no longer a known agent process", pid));
return;
}
let _ = std::process::Command::new("kill")
.args(["-9", &pid.to_string()])
.output();
self.tick();
return;
}
}
let name = self
.summaries
.get(&session.session_id)
.cloned()
.unwrap_or_else(|| format!("PID {}", session.pid));
self.kill_confirm = Some((self.selected, Instant::now()));
self.set_status(format!("Press x again to kill: {}", name));
}
pub fn kill_orphan_ports(&mut self) {
use crate::collector::process::get_listening_ports;
let fresh_ports = get_listening_ports();
for orphan in &self.orphan_ports {
let still_listening = fresh_ports
.get(&orphan.pid)
.is_some_and(|ports| ports.contains(&orphan.port));
if !still_listening {
continue;
}
if let Ok(output) = std::process::Command::new("ps")
.args(["-p", &orphan.pid.to_string(), "-o", "command="])
.output()
{
let current_cmd = String::from_utf8_lossy(&output.stdout).trim().to_string();
if current_cmd == orphan.command {
let _ = std::process::Command::new("kill")
.args([&orphan.pid.to_string()])
.output();
}
}
}
self.tick();
}
pub fn quit(&mut self) {
self.should_quit = true;
}
pub fn jump_to_session(&mut self) -> JumpOutcome {
if self.sessions.is_empty() {
return JumpOutcome::NoOp;
}
if std::env::var("TMUX").is_err() {
return JumpOutcome::NoOp;
}
let target_pid = self.sessions[self.selected].pid;
match self.jump_via_tmux(target_pid) {
None => JumpOutcome::Jumped,
Some(msg) => JumpOutcome::Failed(msg),
}
}
fn jump_via_tmux(&self, target_pid: u32) -> Option<String> {
let output = std::process::Command::new("tmux")
.args([
"list-panes",
"-a",
"-F",
"#{pane_pid} #{session_name}:#{window_index}.#{pane_index}",
])
.output()
.ok()?;
let stdout = String::from_utf8_lossy(&output.stdout);
for line in stdout.lines() {
let mut parts = line.splitn(2, ' ');
let pane_pid: u32 = match parts.next().and_then(|p| p.parse().ok()) {
Some(p) => p,
None => continue,
};
let pane_target = match parts.next() {
Some(t) => t,
None => continue,
};
if is_descendant_of(target_pid, pane_pid) {
if let Some(session_name) = pane_target.split(':').next() {
let _ = std::process::Command::new("tmux")
.args(["switch-client", "-t", session_name])
.status();
}
if let Some(window) = pane_target.split('.').next() {
let _ = std::process::Command::new("tmux")
.args(["select-window", "-t", window])
.status();
}
let _ = std::process::Command::new("tmux")
.args(["select-pane", "-t", pane_target])
.status();
return None; }
}
Some("pane not found".to_string())
}
pub fn session_summary(&self, session: &AgentSession) -> String {
if let Some(summary) = self.summaries.get(&session.session_id) {
summary.clone()
} else if matches!(session.status, SessionStatus::Done) {
if !session.initial_prompt.is_empty() {
sanitize_fallback(&session.initial_prompt, 80)
} else if !session.first_assistant_text.is_empty() {
sanitize_fallback(&session.first_assistant_text, 80)
} else {
"—".to_string()
}
} else if self.pending_summaries.contains(&session.session_id) {
let dots = match (std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis()
/ 500)
% 3
{
0 => ".",
1 => "..",
_ => "...",
};
dots.to_string()
} else if !session.initial_prompt.is_empty() {
sanitize_fallback(&session.initial_prompt, 80)
} else if !session.first_assistant_text.is_empty() {
sanitize_fallback(&session.first_assistant_text, 80)
} else {
"—".to_string()
}
}
}
fn generate_summary(prompt: &str, assistant_text: &str) -> Option<String> {
use std::io::Write;
use std::process::{Command, Stdio};
use std::time::Duration;
let user_part: String = prompt.chars().take(200).collect();
let assistant_part: String = assistant_text.chars().take(200).collect();
let context = if !user_part.is_empty() && !assistant_part.is_empty() {
format!(
"User message: {}\n\nAssistant response: {}",
user_part, assistant_part
)
} else if !assistant_part.is_empty() {
format!("Assistant response: {}", assistant_part)
} else {
format!("User message: {}", user_part)
};
let request = format!(
"You are a conversation title generator. Given the conversation below, create a short title (3-5 words) that describes the session's main topic. Be specific and actionable. Do NOT output generic titles like 'New conversation' or 'Initial setup'. Output ONLY the title, no quotes, no explanation.\n\n{}",
context
);
let mut child = match Command::new("claude")
.args(["--print", "-"])
.current_dir(std::env::temp_dir())
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
{
Ok(c) => c,
Err(_) => return Some(sanitize_fallback(prompt, 80)),
};
if let Some(mut stdin) = child.stdin.take() {
let _ = stdin.write_all(request.as_bytes());
}
let child_pid = child.id();
let (wo_tx, wo_rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let _ = wo_tx.send(child.wait_with_output());
});
let result = match wo_rx.recv_timeout(Duration::from_secs(10)) {
Ok(r) => r,
Err(_) => {
let _ = std::process::Command::new("kill")
.args(["-9", &child_pid.to_string()])
.status();
return None;
}
};
let fallback = sanitize_fallback(prompt, 80);
match result {
Ok(output) if output.status.success() => {
let raw = String::from_utf8_lossy(&output.stdout).trim().to_string();
let lower = raw.to_lowercase();
if raw.is_empty()
|| raw.chars().count() > 80
|| raw.contains("Summarize")
|| raw.starts_with("- ")
|| lower.contains("new conversation")
|| lower.contains("initial setup")
|| lower.contains("initial project")
|| lower.contains("initial conversation")
|| lower.starts_with("greeting")
{
Some(fallback)
} else {
Some(raw.trim_matches('"').trim_matches('\'').to_string())
}
}
_ => Some(fallback),
}
}
fn cache_dir() -> std::path::PathBuf {
dirs::cache_dir()
.unwrap_or_else(|| dirs::home_dir().unwrap_or_default().join(".cache"))
.join("abtop")
}
fn cache_path() -> std::path::PathBuf {
cache_dir().join("summaries.json")
}
fn load_summary_cache() -> HashMap<String, String> {
let path = cache_path();
match std::fs::read_to_string(&path) {
Ok(content) => {
let mut cache: HashMap<String, String> =
serde_json::from_str(&content).unwrap_or_default();
let before = cache.len();
cache.retain(|_, v| !v.contains("You are a conversation tit") && !v.ends_with('…'));
if cache.len() < before {
let _ = std::fs::create_dir_all(cache_dir());
let _ = std::fs::write(&path, serde_json::to_string(&cache).unwrap_or_default());
}
cache
}
Err(_) => HashMap::new(),
}
}
fn is_descendant_of(target: u32, ancestor: u32) -> bool {
if target == ancestor {
return true;
}
let output = match std::process::Command::new("ps")
.args(["-eo", "pid,ppid"])
.output()
{
Ok(o) => o,
Err(_) => return false,
};
let stdout = String::from_utf8_lossy(&output.stdout);
let mut ppid_map: HashMap<u32, u32> = HashMap::new();
for line in stdout.lines().skip(1) {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 2 {
if let (Ok(pid), Ok(ppid)) = (parts[0].parse::<u32>(), parts[1].parse::<u32>()) {
ppid_map.insert(pid, ppid);
}
}
}
let mut current = target;
let mut depth = 0;
while depth < 50 {
if let Some(&parent) = ppid_map.get(¤t) {
if parent == ancestor {
return true;
}
if parent == 0 || parent == 1 || parent == current {
return false;
}
current = parent;
depth += 1;
} else {
return false;
}
}
false
}
fn save_summary_cache(summaries: &HashMap<String, String>) {
let path = cache_path();
let _ = std::fs::create_dir_all(cache_dir());
if let Ok(json) = serde_json::to_string(summaries) {
let tmp = path.with_extension("tmp");
if std::fs::write(&tmp, &json).is_ok() {
let _ = std::fs::rename(&tmp, &path);
}
}
}
const RATE_LIMITED_PCT: f64 = 90.0;
fn promote_waiting_to_rate_limited(sessions: &mut [AgentSession], rate_limits: &[RateLimitInfo]) {
if rate_limits.is_empty() {
return;
}
for s in sessions.iter_mut() {
if s.status != SessionStatus::Waiting {
continue;
}
let over = rate_limits.iter().any(|rl| {
rl.source == s.agent_cli
&& (rl.five_hour_pct.unwrap_or(0.0) > RATE_LIMITED_PCT
|| rl.seven_day_pct.unwrap_or(0.0) > RATE_LIMITED_PCT)
});
if over {
s.status = SessionStatus::RateLimited;
}
}
}
fn is_supported_agent_command(cmd: &str) -> bool {
crate::collector::process::cmd_has_binary(cmd, "claude")
|| crate::collector::process::cmd_has_binary(cmd, "codex")
|| crate::collector::process::cmd_has_binary(cmd, "opencode")
}
fn is_killable_agent_command(cmd: &str) -> bool {
is_supported_agent_command(cmd)
&& !(crate::collector::process::cmd_has_binary(cmd, "codex") && cmd.contains(" app-server"))
}
#[cfg(test)]
mod tests {
use super::*;
fn waiting_session(cli: &'static str) -> AgentSession {
AgentSession {
agent_cli: cli,
pid: 1,
session_id: String::new(),
cwd: String::new(),
project_name: String::new(),
started_at: 0,
status: SessionStatus::Waiting,
model: String::new(),
effort: String::new(),
context_percent: 0.0,
total_input_tokens: 0,
total_output_tokens: 0,
total_cache_read: 0,
total_cache_create: 0,
turn_count: 0,
compaction_count: 0,
current_tasks: vec![],
version: String::new(),
git_branch: String::new(),
mem_mb: 0,
token_history: vec![],
context_history: vec![],
context_window: 0,
subagents: vec![],
mem_file_count: 0,
mem_line_count: 0,
children: vec![],
initial_prompt: String::new(),
first_assistant_text: String::new(),
chat_messages: vec![],
tool_calls: vec![],
pending_since_ms: 0,
thinking_since_ms: 0,
file_accesses: vec![],
config_root: String::new(),
git_added: 0,
git_modified: 0,
}
}
fn rate_limit(source: &str, pct: f64) -> RateLimitInfo {
RateLimitInfo {
source: source.to_string(),
five_hour_pct: Some(pct),
five_hour_resets_at: None,
seven_day_pct: None,
seven_day_resets_at: None,
updated_at: None,
}
}
#[test]
fn test_rate_limited_promotion_is_per_agent_cli() {
let mut sessions = vec![waiting_session("claude"), waiting_session("codex")];
let limits = vec![rate_limit("claude", 95.0)];
promote_waiting_to_rate_limited(&mut sessions, &limits);
assert_eq!(sessions[0].status, SessionStatus::RateLimited);
assert_eq!(sessions[1].status, SessionStatus::Waiting);
}
#[test]
fn test_rate_limited_promotion_ignores_below_threshold() {
let mut sessions = vec![waiting_session("claude")];
let limits = vec![rate_limit("claude", 89.9)];
promote_waiting_to_rate_limited(&mut sessions, &limits);
assert_eq!(sessions[0].status, SessionStatus::Waiting);
}
#[test]
fn test_rate_limited_promotion_skips_non_waiting_sessions() {
let mut sessions = vec![waiting_session("claude")];
sessions[0].status = SessionStatus::Thinking;
let limits = vec![rate_limit("claude", 99.0)];
promote_waiting_to_rate_limited(&mut sessions, &limits);
assert_eq!(sessions[0].status, SessionStatus::Thinking);
}
#[test]
fn supported_agent_command_accepts_opencode() {
assert!(is_supported_agent_command("/usr/local/bin/claude"));
assert!(is_supported_agent_command("codex --resume abc"));
assert!(is_supported_agent_command("/opt/homebrew/bin/opencode"));
assert!(!is_supported_agent_command("node server.js"));
}
#[test]
fn killable_agent_command_rejects_codex_app_server() {
assert!(is_killable_agent_command("codex --resume abc"));
assert!(is_killable_agent_command("/usr/local/bin/claude"));
assert!(!is_killable_agent_command(
"/Applications/Codex.app/Contents/Resources/codex app-server --analytics-default-enabled"
));
}
}