use std::time::Instant;
use crate::task_manager::{TaskRecord, TaskStatus, TaskSummary};
use crate::tools::spec::{ToolError, ToolResult};
use crate::tools::subagent::{MailboxMessage, SubAgentResult, SubAgentStatus};
use crate::tools::swarm::{SwarmOutcome, SwarmTaskStatus};
use crate::tui::app::{App, AppMode, TaskPanelEntry};
use crate::tui::history::{HistoryCell, SubAgentCell, summarize_tool_output};
use crate::tui::pager::PagerView;
use crate::tui::widgets::agent_card::{
AgentLifecycle, DelegateCard, FanoutCard, WorkerSlot, apply_to_delegate, apply_to_fanout,
};
pub(super) fn running_agent_count(app: &App) -> usize {
let mut ids: std::collections::HashSet<&str> =
app.agent_progress.keys().map(String::as_str).collect();
for agent in app
.subagent_cache
.iter()
.filter(|agent| matches!(agent.status, SubAgentStatus::Running))
{
ids.insert(agent.agent_id.as_str());
}
ids.len()
}
pub(super) fn active_fanout_counts(app: &App) -> Option<(usize, usize)> {
if let Some(swarm_id) = app.last_swarm_id.as_ref()
&& let Some(outcome) = app.swarm_jobs.get(swarm_id)
{
return Some((outcome.counts.running, outcome.counts.total));
}
if let Some(idx) = app.last_fanout_card_index
&& let Some(HistoryCell::SubAgent(SubAgentCell::Fanout(card))) = app.history.get(idx)
{
let running = card
.workers
.iter()
.filter(|slot| matches!(slot.status, AgentLifecycle::Running))
.count();
return Some((running, card.worker_count()));
}
if let Some(total) = app.pending_swarm_task_count {
return Some((0, total));
}
None
}
pub(super) fn seed_fanout_card_from_tool_call(
app: &mut App,
name: &str,
input: &serde_json::Value,
) -> bool {
if name != "agent_swarm" {
return false;
}
let Some(tasks) = input.get("tasks").and_then(serde_json::Value::as_array) else {
return false;
};
if tasks.is_empty() {
return false;
}
app.pending_swarm_task_count = Some(tasks.len());
true
}
pub(super) fn sync_fanout_card_from_tool_result(
app: &mut App,
name: &str,
result: &Result<ToolResult, ToolError>,
) -> bool {
if name != "agent_swarm" {
return false;
}
let Ok(tool_result) = result else {
return false;
};
let Ok(payload) = serde_json::from_str::<serde_json::Value>(&tool_result.content) else {
return false;
};
let Some(tasks) = payload
.get("tasks")
.and_then(serde_json::Value::as_array)
.filter(|tasks| !tasks.is_empty())
else {
return false;
};
if let Ok(outcome) = serde_json::from_value::<SwarmOutcome>(payload.clone()) {
return sync_fanout_card_from_swarm_outcome(app, &outcome);
}
let workers = tasks
.iter()
.enumerate()
.map(|(idx, task)| {
let task_id = task
.get("task_id")
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| idx.to_string());
let agent_id = task
.get("agent_id")
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| format!("task:{task_id}"));
let status = task
.get("status")
.and_then(serde_json::Value::as_str)
.map(status_to_lifecycle)
.unwrap_or(AgentLifecycle::Pending);
let mut slot =
WorkerSlot::with_agent(format!("task:{task_id}"), Some(agent_id), status);
slot.label = task
.get("label")
.and_then(serde_json::Value::as_str)
.map(str::to_string);
slot.model = task
.get("model")
.and_then(serde_json::Value::as_str)
.map(str::to_string);
slot.nickname = task
.get("nickname")
.and_then(serde_json::Value::as_str)
.map(str::to_string);
slot
})
.collect::<Vec<_>>();
let Some(idx) = app.last_fanout_card_index else {
return false;
};
let Some(HistoryCell::SubAgent(SubAgentCell::Fanout(card))) = app.history.get_mut(idx) else {
return false;
};
card.workers = workers;
app.mark_history_updated();
true
}
pub(super) fn sync_fanout_card_from_swarm_outcome(app: &mut App, outcome: &SwarmOutcome) -> bool {
app.swarm_jobs
.insert(outcome.swarm_id.clone(), outcome.clone());
app.last_swarm_id = Some(outcome.swarm_id.clone());
let workers = outcome
.tasks
.iter()
.map(worker_slot_from_swarm_task)
.collect::<Vec<_>>();
if workers.is_empty() {
return false;
}
let idx = if let Some(&bound) = app.swarm_card_index.get(&outcome.swarm_id)
&& matches!(
app.history.get(bound),
Some(HistoryCell::SubAgent(SubAgentCell::Fanout(_)))
) {
bound
} else if let Some(idx) = app.last_fanout_card_index
&& matches!(
app.history.get(idx),
Some(HistoryCell::SubAgent(SubAgentCell::Fanout(_)))
)
&& !app.swarm_card_index.values().any(|bound| *bound == idx)
{
idx
} else {
let card = FanoutCard::new("agent_swarm".to_string());
app.add_message(HistoryCell::SubAgent(SubAgentCell::Fanout(card)));
let idx = app.history.len().saturating_sub(1);
app.last_fanout_card_index = Some(idx);
idx
};
app.swarm_card_index.insert(outcome.swarm_id.clone(), idx);
app.pending_swarm_task_count = None;
let Some(HistoryCell::SubAgent(SubAgentCell::Fanout(card))) = app.history.get_mut(idx) else {
return false;
};
card.kind = "agent_swarm".to_string();
card.workers = workers;
for task in &outcome.tasks {
if let Some(agent_id) = task.agent_id.as_ref() {
app.subagent_card_index.insert(agent_id.clone(), idx);
}
}
if outcome.status.is_terminal() {
app.pending_subagent_dispatch = None;
}
app.mark_history_updated();
true
}
fn worker_slot_from_swarm_task(task: &crate::tools::swarm::SwarmTaskOutcome) -> WorkerSlot {
let worker_id = if task.worker_id.trim().is_empty() {
format!("task:{}", task.task_id)
} else {
task.worker_id.clone()
};
let agent_id = task
.agent_id
.clone()
.or_else(|| Some(format!("task:{}", task.task_id)));
let mut slot = WorkerSlot::with_agent(
worker_id,
agent_id,
swarm_task_status_to_lifecycle(&task.status),
);
if !task.label.trim().is_empty() {
slot.label = Some(task.label.clone());
}
if !task.model.trim().is_empty() {
slot.model = Some(task.model.clone());
}
slot.nickname = task.nickname.clone();
slot
}
fn status_to_lifecycle(status: &str) -> AgentLifecycle {
match status.trim().to_ascii_lowercase().as_str() {
"completed" => AgentLifecycle::Completed,
"running" => AgentLifecycle::Running,
"failed" | "interrupted" => AgentLifecycle::Failed,
"cancelled" | "canceled" | "skipped" => AgentLifecycle::Cancelled,
_ => AgentLifecycle::Pending,
}
}
fn swarm_task_status_to_lifecycle(status: &SwarmTaskStatus) -> AgentLifecycle {
match status {
SwarmTaskStatus::Completed => AgentLifecycle::Completed,
SwarmTaskStatus::Running => AgentLifecycle::Running,
SwarmTaskStatus::Failed | SwarmTaskStatus::Interrupted => AgentLifecycle::Failed,
SwarmTaskStatus::Cancelled | SwarmTaskStatus::Skipped => AgentLifecycle::Cancelled,
SwarmTaskStatus::Pending => AgentLifecycle::Pending,
}
}
pub(super) fn reconcile_subagent_activity_state(app: &mut App) {
let running_agents: Vec<(String, String)> = app
.subagent_cache
.iter()
.filter(|agent| matches!(agent.status, SubAgentStatus::Running))
.map(|agent| {
(
agent.agent_id.clone(),
summarize_tool_output(&agent.assignment.objective),
)
})
.collect();
let running_ids: std::collections::HashSet<String> =
running_agents.iter().map(|(id, _)| id.clone()).collect();
app.agent_progress
.retain(|id, _| running_ids.contains(id.as_str()));
for (id, objective) in running_agents {
app.agent_progress.entry(id).or_insert(objective);
}
if running_ids.is_empty() {
app.agent_activity_started_at = None;
} else if app.agent_activity_started_at.is_none() {
app.agent_activity_started_at = Some(Instant::now());
}
}
fn subagent_status_rank(status: &SubAgentStatus) -> u8 {
match status {
SubAgentStatus::Running => 0,
SubAgentStatus::Interrupted(_) => 1,
SubAgentStatus::Failed(_) => 2,
SubAgentStatus::Completed => 3,
SubAgentStatus::Cancelled => 4,
}
}
pub(super) fn sort_subagents_in_place(agents: &mut [SubAgentResult]) {
agents.sort_by(|a, b| {
subagent_status_rank(&a.status)
.cmp(&subagent_status_rank(&b.status))
.then_with(|| a.agent_type.as_str().cmp(b.agent_type.as_str()))
.then_with(|| a.agent_id.cmp(&b.agent_id))
});
}
pub(super) fn handle_subagent_mailbox(app: &mut App, seq: u64, message: &MailboxMessage) {
if let MailboxMessage::TokenUsage { model, usage, .. } = message {
if app.subagent_cost_event_seqs.insert(seq)
&& let Some(cost) = crate::pricing::calculate_turn_cost_from_usage(model, usage)
{
app.accrue_subagent_cost(cost);
}
return; }
let agent_id = message.agent_id().to_string();
let belongs_to_known_swarm = app.swarm_jobs.values().any(|outcome| {
!outcome.status.is_terminal()
&& outcome
.tasks
.iter()
.any(|task| task.agent_id.as_deref() == Some(agent_id.as_str()))
});
if (matches!(message, MailboxMessage::ChildSpawned { .. }) || belongs_to_known_swarm)
&& let Some(idx) = app.last_fanout_card_index
&& let Some(HistoryCell::SubAgent(SubAgentCell::Fanout(card))) = app.history.get_mut(idx)
{
apply_to_fanout(card, message);
app.subagent_card_index.insert(agent_id, idx);
app.mark_history_updated();
return;
}
if let Some(&idx) = app.subagent_card_index.get(&agent_id) {
let updated = match app.history.get_mut(idx) {
Some(HistoryCell::SubAgent(SubAgentCell::Delegate(card))) => {
apply_to_delegate(card, message)
}
Some(HistoryCell::SubAgent(SubAgentCell::Fanout(card))) => {
apply_to_fanout(card, message)
}
_ => false,
};
if updated {
app.mark_history_updated();
}
return;
}
let MailboxMessage::Started { agent_type, .. } = message else {
return;
};
let dispatch_kind = app.pending_subagent_dispatch.as_deref();
let is_fanout = matches!(
dispatch_kind,
Some("agent_swarm" | "spawn_agents_on_csv" | "rlm")
) || belongs_to_known_swarm;
if is_fanout {
if let Some(idx) = app.last_fanout_card_index
&& let Some(HistoryCell::SubAgent(SubAgentCell::Fanout(card))) =
app.history.get_mut(idx)
{
card.claim_pending_worker(&agent_id, AgentLifecycle::Running);
app.subagent_card_index.insert(agent_id, idx);
} else {
let mut card = FanoutCard::new(dispatch_kind.unwrap_or("swarm").to_string());
card.upsert_worker(&agent_id, AgentLifecycle::Running);
app.add_message(HistoryCell::SubAgent(SubAgentCell::Fanout(card)));
let idx = app.history.len().saturating_sub(1);
app.last_fanout_card_index = Some(idx);
app.subagent_card_index.insert(agent_id, idx);
}
} else {
let card = DelegateCard::new(agent_id.clone(), agent_type.clone());
app.add_message(HistoryCell::SubAgent(SubAgentCell::Delegate(card)));
let idx = app.history.len().saturating_sub(1);
app.subagent_card_index.insert(agent_id, idx);
app.pending_subagent_dispatch = None;
}
app.mark_history_updated();
}
pub(super) fn task_mode_label(mode: AppMode) -> &'static str {
mode.as_setting()
}
pub(super) fn task_summary_to_panel_entry(summary: TaskSummary) -> TaskPanelEntry {
TaskPanelEntry {
id: summary.id,
status: task_status_label(summary.status).to_string(),
prompt_summary: summary.prompt_summary,
duration_ms: summary.duration_ms,
}
}
fn task_status_label(status: TaskStatus) -> &'static str {
match status {
TaskStatus::Queued => "queued",
TaskStatus::Running => "running",
TaskStatus::Completed => "completed",
TaskStatus::Failed => "failed",
TaskStatus::Canceled => "canceled",
}
}
pub(super) fn format_task_list(tasks: &[TaskSummary]) -> String {
if tasks.is_empty() {
return "No tasks found.".to_string();
}
let mut lines = vec![
format!("Tasks ({})", tasks.len()),
"----------------------------------------".to_string(),
];
for task in tasks {
let duration = task
.duration_ms
.map(|ms| format!("{:.2}s", ms as f64 / 1000.0))
.unwrap_or_else(|| "-".to_string());
lines.push(format!(
"{} {:9} {} {}",
task.id,
task_status_label(task.status),
duration,
task.prompt_summary
));
}
lines.push("Use /task show <id> for timeline details.".to_string());
lines.join("\n")
}
pub(super) fn open_task_pager(app: &mut App, task: &TaskRecord) {
let width = app
.last_transcript_area
.map(|area| area.width)
.unwrap_or(100)
.saturating_sub(4);
app.view_stack.push(PagerView::from_text(
format!("Task {}", task.id),
&format_task_detail(task),
width.max(60),
));
}
fn format_task_detail(task: &TaskRecord) -> String {
let mut lines = Vec::new();
lines.push(format!("Task: {}", task.id));
lines.push(format!("Status: {}", task_status_label(task.status)));
lines.push(format!("Mode: {}", task.mode));
lines.push(format!("Model: {}", task.model));
lines.push(format!("Workspace: {}", task.workspace.display()));
if let Some(thread_id) = task.thread_id.as_ref() {
lines.push(format!("Runtime Thread: {thread_id}"));
}
if let Some(turn_id) = task.turn_id.as_ref() {
lines.push(format!("Runtime Turn: {turn_id}"));
}
if task.runtime_event_count > 0 {
lines.push(format!("Runtime Events: {}", task.runtime_event_count));
}
lines.push(format!("Created: {}", task.created_at));
if let Some(started_at) = task.started_at {
lines.push(format!("Started: {}", started_at));
}
if let Some(ended_at) = task.ended_at {
lines.push(format!("Ended: {}", ended_at));
}
if let Some(duration) = task.duration_ms {
lines.push(format!("Duration: {:.2}s", duration as f64 / 1000.0));
}
lines.push(String::new());
lines.push("Prompt:".to_string());
lines.push(task.prompt.clone());
if let Some(summary) = task.result_summary.as_ref() {
lines.push(String::new());
lines.push("Result Summary:".to_string());
lines.push(summary.clone());
}
if let Some(path) = task.result_detail_path.as_ref() {
lines.push(format!("Result Artifact: {}", path.display()));
}
if let Some(error) = task.error.as_ref() {
lines.push(String::new());
lines.push(format!("Error: {error}"));
}
lines.push(String::new());
lines.push("Tool Calls:".to_string());
if task.tool_calls.is_empty() {
lines.push("- (none)".to_string());
} else {
for tool in &task.tool_calls {
let status = match tool.status {
crate::task_manager::TaskToolStatus::Running => "running",
crate::task_manager::TaskToolStatus::Success => "success",
crate::task_manager::TaskToolStatus::Failed => "failed",
crate::task_manager::TaskToolStatus::Canceled => "canceled",
};
let mut line = format!(
"- {} [{}] {}",
tool.name,
status,
tool.output_summary.as_deref().unwrap_or("(no summary)")
);
if let Some(duration) = tool.duration_ms {
line.push_str(&format!(" ({:.2}s)", duration as f64 / 1000.0));
}
lines.push(line);
if let Some(path) = tool.detail_path.as_ref() {
lines.push(format!(" detail: {}", path.display()));
}
if let Some(path) = tool.patch_ref.as_ref() {
lines.push(format!(" patch: {}", path.display()));
}
}
}
lines.push(String::new());
lines.push("Timeline:".to_string());
if task.timeline.is_empty() {
lines.push("- (none)".to_string());
} else {
for entry in &task.timeline {
lines.push(format!(
"- [{}] {}: {}",
entry.timestamp, entry.kind, entry.summary
));
if let Some(path) = entry.detail_path.as_ref() {
lines.push(format!(" detail: {}", path.display()));
}
}
}
lines.join("\n")
}