use std::collections::HashSet;
use ratatui::{
buffer::Buffer,
layout::Rect,
prelude::Widget,
style::Style,
text::{Line, Span},
widgets::Block,
};
use unicode_width::UnicodeWidthStr;
use crate::localization::truncate_to_width;
use crate::tui::app::{App, TaskPanelEntryKind};
const ITEM_LABEL_MAX_WIDTH: usize = 20;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PendingItemKind {
Shell,
Task,
Agent,
}
impl PendingItemKind {
#[must_use]
pub fn noun(self) -> &'static str {
match self {
Self::Shell => "shell",
Self::Task => "task",
Self::Agent => "agent",
}
}
fn plural_noun(self, count: usize) -> String {
if count == 1 {
self.noun().to_string()
} else {
format!("{}s", self.noun())
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PendingItem {
pub kind: PendingItemKind,
pub label: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct PendingWork {
pub items: Vec<PendingItem>,
}
impl PendingWork {
#[must_use]
pub fn is_empty(&self) -> bool {
self.items.is_empty()
}
#[must_use]
pub fn count(&self, kind: PendingItemKind) -> usize {
self.items.iter().filter(|item| item.kind == kind).count()
}
#[must_use]
pub fn render_line(&self, width: usize) -> Option<String> {
if self.items.is_empty() {
return None;
}
if width == 0 {
return Some(String::new());
}
let mut counts: Vec<String> = Vec::new();
for kind in [
PendingItemKind::Shell,
PendingItemKind::Task,
PendingItemKind::Agent,
] {
let n = self.count(kind);
if n > 0 {
counts.push(format!("{n} {}", kind.plural_noun(n)));
}
}
let labels = self
.items
.iter()
.map(|item| item.label.as_str())
.collect::<Vec<_>>()
.join(" · ");
let line = format!("⏳ {} — {}", counts.join(" · "), labels);
Some(truncate_to_width(&line, width))
}
}
fn truncate_label(label: &str) -> String {
let trimmed = label.trim();
if trimmed.is_empty() {
"…".to_string()
} else {
truncate_to_width(trimmed, ITEM_LABEL_MAX_WIDTH)
}
}
#[must_use]
pub fn pending_work_from_app(app: &App) -> PendingWork {
let mut items: Vec<PendingItem> = Vec::new();
for entry in &app.task_panel {
if entry.kind != TaskPanelEntryKind::Background {
continue;
}
if !matches!(entry.status.as_str(), "running" | "queued") {
continue;
}
let (kind, raw_label) = match entry.prompt_summary.strip_prefix("shell: ") {
Some(command) => (PendingItemKind::Shell, command),
None => (PendingItemKind::Task, entry.id.as_str()),
};
items.push(PendingItem {
kind,
label: truncate_label(raw_label),
});
}
let mut seen: HashSet<&str> = HashSet::new();
for agent in app.subagent_cache.iter().filter(|agent| {
matches!(
agent.status,
crate::tools::subagent::SubAgentStatus::Running
)
}) {
if !seen.insert(agent.agent_id.as_str()) {
continue;
}
let role = agent
.assignment
.role
.as_deref()
.filter(|role| !role.trim().is_empty())
.unwrap_or_else(|| agent.agent_type.as_str());
let name = crate::tui::sidebar::dispatched_agent_name(agent)
.or_else(|| {
agent
.nickname
.as_deref()
.filter(|name| !name.trim().is_empty() && *name != agent.agent_id)
})
.or_else(|| app.agent_label_map.get(&agent.agent_id).map(String::as_str));
let label = match name {
Some(name) if name != role => format!("{name}·{role}"),
_ => role.to_string(),
};
items.push(PendingItem {
kind: PendingItemKind::Agent,
label: truncate_label(&label),
});
}
for id in app.agent_progress.keys() {
if !seen.insert(id.as_str()) {
continue;
}
let label = app.agent_display_label(id);
items.push(PendingItem {
kind: PendingItemKind::Agent,
label: truncate_label(&label),
});
}
PendingWork { items }
}
pub fn render(area: Rect, buf: &mut Buffer, app: &App, work: &PendingWork) {
if area.width == 0 || area.height == 0 || work.is_empty() {
return;
}
let Some(line) = work.render_line(usize::from(area.width)) else {
return;
};
Block::default()
.style(Style::default().bg(app.ui_theme.surface_bg))
.render(area, buf);
let marker = Span::styled(
"⏳",
Style::default()
.fg(app.ui_theme.status_warning)
.add_modifier(ratatui::style::Modifier::BOLD),
);
let body = Span::styled(line, Style::default().fg(app.ui_theme.text_muted));
let width = usize::from(area.width);
let mut spans = vec![marker, Span::raw(" ")];
let body_width = body.content.width();
if body_width > width.saturating_sub(2) {
Line::from(vec![body]).render(area, buf);
return;
}
spans.push(body);
Line::from(spans).render(area, buf);
}
#[cfg(test)]
mod tests {
use super::*;
fn shell(label: &str) -> PendingItem {
PendingItem {
kind: PendingItemKind::Shell,
label: truncate_label(label),
}
}
fn task(label: &str) -> PendingItem {
PendingItem {
kind: PendingItemKind::Task,
label: truncate_label(label),
}
}
fn agent(label: &str) -> PendingItem {
PendingItem {
kind: PendingItemKind::Agent,
label: truncate_label(label),
}
}
#[test]
fn empty_pending_work_hides_the_indicator() {
let work = PendingWork::default();
assert!(work.is_empty());
assert_eq!(work.render_line(80), None, "empty -> no line, no row");
assert_eq!(work.count(PendingItemKind::Shell), 0);
assert_eq!(work.count(PendingItemKind::Task), 0);
assert_eq!(work.count(PendingItemKind::Agent), 0);
}
#[test]
fn counts_cover_shells_tasks_and_agents_with_pluralization() {
let work = PendingWork {
items: vec![shell("cargo test"), task("run"), agent("Agent 3·scout")],
};
assert!(!work.is_empty());
assert_eq!(work.count(PendingItemKind::Shell), 1);
assert_eq!(work.count(PendingItemKind::Task), 1);
assert_eq!(work.count(PendingItemKind::Agent), 1);
let line = work.render_line(200).expect("pending work renders");
assert!(line.contains("1 shell"), "got: {line}");
assert!(line.contains("1 task"), "got: {line}");
assert!(line.contains("1 agent"), "got: {line}");
assert!(line.contains("cargo test"), "got: {line}");
assert!(line.contains("run"), "got: {line}");
assert!(line.contains("Agent 3·scout"), "got: {line}");
}
#[test]
fn plural_counts_for_multiple_same_kind_items() {
let work = PendingWork {
items: vec![shell("one"), shell("two"), agent("A")],
};
let line = work.render_line(200).unwrap();
assert!(line.contains("2 shells"), "got: {line}");
assert!(line.contains("1 agent"), "got: {line}");
}
#[test]
fn completion_clears_the_line() {
let mut work = PendingWork {
items: vec![shell("cargo test")],
};
assert!(work.render_line(80).is_some());
work.items.clear();
assert!(work.is_empty());
assert_eq!(work.render_line(80), None, "completion -> clears");
}
#[test]
fn narrow_width_truncates_but_keeps_prefix() {
let work = PendingWork {
items: vec![shell("cargo test"), agent("Agent 3·scout")],
};
let line = work.render_line(24).unwrap();
assert!(line.starts_with("⏳"), "marker survives: got {line}");
assert!(line.width() <= 24, "line fits budget: got {line}");
}
#[test]
fn long_labels_are_pre_truncated() {
let long = "cargo test -p codewhale-tui --lib background_indicator -- --exact this is long";
let work = PendingWork {
items: vec![shell(long)],
};
let line = work.render_line(400).unwrap();
assert!(
line.contains('…'),
"over-long command ellipsized: got {line}"
);
assert!(
work.items[0].label.width() <= ITEM_LABEL_MAX_WIDTH,
"label capped at {ITEM_LABEL_MAX_WIDTH}: got {}",
work.items[0].label
);
}
#[test]
fn pending_work_from_app_skips_non_background_entries() {
let options = crate::test_support::test_tui_options(std::path::PathBuf::from("."));
let app = crate::test_support::test_app_with_options(options);
assert!(
pending_work_from_app(&app).is_empty(),
"bare app has no pending background work"
);
}
#[test]
fn pending_work_from_app_picks_up_shell_and_agent_entries() {
use crate::tui::app::TaskPanelEntry;
let options = crate::test_support::test_tui_options(std::path::PathBuf::from("."));
let mut app = crate::test_support::test_app_with_options(options);
app.task_panel.push(TaskPanelEntry {
id: "shell_a1b2c3d4".to_string(),
status: "running".to_string(),
prompt_summary: "shell: cargo test -p codewhale-tui".to_string(),
duration_ms: Some(42_000),
kind: TaskPanelEntryKind::Background,
stale: true,
elapsed_since_output_ms: Some(99_000),
owner_agent_id: None,
owner_agent_name: None,
current_tool: None,
role: None,
files_touched: 0,
});
app.task_panel.push(TaskPanelEntry {
id: "run".to_string(),
status: "running".to_string(),
prompt_summary: "background confirmation test".to_string(),
duration_ms: Some(99_000),
kind: TaskPanelEntryKind::Background,
stale: false,
elapsed_since_output_ms: None,
owner_agent_id: None,
owner_agent_name: None,
current_tool: None,
role: None,
files_touched: 0,
});
app.agent_progress
.insert("agent_live".to_string(), "checking the build".to_string());
app.agent_label_map
.insert("agent_live".to_string(), "Agent 1".to_string());
let work = pending_work_from_app(&app);
assert_eq!(work.count(PendingItemKind::Shell), 1, "shell job counted");
assert_eq!(work.count(PendingItemKind::Task), 1, "durable task counted");
assert_eq!(
work.count(PendingItemKind::Agent),
1,
"running agent counted"
);
let line = work.render_line(400).unwrap();
assert!(line.contains("cargo test"), "shell command labeled: {line}");
assert!(line.contains("run"), "task id labeled: {line}");
assert!(line.contains("Agent 1"), "agent label shown: {line}");
app.task_panel.clear();
app.agent_progress.clear();
let cleared = pending_work_from_app(&app);
assert!(cleared.is_empty(), "completion clears the indicator");
}
#[test]
fn pending_agents_are_labelled_by_the_name_they_were_dispatched_under() {
use crate::tools::subagent::{
FleetRole, SubAgentAssignment, SubAgentResult, SubAgentStatus,
};
let options = crate::test_support::test_tui_options(std::path::PathBuf::from("."));
let mut app = crate::test_support::test_app_with_options(options);
let running = |agent_id: &str, name: &str| SubAgentResult {
name: name.to_string(),
agent_id: agent_id.to_string(),
context_mode: "fresh".to_string(),
fork_context: false,
workspace: None,
git_branch: None,
agent_type: FleetRole::Worker,
assignment: SubAgentAssignment {
objective: "sweep the lane".to_string(),
role: Some("builder".to_string()),
},
model: "test-model".to_string(),
nickname: Some("Blue Whale".to_string()),
status: SubAgentStatus::Running,
worker_status: None,
runtime_permissions: None,
parent_run_id: None,
spawn_depth: 0,
child_route: None,
result: None,
steps_taken: 1,
checkpoint: None,
needs_input: None,
duration_ms: 100,
started_at: None,
from_prior_session: false,
};
app.subagent_cache
.push(running("agent_named_lane", "triage"));
app.subagent_cache
.push(running("agent_plain_lane", "agent_plain_lane"));
let work = pending_work_from_app(&app);
let labels: Vec<&str> = work.items.iter().map(|item| item.label.as_str()).collect();
assert_eq!(labels, ["triage·builder", "Blue Whale·builder"]);
}
}