| 1 | //! Compact transcript rendering for agent and activity metadata cells. |
| 2 | |
| 3 | use ratatui::style::Style; |
| 4 | use ratatui::text::{Line, Span}; |
| 5 | |
| 6 | use crate::palette; |
| 7 | |
| 8 | use super::{ |
| 9 | GenericToolCell, render_tool_header_with_family_and_summary, tool_status_label, truncate_text, |
| 10 | }; |
| 11 | |
| 12 | pub(super) fn render_agent_compact(cell: &GenericToolCell, low_motion: bool) -> Vec<Line<'static>> { |
| 13 | let family = crate::tui::widgets::tool_card::ToolFamily::Delegate; |
| 14 | let agent_id = cell |
| 15 | .output |
| 16 | .as_deref() |
| 17 | .and_then(extract_agent_id) |
| 18 | .map(str::to_string) |
| 19 | .unwrap_or_else(|| delegate_identity_fallback(cell)); |
| 20 | // Inspections and joins must not draw the same "delegate done" line as a |
| 21 | // spawn — during a fan-out session every peek/status/wait would otherwise |
| 22 | // read as yet another completed delegate (#4112, dogfood A5). The action |
| 23 | // is stamped at the front of the args summary by tool_routing. |
| 24 | let state_label = match agent_inspection_action(cell) { |
| 25 | Some(AgentCompactAction::Check) => match cell.status { |
| 26 | super::ToolStatus::Running => "checking", |
| 27 | _ => "checked", |
| 28 | }, |
| 29 | Some(AgentCompactAction::Wait) => match cell.status { |
| 30 | super::ToolStatus::Running => "waiting", |
| 31 | _ => "waited", |
| 32 | }, |
| 33 | None => tool_status_label(cell.status), |
| 34 | }; |
| 35 | vec![render_tool_header_with_family_and_summary( |
| 36 | family, |
| 37 | Some(agent_id.as_str()), |
| 38 | state_label, |
| 39 | cell.status, |
| 40 | None, |
| 41 | low_motion, |
| 42 | )] |
| 43 | } |
| 44 | |
| 45 | enum AgentCompactAction { |
| 46 | /// Read-only inspection: peek / status / progress / list / inspect. |
| 47 | Check, |
| 48 | /// Blocking join: wait / join / await / block. |
| 49 | Wait, |
| 50 | } |
| 51 | |
| 52 | /// Whether this `agent` cell is a read-only inspection or join rather than a |
| 53 | /// spawn — those stay compact even in Transcript mode. |
| 54 | pub(super) fn is_agent_inspection(cell: &GenericToolCell) -> bool { |
| 55 | agent_inspection_action(cell).is_some() |
| 56 | } |
| 57 | |
| 58 | fn agent_inspection_action(cell: &GenericToolCell) -> Option<AgentCompactAction> { |
| 59 | let summary = cell.input_summary.as_deref()?; |
| 60 | let action = summary.strip_prefix("action:")?.trim_start(); |
| 61 | let action = action.split_whitespace().next().unwrap_or(""); |
| 62 | match action.trim_end_matches(',') { |
| 63 | "peek" | "progress" | "status" | "list" | "inspect" => Some(AgentCompactAction::Check), |
| 64 | "wait" | "join" | "await" | "block" => Some(AgentCompactAction::Wait), |
| 65 | _ => None, |
| 66 | } |
| 67 | } |
| 68 | |
| 69 | pub(super) fn render_activity_group(cell: &GenericToolCell, width: u16) -> Vec<Line<'static>> { |
| 70 | let summary = cell.input_summary.as_deref().unwrap_or("Updated metadata"); |
| 71 | let budget = usize::from(width).max(1); |
| 72 | vec![Line::from(Span::styled( |
| 73 | truncate_text(summary, budget), |
| 74 | Style::default().fg(palette::TEXT_MUTED), |
| 75 | ))] |
| 76 | } |
| 77 | |
| 78 | fn delegate_identity_fallback(cell: &GenericToolCell) -> String { |
| 79 | if let Some(summary) = cell.input_summary.as_deref() { |
| 80 | let summary = summary.trim(); |
| 81 | if let Some(rest) = summary.strip_prefix("role:") { |
| 82 | let role = rest.split_whitespace().next().unwrap_or(rest).trim(); |
| 83 | if !role.is_empty() { |
| 84 | return role.to_string(); |
| 85 | } |
| 86 | } |
| 87 | if let Some(rest) = summary.strip_prefix("prompt:") { |
| 88 | let title = rest.trim(); |
| 89 | if !title.is_empty() { |
| 90 | let slug: String = title |
| 91 | .chars() |
| 92 | .take(24) |
| 93 | .map(|ch| { |
| 94 | if ch.is_ascii_alphanumeric() { |
| 95 | ch.to_ascii_lowercase() |
| 96 | } else { |
| 97 | '-' |
| 98 | } |
| 99 | }) |
| 100 | .collect(); |
| 101 | let slug = slug.trim_matches('-'); |
| 102 | if !slug.is_empty() { |
| 103 | return slug.to_string(); |
| 104 | } |
| 105 | } |
| 106 | } |
| 107 | } |
| 108 | // #4148: never surface the raw internal fallback token ("unknown child") |
| 109 | // in the default transcript. When we can't resolve a concrete role, slug, |
| 110 | // or agent id, a friendly, non-leaky label reads best next to the |
| 111 | // "delegate" verb ("delegate running · subagent"). |
| 112 | "subagent".to_string() |
| 113 | } |
| 114 | |
| 115 | pub(super) fn extract_agent_id(output: &str) -> Option<&str> { |
| 116 | let key = "\"agent_id\""; |
| 117 | let key_idx = output.find(key)?; |
| 118 | let rest = &output[key_idx + key.len()..]; |
| 119 | let colon = rest.find(':')?; |
| 120 | let after_colon = rest[colon + 1..].trim_start(); |
| 121 | let after_colon = after_colon.strip_prefix('"')?; |
| 122 | let end = after_colon.find('"')?; |
| 123 | let id = &after_colon[..end]; |
| 124 | (!id.is_empty()).then_some(id) |
| 125 | } |
| 126 |