use crate::widgets::bg_activity_overlay::{ActivityRow, ActivityStatus, MAX_VISIBLE};
use koda_core::bg_agent::{AgentStatus, BgTaskSnapshot};
use koda_core::engine::event::BgChildActivityKind;
use koda_core::tools::bg_process::{BgProcessSnapshot, BgProcessStatus};
use std::collections::HashMap;
use std::time::Duration;
#[derive(Debug, Clone)]
struct LastActivity {
description: String,
}
#[derive(Debug, Clone, Default)]
pub struct BgActivityTracker {
per_agent: HashMap<u32, LastActivity>,
cancelling: std::collections::HashSet<u32>,
}
impl BgActivityTracker {
pub fn record_activity(&mut self, task_id: u32, kind: &BgChildActivityKind) {
let description = match kind {
BgChildActivityKind::ToolStart { summary, .. } => summary.clone(),
BgChildActivityKind::ToolEnd { tool_name, success } => {
if *success {
return;
}
format!("{tool_name} \u{2717}")
}
BgChildActivityKind::Info { message } => message.clone(),
};
self.per_agent.insert(task_id, LastActivity { description });
}
pub fn mark_cancelling(&mut self, task_id: u32) {
self.cancelling.insert(task_id);
}
fn forget(&mut self, task_id: u32) {
self.per_agent.remove(&task_id);
self.cancelling.remove(&task_id);
}
pub fn build_rows(
&mut self,
agents: &[BgTaskSnapshot],
processes: &[BgProcessSnapshot],
) -> (Vec<ActivityRow>, usize) {
let live_ids: std::collections::HashSet<u32> = agents.iter().map(|a| a.task_id).collect();
let stale: Vec<u32> = self
.per_agent
.keys()
.copied()
.filter(|id| !live_ids.contains(id))
.collect();
for id in stale {
self.forget(id);
}
let mut agent_rows: Vec<(u32, ActivityRow)> = agents
.iter()
.filter_map(|s| {
let status = match s.status {
AgentStatus::Pending => ActivityStatus::Pending,
AgentStatus::Running { .. } => {
if self.cancelling.contains(&s.task_id) {
ActivityStatus::Cancelling
} else {
ActivityStatus::Running
}
}
AgentStatus::Cancelled
| AgentStatus::Completed { .. }
| AgentStatus::Errored { .. } => return None,
};
let activity = self
.per_agent
.get(&s.task_id)
.map(|a| a.description.clone());
Some((
s.task_id,
ActivityRow {
icon: "\u{1f916}", label: s.agent_name.clone(),
age: format_age(s.age),
activity,
status,
},
))
})
.collect();
agent_rows.sort_by_key(|(id, _)| *id);
let mut process_rows: Vec<(u32, ActivityRow)> = processes
.iter()
.filter_map(|p| {
let status = match p.status {
BgProcessStatus::Running => ActivityStatus::Running,
BgProcessStatus::Killed | BgProcessStatus::Exited { .. } => return None,
};
Some((
p.pid,
ActivityRow {
icon: "\u{1f41a}", label: format!("process:{}", p.pid),
age: format_age(p.age),
activity: Some(p.command.clone()),
status,
},
))
})
.collect();
process_rows.sort_by_key(|(pid, _)| *pid);
let mut all: Vec<ActivityRow> = agent_rows.into_iter().map(|(_, r)| r).collect();
all.extend(process_rows.into_iter().map(|(_, r)| r));
let total = all.len();
all.truncate(MAX_VISIBLE);
(all, total)
}
}
fn format_age(d: Duration) -> String {
let secs = d.as_secs();
if secs == 0 {
"<1s".to_string()
} else if secs < 60 {
format!("{secs:>2}s")
} else if secs < 3600 {
let m = secs / 60;
format!("{m:>2}m")
} else {
let h = secs / 3600;
format!("{h:>2}h")
}
}
#[cfg(test)]
mod tests {
use super::*;
use koda_core::engine::event::BgChildActivityKind;
fn agent(task_id: u32, name: &str, age_secs: u64, status: AgentStatus) -> BgTaskSnapshot {
BgTaskSnapshot::for_testing(
task_id,
name.to_string(),
"x".into(),
Duration::from_secs(age_secs),
status,
None,
)
}
fn process(pid: u32, command: &str, age_secs: u64) -> BgProcessSnapshot {
BgProcessSnapshot {
pid,
command: command.to_string(),
age: Duration::from_secs(age_secs),
status: BgProcessStatus::Running,
spawner: None,
}
}
#[test]
fn empty_tracker_with_empty_registry_yields_no_rows() {
let mut t = BgActivityTracker::default();
let (rows, total) = t.build_rows(&[], &[]);
assert!(rows.is_empty());
assert_eq!(total, 0);
}
#[test]
fn running_agent_without_recorded_activity_renders_with_no_activity() {
let mut t = BgActivityTracker::default();
let snap = vec![agent(1, "explore", 5, AgentStatus::Running { iter: 1 })];
let (rows, total) = t.build_rows(&snap, &[]);
assert_eq!(total, 1);
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].label, "explore");
assert!(rows[0].activity.is_none());
assert_eq!(rows[0].status, ActivityStatus::Running);
}
#[test]
fn tool_start_event_populates_activity() {
let mut t = BgActivityTracker::default();
t.record_activity(
1,
&BgChildActivityKind::ToolStart {
tool_name: "Read".into(),
summary: "Read src/auth.rs".into(),
},
);
let snap = vec![agent(1, "explore", 5, AgentStatus::Running { iter: 2 })];
let (rows, _) = t.build_rows(&snap, &[]);
assert_eq!(rows[0].activity.as_deref(), Some("Read src/auth.rs"));
}
#[test]
fn tool_end_success_does_not_overwrite_last_tool_start() {
let mut t = BgActivityTracker::default();
t.record_activity(
1,
&BgChildActivityKind::ToolStart {
tool_name: "Read".into(),
summary: "Read foo.rs".into(),
},
);
t.record_activity(
1,
&BgChildActivityKind::ToolEnd {
tool_name: "Read".into(),
success: true,
},
);
let snap = vec![agent(1, "explore", 5, AgentStatus::Running { iter: 2 })];
let (rows, _) = t.build_rows(&snap, &[]);
assert_eq!(rows[0].activity.as_deref(), Some("Read foo.rs"));
}
#[test]
fn tool_end_failure_surfaces_failure_marker() {
let mut t = BgActivityTracker::default();
t.record_activity(
1,
&BgChildActivityKind::ToolStart {
tool_name: "Bash".into(),
summary: "Bash cargo test".into(),
},
);
t.record_activity(
1,
&BgChildActivityKind::ToolEnd {
tool_name: "Bash".into(),
success: false,
},
);
let snap = vec![agent(1, "verify", 12, AgentStatus::Running { iter: 3 })];
let (rows, _) = t.build_rows(&snap, &[]);
let activity = rows[0].activity.as_deref().unwrap();
assert!(
activity.contains("Bash") && activity.contains('\u{2717}'),
"expected failure marker in activity: {activity:?}"
);
}
#[test]
fn info_event_overrides_previous_activity() {
let mut t = BgActivityTracker::default();
t.record_activity(
1,
&BgChildActivityKind::ToolStart {
tool_name: "Read".into(),
summary: "Read foo.rs".into(),
},
);
t.record_activity(
1,
&BgChildActivityKind::Info {
message: "Cache hit, skipping".into(),
},
);
let snap = vec![agent(1, "explore", 5, AgentStatus::Running { iter: 2 })];
let (rows, _) = t.build_rows(&snap, &[]);
assert_eq!(rows[0].activity.as_deref(), Some("Cache hit, skipping"));
}
#[test]
fn terminal_agents_are_excluded_from_overlay() {
let mut t = BgActivityTracker::default();
let snap = vec![
agent(1, "explore", 5, AgentStatus::Running { iter: 2 }),
agent(
2,
"verify",
7,
AgentStatus::Completed {
summary: "done".into(),
},
),
agent(
3,
"lint",
3,
AgentStatus::Errored {
error: "boom".into(),
},
),
agent(4, "scan", 9, AgentStatus::Cancelled),
];
let (rows, total) = t.build_rows(&snap, &[]);
assert_eq!(total, 1, "only the running agent should count: {rows:?}");
assert_eq!(rows[0].label, "explore");
}
#[test]
fn cancelling_marker_flips_status_color() {
let mut t = BgActivityTracker::default();
t.mark_cancelling(7);
let snap = vec![agent(7, "explore", 5, AgentStatus::Running { iter: 2 })];
let (rows, _) = t.build_rows(&snap, &[]);
assert_eq!(rows[0].status, ActivityStatus::Cancelling);
}
#[test]
fn stale_tracker_entries_pruned_when_registry_drops_task() {
let mut t = BgActivityTracker::default();
t.record_activity(
42,
&BgChildActivityKind::ToolStart {
tool_name: "Read".into(),
summary: "stale".into(),
},
);
t.mark_cancelling(42);
let _ = t.build_rows(&[], &[]);
assert!(!t.per_agent.contains_key(&42));
assert!(!t.cancelling.contains(&42));
}
#[test]
fn agents_sorted_before_processes_in_render_order() {
let mut t = BgActivityTracker::default();
let agents = vec![
agent(2, "verify", 2, AgentStatus::Running { iter: 1 }),
agent(1, "explore", 4, AgentStatus::Running { iter: 1 }),
];
let procs = vec![process(99, "cargo build", 8)];
let (rows, _) = t.build_rows(&agents, &procs);
assert_eq!(rows[0].label, "explore", "task_id=1 should sort first");
assert_eq!(rows[1].label, "verify", "task_id=2 should sort second");
assert!(
rows[2].label.starts_with("process:"),
"processes should render after agents: {:?}",
rows[2].label
);
}
#[test]
fn overflow_total_exceeds_visible_when_more_than_max_visible() {
let mut t = BgActivityTracker::default();
let agents: Vec<_> = (0..(MAX_VISIBLE as u32 + 4))
.map(|i| agent(i, &format!("ag{i}"), 1, AgentStatus::Running { iter: 1 }))
.collect();
let (rows, total) = t.build_rows(&agents, &[]);
assert_eq!(rows.len(), MAX_VISIBLE);
assert_eq!(total, MAX_VISIBLE + 4);
}
#[test]
fn process_row_uses_command_as_activity() {
let mut t = BgActivityTracker::default();
let procs = vec![process(123, "cargo test --release", 4)];
let (rows, _) = t.build_rows(&[], &procs);
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].label, "process:123");
assert_eq!(rows[0].activity.as_deref(), Some("cargo test --release"));
}
#[test]
fn format_age_under_one_second() {
assert_eq!(format_age(Duration::from_millis(500)), "<1s");
}
#[test]
fn format_age_seconds_padded_to_two_digits() {
assert_eq!(format_age(Duration::from_secs(4)), " 4s");
assert_eq!(format_age(Duration::from_secs(42)), "42s");
}
#[test]
fn format_age_minutes_padded() {
assert_eq!(format_age(Duration::from_secs(60)), " 1m");
assert_eq!(format_age(Duration::from_secs(15 * 60)), "15m");
}
#[test]
fn format_age_hours_padded() {
assert_eq!(format_age(Duration::from_secs(3600)), " 1h");
}
}