| 1 | //! Sub-agent and background-task routing helpers for the TUI loop. |
| 2 | |
| 3 | use std::time::Instant; |
| 4 | |
| 5 | use crate::task_manager::{TaskRecord, TaskStatus, TaskSummary}; |
| 6 | use crate::tools::subagent::{MailboxMessage, SubAgentResult, SubAgentStatus}; |
| 7 | use crate::tui::app::{App, AppMode, TaskPanelEntry}; |
| 8 | use crate::tui::history::{HistoryCell, SubAgentCell, summarize_tool_output}; |
| 9 | use crate::tui::pager::PagerView; |
| 10 | use crate::tui::widgets::agent_card::{ |
| 11 | AgentLifecycle, DelegateCard, FanoutCard, apply_to_delegate, apply_to_fanout, |
| 12 | }; |
| 13 | |
| 14 | pub(super) fn running_agent_count(app: &App) -> usize { |
| 15 | let mut ids: std::collections::HashSet<&str> = |
| 16 | app.agent_progress.keys().map(String::as_str).collect(); |
| 17 | for agent in app |
| 18 | .subagent_cache |
| 19 | .iter() |
| 20 | .filter(|agent| matches!(agent.status, SubAgentStatus::Running)) |
| 21 | { |
| 22 | ids.insert(agent.agent_id.as_str()); |
| 23 | } |
| 24 | ids.len() |
| 25 | } |
| 26 | |
| 27 | pub(super) fn active_fanout_counts(app: &App) -> Option<(usize, usize)> { |
| 28 | // Read running count from the canonical slot states on the active |
| 29 | // FanoutCard, if one exists. Used by `rlm` and any future multi-child |
| 30 | // dispatch the parent agent makes via repeated `agent_spawn`. |
| 31 | if let Some(idx) = app.last_fanout_card_index |
| 32 | && let Some(HistoryCell::SubAgent(SubAgentCell::Fanout(card))) = app.history.get(idx) |
| 33 | { |
| 34 | let running = card |
| 35 | .workers |
| 36 | .iter() |
| 37 | .filter(|slot| matches!(slot.status, AgentLifecycle::Running)) |
| 38 | .count(); |
| 39 | return Some((running, card.worker_count())); |
| 40 | } |
| 41 | None |
| 42 | } |
| 43 | |
| 44 | pub(super) fn reconcile_subagent_activity_state(app: &mut App) { |
| 45 | let running_agents: Vec<(String, String)> = app |
| 46 | .subagent_cache |
| 47 | .iter() |
| 48 | .filter(|agent| matches!(agent.status, SubAgentStatus::Running)) |
| 49 | .map(|agent| { |
| 50 | ( |
| 51 | agent.agent_id.clone(), |
| 52 | summarize_tool_output(&agent.assignment.objective), |
| 53 | ) |
| 54 | }) |
| 55 | .collect(); |
| 56 | |
| 57 | let running_ids: std::collections::HashSet<String> = |
| 58 | running_agents.iter().map(|(id, _)| id.clone()).collect(); |
| 59 | app.agent_progress |
| 60 | .retain(|id, _| running_ids.contains(id.as_str())); |
| 61 | for (id, objective) in running_agents { |
| 62 | app.agent_progress.entry(id).or_insert(objective); |
| 63 | } |
| 64 | |
| 65 | if running_ids.is_empty() { |
| 66 | app.agent_activity_started_at = None; |
| 67 | } else if app.agent_activity_started_at.is_none() { |
| 68 | app.agent_activity_started_at = Some(Instant::now()); |
| 69 | } |
| 70 | } |
| 71 | |
| 72 | fn subagent_status_rank(status: &SubAgentStatus) -> u8 { |
| 73 | match status { |
| 74 | SubAgentStatus::Running => 0, |
| 75 | SubAgentStatus::Interrupted(_) => 1, |
| 76 | SubAgentStatus::Failed(_) => 2, |
| 77 | SubAgentStatus::Completed => 3, |
| 78 | SubAgentStatus::Cancelled => 4, |
| 79 | } |
| 80 | } |
| 81 | |
| 82 | pub(super) fn sort_subagents_in_place(agents: &mut [SubAgentResult]) { |
| 83 | agents.sort_by(|a, b| { |
| 84 | subagent_status_rank(&a.status) |
| 85 | .cmp(&subagent_status_rank(&b.status)) |
| 86 | .then_with(|| a.agent_type.as_str().cmp(b.agent_type.as_str())) |
| 87 | .then_with(|| a.agent_id.cmp(&b.agent_id)) |
| 88 | }); |
| 89 | } |
| 90 | |
| 91 | /// Route a `MailboxMessage` envelope to the matching in-transcript card, |
| 92 | /// allocating a `DelegateCard` or `FanoutCard` on first sight (issue #128). |
| 93 | pub(super) fn handle_subagent_mailbox(app: &mut App, seq: u64, message: &MailboxMessage) { |
| 94 | // Accumulate sub-agent token costs for the real-time footer counter (#166). |
| 95 | if let MailboxMessage::TokenUsage { model, usage, .. } = message { |
| 96 | if app.session.subagent_cost_event_seqs.insert(seq) |
| 97 | && let Some(cost) = |
| 98 | crate::pricing::calculate_turn_cost_estimate_from_usage(model, usage) |
| 99 | { |
| 100 | app.accrue_subagent_cost_estimate(cost); |
| 101 | } |
| 102 | return; // No card visual change needed; the footer handles display. |
| 103 | } |
| 104 | |
| 105 | // Resolve (or allocate) the target cell for this envelope. ChildSpawned |
| 106 | // is special — it always belongs to the active fanout card if one |
| 107 | // exists; otherwise it seeds a new one. |
| 108 | let agent_id = message.agent_id().to_string(); |
| 109 | |
| 110 | if matches!(message, MailboxMessage::ChildSpawned { .. }) |
| 111 | && let Some(idx) = app.last_fanout_card_index |
| 112 | && let Some(HistoryCell::SubAgent(SubAgentCell::Fanout(card))) = app.history.get_mut(idx) |
| 113 | { |
| 114 | apply_to_fanout(card, message); |
| 115 | app.subagent_card_index.insert(agent_id, idx); |
| 116 | app.mark_history_updated(); |
| 117 | return; |
| 118 | } |
| 119 | |
| 120 | // Existing card for this agent_id? Mutate in place. |
| 121 | if let Some(&idx) = app.subagent_card_index.get(&agent_id) { |
| 122 | let updated = match app.history.get_mut(idx) { |
| 123 | Some(HistoryCell::SubAgent(SubAgentCell::Delegate(card))) => { |
| 124 | apply_to_delegate(card, message) |
| 125 | } |
| 126 | Some(HistoryCell::SubAgent(SubAgentCell::Fanout(card))) => { |
| 127 | apply_to_fanout(card, message) |
| 128 | } |
| 129 | _ => false, |
| 130 | }; |
| 131 | if updated { |
| 132 | app.mark_history_updated(); |
| 133 | } |
| 134 | return; |
| 135 | } |
| 136 | |
| 137 | // No existing card — only `Started` reasonably opens one. Anything else |
| 138 | // for an unknown agent_id is dropped (likely arrived after the cell was |
| 139 | // cleared, e.g. session-resume edge cases). |
| 140 | let MailboxMessage::Started { agent_type, .. } = message else { |
| 141 | return; |
| 142 | }; |
| 143 | |
| 144 | let dispatch_kind = app.pending_subagent_dispatch.as_deref(); |
| 145 | let is_fanout = matches!(dispatch_kind, Some("rlm")); |
| 146 | |
| 147 | if is_fanout { |
| 148 | // Reuse the active fanout card for sibling spawns; otherwise create |
| 149 | // one anchored at this position so subsequent siblings join it. |
| 150 | if let Some(idx) = app.last_fanout_card_index |
| 151 | && let Some(HistoryCell::SubAgent(SubAgentCell::Fanout(card))) = |
| 152 | app.history.get_mut(idx) |
| 153 | { |
| 154 | card.claim_pending_worker(&agent_id, AgentLifecycle::Running); |
| 155 | app.subagent_card_index.insert(agent_id, idx); |
| 156 | } else { |
| 157 | let mut card = FanoutCard::new(dispatch_kind.unwrap_or("rlm").to_string()); |
| 158 | card.upsert_worker(&agent_id, AgentLifecycle::Running); |
| 159 | app.add_message(HistoryCell::SubAgent(SubAgentCell::Fanout(card))); |
| 160 | let idx = app.history.len().saturating_sub(1); |
| 161 | app.last_fanout_card_index = Some(idx); |
| 162 | app.subagent_card_index.insert(agent_id, idx); |
| 163 | } |
| 164 | } else { |
| 165 | let card = DelegateCard::new(agent_id.clone(), agent_type.clone()); |
| 166 | app.add_message(HistoryCell::SubAgent(SubAgentCell::Delegate(card))); |
| 167 | let idx = app.history.len().saturating_sub(1); |
| 168 | app.subagent_card_index.insert(agent_id, idx); |
| 169 | // Single delegate consumes the pending dispatch label so a follow-on |
| 170 | // tool call doesn't accidentally inherit it. |
| 171 | app.pending_subagent_dispatch = None; |
| 172 | } |
| 173 | |
| 174 | app.mark_history_updated(); |
| 175 | } |
| 176 | |
| 177 | pub(super) fn task_mode_label(mode: AppMode) -> &'static str { |
| 178 | mode.as_setting() |
| 179 | } |
| 180 | |
| 181 | pub(super) fn task_summary_to_panel_entry(summary: TaskSummary) -> TaskPanelEntry { |
| 182 | TaskPanelEntry { |
| 183 | id: summary.id, |
| 184 | status: task_status_label(summary.status).to_string(), |
| 185 | prompt_summary: summary.prompt_summary, |
| 186 | duration_ms: summary.duration_ms, |
| 187 | } |
| 188 | } |
| 189 | |
| 190 | fn task_status_label(status: TaskStatus) -> &'static str { |
| 191 | match status { |
| 192 | TaskStatus::Queued => "queued", |
| 193 | TaskStatus::Running => "running", |
| 194 | TaskStatus::Completed => "completed", |
| 195 | TaskStatus::Failed => "failed", |
| 196 | TaskStatus::Canceled => "canceled", |
| 197 | } |
| 198 | } |
| 199 | |
| 200 | pub(super) fn format_task_list(tasks: &[TaskSummary]) -> String { |
| 201 | if tasks.is_empty() { |
| 202 | return "No tasks found.".to_string(); |
| 203 | } |
| 204 | |
| 205 | let mut lines = vec![ |
| 206 | format!("Tasks ({})", tasks.len()), |
| 207 | "----------------------------------------".to_string(), |
| 208 | ]; |
| 209 | for task in tasks { |
| 210 | let duration = task |
| 211 | .duration_ms |
| 212 | .map(|ms| format!("{:.2}s", ms as f64 / 1000.0)) |
| 213 | .unwrap_or_else(|| "-".to_string()); |
| 214 | lines.push(format!( |
| 215 | "{} {:9} {} {}", |
| 216 | task.id, |
| 217 | task_status_label(task.status), |
| 218 | duration, |
| 219 | task.prompt_summary |
| 220 | )); |
| 221 | } |
| 222 | lines.push("Use /task show <id> for timeline details.".to_string()); |
| 223 | lines.join("\n") |
| 224 | } |
| 225 | |
| 226 | pub(super) fn open_task_pager(app: &mut App, task: &TaskRecord) { |
| 227 | let width = app |
| 228 | .viewport |
| 229 | .last_transcript_area |
| 230 | .map(|area| area.width) |
| 231 | .unwrap_or(100) |
| 232 | .saturating_sub(4); |
| 233 | app.view_stack.push(PagerView::from_text( |
| 234 | format!("Task {}", task.id), |
| 235 | &format_task_detail(task), |
| 236 | width.max(60), |
| 237 | )); |
| 238 | } |
| 239 | |
| 240 | fn format_task_detail(task: &TaskRecord) -> String { |
| 241 | let mut lines = Vec::new(); |
| 242 | lines.push(format!("Task: {}", task.id)); |
| 243 | lines.push(format!("Status: {}", task_status_label(task.status))); |
| 244 | lines.push(format!("Mode: {}", task.mode)); |
| 245 | lines.push(format!("Model: {}", task.model)); |
| 246 | lines.push(format!( |
| 247 | "Workspace: {}", |
| 248 | crate::utils::display_path(&task.workspace) |
| 249 | )); |
| 250 | if let Some(thread_id) = task.thread_id.as_ref() { |
| 251 | lines.push(format!("Runtime Thread: {thread_id}")); |
| 252 | } |
| 253 | if let Some(turn_id) = task.turn_id.as_ref() { |
| 254 | lines.push(format!("Runtime Turn: {turn_id}")); |
| 255 | } |
| 256 | if task.runtime_event_count > 0 { |
| 257 | lines.push(format!("Runtime Events: {}", task.runtime_event_count)); |
| 258 | } |
| 259 | lines.push(format!("Created: {}", task.created_at)); |
| 260 | if let Some(started_at) = task.started_at { |
| 261 | lines.push(format!("Started: {}", started_at)); |
| 262 | } |
| 263 | if let Some(ended_at) = task.ended_at { |
| 264 | lines.push(format!("Ended: {}", ended_at)); |
| 265 | } |
| 266 | if let Some(duration) = task.duration_ms { |
| 267 | lines.push(format!("Duration: {:.2}s", duration as f64 / 1000.0)); |
| 268 | } |
| 269 | lines.push(String::new()); |
| 270 | lines.push("Prompt:".to_string()); |
| 271 | lines.push(task.prompt.clone()); |
| 272 | |
| 273 | if let Some(summary) = task.result_summary.as_ref() { |
| 274 | lines.push(String::new()); |
| 275 | lines.push("Result Summary:".to_string()); |
| 276 | lines.push(summary.clone()); |
| 277 | } |
| 278 | if let Some(path) = task.result_detail_path.as_ref() { |
| 279 | lines.push(format!("Result Artifact: {}", path.display())); |
| 280 | } |
| 281 | if let Some(error) = task.error.as_ref() { |
| 282 | lines.push(String::new()); |
| 283 | lines.push(format!("Error: {error}")); |
| 284 | } |
| 285 | |
| 286 | lines.push(String::new()); |
| 287 | lines.push("Tool Calls:".to_string()); |
| 288 | if task.tool_calls.is_empty() { |
| 289 | lines.push("- (none)".to_string()); |
| 290 | } else { |
| 291 | for tool in &task.tool_calls { |
| 292 | let status = match tool.status { |
| 293 | crate::task_manager::TaskToolStatus::Running => "running", |
| 294 | crate::task_manager::TaskToolStatus::Success => "success", |
| 295 | crate::task_manager::TaskToolStatus::Failed => "failed", |
| 296 | crate::task_manager::TaskToolStatus::Canceled => "canceled", |
| 297 | }; |
| 298 | let mut line = format!( |
| 299 | "- {} [{}] {}", |
| 300 | tool.name, |
| 301 | status, |
| 302 | tool.output_summary.as_deref().unwrap_or("(no summary)") |
| 303 | ); |
| 304 | if let Some(duration) = tool.duration_ms { |
| 305 | line.push_str(&format!(" ({:.2}s)", duration as f64 / 1000.0)); |
| 306 | } |
| 307 | lines.push(line); |
| 308 | if let Some(path) = tool.detail_path.as_ref() { |
| 309 | lines.push(format!(" detail: {}", path.display())); |
| 310 | } |
| 311 | if let Some(path) = tool.patch_ref.as_ref() { |
| 312 | lines.push(format!(" patch: {}", path.display())); |
| 313 | } |
| 314 | } |
| 315 | } |
| 316 | |
| 317 | lines.push(String::new()); |
| 318 | lines.push("Timeline:".to_string()); |
| 319 | if task.timeline.is_empty() { |
| 320 | lines.push("- (none)".to_string()); |
| 321 | } else { |
| 322 | for entry in &task.timeline { |
| 323 | lines.push(format!( |
| 324 | "- [{}] {}: {}", |
| 325 | entry.timestamp, entry.kind, entry.summary |
| 326 | )); |
| 327 | if let Some(path) = entry.detail_path.as_ref() { |
| 328 | lines.push(format!(" detail: {}", path.display())); |
| 329 | } |
| 330 | } |
| 331 | } |
| 332 | |
| 333 | lines.join("\n") |
| 334 | } |
| 335 |