use crate::scroll_buffer::ScrollBuffer;
use crate::tui_output;
use koda_core::tools::bg_task_tools::TaskId;
use ratatui::style::Modifier;
use ratatui::text::{Line, Span};
use tui_output::{BOLD, CYAN, DIM};
pub(crate) fn handle_list_background_tasks(
buffer: &mut ScrollBuffer,
bg_agents: &koda_core::bg_agent::BgAgentRegistry,
bg_processes: &koda_core::tools::bg_process::BgRegistry,
) {
bg_processes.reap();
let agent_snaps = bg_agents.snapshot();
let process_snaps = bg_processes.snapshot();
tui_output::blank(buffer);
tui_output::emit_line(buffer, Line::styled(" \u{1f43e} Background tasks", BOLD));
tui_output::blank(buffer);
if agent_snaps.is_empty() && process_snaps.is_empty() {
tui_output::dim_msg(buffer, "No background tasks.".into());
tui_output::blank(buffer);
tui_output::dim_msg(
buffer,
"Ask Koda to launch one with `background: true` in InvokeAgent or Bash.".into(),
);
return;
}
let id_strings: Vec<String> = agent_snaps
.iter()
.map(|s| format!("agent:{}", s.task_id))
.chain(process_snaps.iter().map(|s| format!("process:{}", s.pid)))
.collect();
let id_col = id_strings.iter().map(|s| s.len()).max().unwrap_or(8).max(8);
let name_col = agent_snaps
.iter()
.map(|s| s.agent_name.len())
.chain(process_snaps.iter().map(|s| command_head(&s.command).len()))
.max()
.unwrap_or(8)
.max(8);
tui_output::emit_line(
buffer,
Line::from(vec![Span::styled(
format!(
" {:<id_col$} {:<name_col$} {:<6} STATUS",
"ID", "NAME", "AGE"
),
DIM,
)]),
);
for snap in &agent_snaps {
let id = format!("agent:{}", snap.task_id);
let mut spans = vec![Span::raw(format!(
" {:<id_col$} {:<name_col$} {:<6} ",
id,
snap.agent_name,
format_age(snap.age),
))];
spans.extend(agent_status_spans(&snap.status));
tui_output::emit_line(buffer, Line::from(spans));
}
for snap in &process_snaps {
let id = format!("process:{}", snap.pid);
let name = command_head(&snap.command);
let mut spans = vec![Span::raw(format!(
" {:<id_col$} {:<name_col$} {:<6} ",
id,
name,
format_age(snap.age),
))];
spans.extend(process_status_spans(&snap.status));
tui_output::emit_line(buffer, Line::from(spans));
}
tui_output::blank(buffer);
tui_output::dim_msg(
buffer,
"Use `/cancel agent:<id>` or `/cancel process:<id>` to stop one.".into(),
);
}
pub(crate) fn handle_cancel_background_task(
buffer: &mut ScrollBuffer,
bg_agents: &koda_core::bg_agent::BgAgentRegistry,
bg_processes: &koda_core::tools::bg_process::BgRegistry,
task_id: Option<TaskId>,
) {
let Some(id) = task_id else {
tui_output::warn_msg(
buffer,
"Usage: /cancel <agent:id|process:id> (run /agents to see ids)".into(),
);
return;
};
match id {
TaskId::Agent(n) => {
if bg_agents.cancel(n) {
tui_output::ok_msg(
buffer,
format!("Cancellation requested for agent:{n}. Result will inject shortly."),
);
} else {
tui_output::warn_msg(
buffer,
format!(
"No background sub-agent with id agent:{n}. Run /agents to see active tasks."
),
);
}
}
TaskId::Process(n) => {
if bg_processes.kill(n) {
tui_output::ok_msg(
buffer,
format!("SIGTERM sent to process:{n}. It should exit shortly."),
);
} else {
tui_output::warn_msg(
buffer,
format!(
"No background process with id process:{n}. Run /agents to see active tasks."
),
);
}
}
}
}
fn format_age(d: std::time::Duration) -> String {
let secs = d.as_secs();
if secs < 60 {
format!("{secs}s")
} else if secs < 3_600 {
format!("{}m", secs / 60)
} else if secs < 86_400 {
format!("{}h", secs / 3_600)
} else {
format!("{}d", secs / 86_400)
}
}
const COMMAND_HEAD_CHARS: usize = 24;
fn command_head(cmd: &str) -> String {
let trimmed = cmd.trim();
if trimmed.chars().count() <= COMMAND_HEAD_CHARS {
trimmed.to_string()
} else {
let truncated: String = trimmed.chars().take(COMMAND_HEAD_CHARS).collect();
format!("{truncated}\u{2026}")
}
}
fn agent_status_spans(status: &koda_core::bg_agent::AgentStatus) -> Vec<Span<'static>> {
use koda_core::bg_agent::AgentStatus;
match status {
AgentStatus::Pending => vec![Span::styled("\u{25d0} Pending", CYAN)],
AgentStatus::Running { iter } => {
let label = if *iter == 0 {
"\u{25b6} Running".to_string()
} else {
format!("\u{25b6} Running (iter {iter})")
};
vec![Span::styled(label, CYAN.add_modifier(Modifier::BOLD))]
}
AgentStatus::Cancelled => vec![Span::styled("\u{2297} Cancelled", DIM)],
AgentStatus::Completed { summary } => {
let mut spans = vec![Span::styled("\u{2713} Completed", tui_output::GREEN)];
let preview = summary_preview(summary);
if !preview.is_empty() {
spans.push(Span::styled(format!(" \u{2014} {preview}"), DIM));
}
spans
}
AgentStatus::Errored { error } => {
let mut spans = vec![Span::styled("\u{2717} Errored", tui_output::RED)];
let preview = summary_preview(error);
if !preview.is_empty() {
spans.push(Span::styled(format!(" \u{2014} {preview}"), DIM));
}
spans
}
}
}
fn process_status_spans(
status: &koda_core::tools::bg_process::BgProcessStatus,
) -> Vec<Span<'static>> {
use koda_core::tools::bg_process::BgProcessStatus;
match status {
BgProcessStatus::Running => vec![Span::styled(
"\u{25b6} Running",
CYAN.add_modifier(Modifier::BOLD),
)],
BgProcessStatus::Killed => vec![Span::styled("\u{2297} Killed", DIM)],
BgProcessStatus::Exited { code: Some(0) } => {
vec![Span::styled("\u{2713} Exited (0)", tui_output::GREEN)]
}
BgProcessStatus::Exited { code: Some(c) } => vec![Span::styled(
format!("\u{2717} Exited ({c})"),
tui_output::RED,
)],
BgProcessStatus::Exited { code: None } => {
vec![Span::styled("\u{2717} Exited (signal)", tui_output::RED)]
}
}
}
const PREVIEW_CHARS: usize = 60;
fn summary_preview(s: &str) -> String {
let collapsed: String = s.split_whitespace().collect::<Vec<_>>().join(" ");
if collapsed.chars().count() <= PREVIEW_CHARS {
collapsed
} else {
let truncated: String = collapsed.chars().take(PREVIEW_CHARS).collect();
format!("{truncated}\u{2026}")
}
}
#[cfg(test)]
mod tests {
use super::*;
use koda_core::bg_agent::{AgentStatus, BgAgentRegistry, BgPayload};
use koda_core::tools::bg_process::BgRegistry;
use std::time::Duration;
use tokio::sync::{oneshot, watch};
use tokio_util::sync::CancellationToken;
fn buffer_text(buffer: &ScrollBuffer) -> String {
buffer
.all_lines()
.map(|line| {
line.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n")
}
fn register_entry(
reg: &BgAgentRegistry,
agent_name: &str,
prompt: &str,
) -> (
u32,
oneshot::Sender<Result<BgPayload, BgPayload>>,
watch::Sender<AgentStatus>,
CancellationToken,
) {
let parent = CancellationToken::new();
let r = reg.reserve(&parent, None);
let task_id = r.task_id;
let tx = r.tx;
let status_tx = r.status_tx;
let observer = r.cancel.clone();
let noop = tokio::spawn(async {});
reg.attach(
task_id,
agent_name,
prompt,
r.rx,
r.cancel,
r.status_rx,
None,
None,
noop,
);
(task_id, tx, status_tx, observer)
}
fn spawn_sleep_in_registry(reg: &BgRegistry) -> u32 {
let mut cmd = tokio::process::Command::new("sleep");
cmd.arg("60");
let child = cmd.spawn().expect("spawn sleep");
let pid = child.id().expect("sleep should have a pid before exit");
reg.insert(pid, "sleep 60".to_string(), child, None);
pid
}
#[test]
fn format_age_seconds_under_one_minute() {
assert_eq!(format_age(Duration::from_secs(0)), "0s");
assert_eq!(format_age(Duration::from_secs(1)), "1s");
assert_eq!(format_age(Duration::from_secs(59)), "59s");
}
#[test]
fn format_age_minutes_round_down() {
assert_eq!(format_age(Duration::from_secs(60)), "1m");
assert_eq!(format_age(Duration::from_secs(119)), "1m");
assert_eq!(format_age(Duration::from_secs(3_599)), "59m");
}
#[test]
fn format_age_hours_round_down() {
assert_eq!(format_age(Duration::from_secs(3_600)), "1h");
assert_eq!(format_age(Duration::from_secs(86_399)), "23h");
}
#[test]
fn format_age_days_round_down() {
assert_eq!(format_age(Duration::from_secs(86_400)), "1d");
assert_eq!(format_age(Duration::from_secs(172_799)), "1d");
assert_eq!(format_age(Duration::from_secs(259_200)), "3d");
}
#[test]
fn command_head_short_passes_through() {
assert_eq!(command_head("cargo test"), "cargo test");
assert_eq!(command_head(" ls -la "), "ls -la");
}
#[test]
fn command_head_long_truncates_with_ellipsis() {
let long = "cargo test --workspace --features=foo,bar -- --nocapture | tee log.txt";
let head = command_head(long);
assert_eq!(head.chars().count(), COMMAND_HEAD_CHARS + 1);
assert!(head.ends_with('\u{2026}'));
assert!(head.starts_with("cargo test"));
}
#[test]
fn summary_preview_collapses_whitespace() {
assert_eq!(
summary_preview("line one\nline two\tline three"),
"line one line two line three"
);
}
#[test]
fn summary_preview_truncates_with_ellipsis() {
let long = "a".repeat(PREVIEW_CHARS + 10);
let preview = summary_preview(&long);
assert_eq!(preview.chars().count(), PREVIEW_CHARS + 1);
assert!(preview.ends_with('\u{2026}'));
}
#[test]
fn summary_preview_short_passes_through() {
assert_eq!(summary_preview("all good"), "all good");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn list_background_tasks_empty_renders_explicit_message() {
let mut buf = ScrollBuffer::new(64);
let reg = BgAgentRegistry::new();
let procs = BgRegistry::new();
handle_list_background_tasks(&mut buf, ®, &procs);
let text = buffer_text(&buf);
assert!(
text.contains("No background tasks."),
"empty registries should render explicit empty-state line, got: {text}"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn list_background_tasks_renders_each_pending_agent_with_prefix() {
let mut buf = ScrollBuffer::new(64);
let reg = BgAgentRegistry::new();
let procs = BgRegistry::new();
let (id_a, _tx_a, _stx_a, _cancel_a) = register_entry(®, "explore", "map repo");
let (id_b, _tx_b, _stx_b, _cancel_b) = register_entry(®, "verify", "check tests");
handle_list_background_tasks(&mut buf, ®, &procs);
let text = buffer_text(&buf);
assert!(text.contains(&format!("agent:{id_a}")));
assert!(text.contains(&format!("agent:{id_b}")));
assert!(text.contains("explore"));
assert!(text.contains("verify"));
assert!(
text.contains("Pending"),
"Pending status label missing, got: {text}"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn list_background_tasks_reflects_running_status() {
let mut buf = ScrollBuffer::new(64);
let reg = BgAgentRegistry::new();
let procs = BgRegistry::new();
let (_id, _tx, status_tx, _cancel) = register_entry(®, "explore", "x");
status_tx.send(AgentStatus::Running { iter: 7 }).unwrap();
handle_list_background_tasks(&mut buf, ®, &procs);
let text = buffer_text(&buf);
assert!(
text.contains("Running"),
"expected 'Running' label, got: {text}"
);
assert!(
text.contains("iter 7"),
"expected per-iter detail when iter > 0, got: {text}"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn list_background_tasks_hides_iter_zero_placeholder() {
let mut buf = ScrollBuffer::new(64);
let reg = BgAgentRegistry::new();
let procs = BgRegistry::new();
let (_id, _tx, status_tx, _cancel) = register_entry(®, "explore", "x");
status_tx.send(AgentStatus::Running { iter: 0 }).unwrap();
handle_list_background_tasks(&mut buf, ®, &procs);
let text = buffer_text(&buf);
assert!(text.contains("Running"));
assert!(
!text.contains("iter 0"),
"iter 0 should not render the per-iter detail (it's a Layer-0 placeholder), got: {text}"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn list_background_tasks_renders_processes_with_prefix() {
let mut buf = ScrollBuffer::new(64);
let reg = BgAgentRegistry::new();
let procs = BgRegistry::new();
let pid = spawn_sleep_in_registry(&procs);
handle_list_background_tasks(&mut buf, ®, &procs);
let text = buffer_text(&buf);
assert!(
text.contains(&format!("process:{pid}")),
"process row missing or unprefixed, got: {text}"
);
assert!(
text.contains("sleep"),
"expected command head 'sleep' in row, got: {text}"
);
assert!(
text.contains("Running"),
"freshly-spawned process should report Running, got: {text}"
);
procs.kill(pid);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn list_background_tasks_unifies_both_registries() {
let mut buf = ScrollBuffer::new(64);
let reg = BgAgentRegistry::new();
let procs = BgRegistry::new();
let (agent_id, _tx, _stx, _c) = register_entry(®, "explore", "x");
let pid = spawn_sleep_in_registry(&procs);
handle_list_background_tasks(&mut buf, ®, &procs);
let text = buffer_text(&buf);
assert!(text.contains(&format!("agent:{agent_id}")));
assert!(text.contains(&format!("process:{pid}")));
procs.kill(pid);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn cancel_known_agent_id_reports_success_and_fires_token() {
let mut buf = ScrollBuffer::new(64);
let reg = BgAgentRegistry::new();
let procs = BgRegistry::new();
let (task_id, _tx, _status_tx, observer) = register_entry(®, "explore", "x");
handle_cancel_background_task(&mut buf, ®, &procs, Some(TaskId::Agent(task_id)));
let text = buffer_text(&buf);
assert!(
text.contains(&format!("agent:{task_id}")),
"success message should mention the prefixed id, got: {text}"
);
assert!(
observer.is_cancelled(),
"the cancel token should have been fired"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn cancel_known_process_id_kills_and_reports_success() {
let mut buf = ScrollBuffer::new(64);
let reg = BgAgentRegistry::new();
let procs = BgRegistry::new();
let pid = spawn_sleep_in_registry(&procs);
handle_cancel_background_task(&mut buf, ®, &procs, Some(TaskId::Process(pid)));
let text = buffer_text(&buf);
assert!(
text.contains(&format!("process:{pid}")),
"success message should mention the prefixed id, got: {text}"
);
assert!(
text.to_lowercase().contains("sigterm") || text.to_lowercase().contains("exit"),
"expected SIGTERM acknowledgement, got: {text}"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn cancel_unknown_agent_id_reports_helpful_error() {
let mut buf = ScrollBuffer::new(64);
let reg = BgAgentRegistry::new();
let procs = BgRegistry::new();
handle_cancel_background_task(&mut buf, ®, &procs, Some(TaskId::Agent(999)));
let text = buffer_text(&buf);
assert!(
text.contains("agent:999") && text.contains("/agents"),
"warn should name the missing prefixed id and point to /agents, got: {text}"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn cancel_unknown_process_id_reports_helpful_error() {
let mut buf = ScrollBuffer::new(64);
let reg = BgAgentRegistry::new();
let procs = BgRegistry::new();
handle_cancel_background_task(&mut buf, ®, &procs, Some(TaskId::Process(999_999)));
let text = buffer_text(&buf);
assert!(
text.contains("process:999999") && text.contains("/agents"),
"warn should name the missing prefixed id and point to /agents, got: {text}"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn cancel_none_id_renders_usage_with_both_prefixes() {
let mut buf = ScrollBuffer::new(64);
let reg = BgAgentRegistry::new();
let procs = BgRegistry::new();
handle_cancel_background_task(&mut buf, ®, &procs, None);
let text = buffer_text(&buf);
assert!(
text.contains("Usage:") && text.contains("agent:") && text.contains("process:"),
"None id should render a Usage: line with both prefixes, got: {text}"
);
}
}