diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history.rs b/codex-rs/app-server-protocol/src/protocol/thread_history.rs
index 48fa56d687..a26e9ec88b 100644
--- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs
+++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs
@@ -2527,6 +2527,7 @@ mod tests {
prompt: "inspect the repo".into(),
model: "gpt-5.4-mini".into(),
reasoning_effort: codex_protocol::openai_models::ReasoningEffort::Medium,
+ spawn_mode: codex_protocol::protocol::AgentSpawnMode::Spawn,
status: AgentStatus::Running,
}),
];
diff --git a/codex-rs/core/src/tools/handlers/multi_agents.rs b/codex-rs/core/src/tools/handlers/multi_agents.rs
index 166dbd287d..e0353275a6 100644
--- a/codex-rs/core/src/tools/handlers/multi_agents.rs
+++ b/codex-rs/core/src/tools/handlers/multi_agents.rs
@@ -21,6 +21,8 @@ use async_trait::async_trait;
use codex_protocol::ThreadId;
use codex_protocol::models::ResponseInputItem;
use codex_protocol::openai_models::ReasoningEffort;
+use codex_protocol::openai_models::ReasoningEffortPreset;
+use codex_protocol::protocol::AgentSpawnMode;
use codex_protocol::protocol::CollabAgentInteractionBeginEvent;
use codex_protocol::protocol::CollabAgentInteractionEndEvent;
use codex_protocol::protocol::CollabAgentRef;
diff --git a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs
index 9bb7b6055c..47ea42d516 100644
--- a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs
+++ b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs
@@ -147,6 +147,7 @@ impl ToolHandler for Handler {
prompt,
model: effective_model,
reasoning_effort: effective_reasoning_effort,
+ spawn_mode: AgentSpawnMode::Spawn,
status,
}
.into(),
diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs
index aa9f319ffd..064310e7b1 100644
--- a/codex-rs/protocol/src/protocol.rs
+++ b/codex-rs/protocol/src/protocol.rs
@@ -98,6 +98,27 @@ pub const COLLABORATION_MODE_CLOSE_TAG: &str = "";
pub const REALTIME_CONVERSATION_OPEN_TAG: &str = "";
pub const REALTIME_CONVERSATION_CLOSE_TAG: &str = "";
pub const USER_MESSAGE_BEGIN: &str = "## My request for Codex:";
+pub const AGENT_INBOX_KIND: &str = "agent_inbox";
+pub const AGENT_INBOX_MESSAGE_PREFIX: &str = "[agent_inbox:";
+
+#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema)]
+pub struct AgentInboxPayload {
+ pub injected: bool,
+ pub kind: String,
+ pub sender_thread_id: ThreadId,
+ pub message: String,
+}
+
+impl AgentInboxPayload {
+ pub fn new(sender_thread_id: ThreadId, message: String) -> Self {
+ Self {
+ injected: true,
+ kind: AGENT_INBOX_KIND.to_string(),
+ sender_thread_id,
+ message,
+ }
+ }
+}
/// Submission Queue Entry - requests from user
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
@@ -3396,6 +3417,16 @@ pub struct CollabAgentSpawnBeginEvent {
pub reasoning_effort: ReasoningEffortConfig,
}
+#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS, Default)]
+#[serde(rename_all = "snake_case")]
+#[ts(rename_all = "snake_case")]
+pub enum AgentSpawnMode {
+ #[default]
+ Spawn,
+ Fork,
+ Watchdog,
+}
+
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
pub struct CollabAgentRef {
/// Thread ID of the receiver/new agent.
@@ -3443,6 +3474,9 @@ pub struct CollabAgentSpawnEndEvent {
pub model: String,
/// Effective reasoning effort used by the spawned agent after inheritance and role overrides.
pub reasoning_effort: ReasoningEffortConfig,
+ /// Spawn mode used for this agent.
+ #[serde(default)]
+ pub spawn_mode: AgentSpawnMode,
/// Last known status of the new agent reported to the sender agent.
pub status: AgentStatus,
}
diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs
index ba05bdbb74..b6c2402043 100644
--- a/codex-rs/tui/src/chatwidget.rs
+++ b/codex-rs/tui/src/chatwidget.rs
@@ -122,12 +122,22 @@ use codex_protocol::config_types::Settings;
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::items::AgentMessageContent;
use codex_protocol::items::AgentMessageItem;
+#[cfg(test)]
+use codex_protocol::models::ContentItem;
use codex_protocol::models::MessagePhase;
+#[cfg(test)]
+use codex_protocol::models::ResponseItem;
use codex_protocol::models::local_image_label_text;
use codex_protocol::parse_command::ParsedCommand;
use codex_protocol::plan_tool::PlanItemArg as UpdatePlanItemArg;
use codex_protocol::plan_tool::StepStatus as UpdatePlanItemStatus;
#[cfg(test)]
+use codex_protocol::protocol::AGENT_INBOX_KIND;
+#[cfg(test)]
+use codex_protocol::protocol::AGENT_INBOX_MESSAGE_PREFIX;
+#[cfg(test)]
+use codex_protocol::protocol::AgentInboxPayload;
+#[cfg(test)]
use codex_protocol::protocol::AgentMessageDeltaEvent;
#[cfg(test)]
use codex_protocol::protocol::AgentMessageEvent;
@@ -139,6 +149,7 @@ use codex_protocol::protocol::AgentReasoningEvent;
use codex_protocol::protocol::AgentReasoningRawContentDeltaEvent;
#[cfg(test)]
use codex_protocol::protocol::AgentReasoningRawContentEvent;
+use codex_protocol::protocol::AgentSpawnMode;
use codex_protocol::protocol::AgentStatus;
use codex_protocol::protocol::ApplyPatchApprovalRequestEvent;
#[cfg(test)]
@@ -181,6 +192,8 @@ use codex_protocol::protocol::McpToolCallEndEvent;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::PatchApplyBeginEvent;
use codex_protocol::protocol::RateLimitSnapshot;
+#[cfg(test)]
+use codex_protocol::protocol::RawResponseItemEvent;
use codex_protocol::protocol::ReviewRequest;
use codex_protocol::protocol::ReviewTarget;
use codex_protocol::protocol::SkillMetadata as ProtocolSkillMetadata;
@@ -441,6 +454,38 @@ fn is_unified_exec_source(source: ExecCommandSource) -> bool {
)
}
+#[cfg(test)]
+fn agent_inbox_message_from_item(item: &ResponseItem) -> Option<(Option, String)> {
+ match item {
+ ResponseItem::FunctionCallOutput { output, .. } => {
+ let text = output.body.to_text()?;
+ let payload: AgentInboxPayload = serde_json::from_str(&text).ok()?;
+ if !payload.injected || payload.kind != AGENT_INBOX_KIND {
+ return None;
+ }
+ Some((Some(payload.sender_thread_id.to_string()), payload.message))
+ }
+ ResponseItem::Message { content, .. } => {
+ let text = content.iter().find_map(|item| match item {
+ ContentItem::InputText { text } | ContentItem::OutputText { text } => {
+ Some(text.as_str())
+ }
+ _ => None,
+ })?;
+ let rest = text.strip_prefix(AGENT_INBOX_MESSAGE_PREFIX)?;
+ let (sender, message) = rest.split_once(']')?;
+ let message = message.trim_start().to_string();
+ let sender = sender.trim().to_string();
+ if sender.is_empty() {
+ Some((None, message))
+ } else {
+ Some((Some(sender), message))
+ }
+ }
+ _ => None,
+ }
+}
+
fn is_standard_tool_call(parsed_cmd: &[ParsedCommand]) -> bool {
!parsed_cmd.is_empty()
&& parsed_cmd
@@ -937,6 +982,8 @@ pub(crate) struct ChatWidget {
status_line_branch_lookup_complete: bool,
external_editor_state: ExternalEditorState,
realtime_conversation: RealtimeConversationUiState,
+ #[cfg(test)]
+ last_replayed_agent_inbox_message: Option<(Option, String)>,
last_rendered_user_message_event: Option,
last_non_retry_error: Option<(String, String)>,
}
@@ -3603,6 +3650,32 @@ impl ChatWidget {
self.request_redraw();
}
+ #[cfg(test)]
+ fn on_raw_response_item(&mut self, event: RawResponseItemEvent, from_replay: bool) {
+ let Some((sender, message)) = agent_inbox_message_from_item(&event.item) else {
+ if from_replay {
+ self.last_replayed_agent_inbox_message = None;
+ }
+ return;
+ };
+
+ let replay_key = (sender.clone(), message.clone());
+ if from_replay {
+ if self.last_replayed_agent_inbox_message.as_ref() == Some(&replay_key) {
+ return;
+ }
+ self.last_replayed_agent_inbox_message = Some(replay_key);
+ } else {
+ self.last_replayed_agent_inbox_message = None;
+ }
+
+ let hint = sender.map(|sender| format!("from {sender}"));
+ self.add_to_history(history_cell::new_info_event(
+ format!("Agent message: {message}"),
+ hint,
+ ));
+ }
+
fn on_collab_agent_tool_call(&mut self, item: ThreadItem) {
let ThreadItem::CollabAgentToolCall {
id,
@@ -3665,6 +3738,7 @@ impl ChatWidget {
prompt: prompt.unwrap_or_default(),
model: String::new(),
reasoning_effort: ReasoningEffortConfig::Medium,
+ spawn_mode: AgentSpawnMode::Spawn,
status: first_receiver
.as_ref()
.and_then(|thread_id| agents_states.get(&thread_id.to_string()))
@@ -4613,6 +4687,8 @@ impl ChatWidget {
status_line_branch_lookup_complete: false,
external_editor_state: ExternalEditorState::Closed,
realtime_conversation: RealtimeConversationUiState::default(),
+ #[cfg(test)]
+ last_replayed_agent_inbox_message: None,
last_rendered_user_message_event: None,
last_non_retry_error: None,
};
@@ -6687,6 +6763,9 @@ impl ChatWidget {
if !is_resume_initial_replay && !is_stream_error {
self.restore_retry_status_header_if_present();
}
+ if !from_replay || !matches!(&msg, EventMsg::RawResponseItem(_)) {
+ self.last_replayed_agent_inbox_message = None;
+ }
match msg {
EventMsg::AgentMessageDelta(_)
@@ -6894,8 +6973,8 @@ impl ChatWidget {
});
}
}
- EventMsg::RawResponseItem(_)
- | EventMsg::ItemStarted(_)
+ EventMsg::RawResponseItem(ev) => self.on_raw_response_item(ev, from_replay),
+ EventMsg::ItemStarted(_)
| EventMsg::AgentMessageContentDelta(_)
| EventMsg::ReasoningContentDelta(_)
| EventMsg::ReasoningRawContentDelta(_)
diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__thread_snapshot_replay_agent_inbox_dedupes_compatibility_items.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__thread_snapshot_replay_agent_inbox_dedupes_compatibility_items.snap
new file mode 100644
index 0000000000..10417e9fbb
--- /dev/null
+++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__thread_snapshot_replay_agent_inbox_dedupes_compatibility_items.snap
@@ -0,0 +1,5 @@
+---
+source: tui_app_server/src/chatwidget/tests.rs
+expression: rendered
+---
+• Agent message: Please review the latest diff from 019cbff7-558b-77d3-8653-8238ab5361ec
diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs
index a7119d962c..3c9297fb9f 100644
--- a/codex-rs/tui/src/chatwidget/tests.rs
+++ b/codex-rs/tui/src/chatwidget/tests.rs
@@ -106,7 +106,10 @@ use codex_protocol::items::AgentMessageItem;
use codex_protocol::items::PlanItem;
use codex_protocol::items::TurnItem;
use codex_protocol::items::UserMessageItem;
+use codex_protocol::models::ContentItem;
+use codex_protocol::models::FunctionCallOutputPayload;
use codex_protocol::models::MessagePhase;
+use codex_protocol::models::ResponseItem;
use codex_protocol::openai_models::ModelPreset;
use codex_protocol::openai_models::ReasoningEffortPreset;
use codex_protocol::openai_models::default_input_modalities;
@@ -114,6 +117,8 @@ use codex_protocol::parse_command::ParsedCommand;
use codex_protocol::plan_tool::PlanItemArg;
use codex_protocol::plan_tool::StepStatus;
use codex_protocol::plan_tool::UpdatePlanArgs;
+use codex_protocol::protocol::AGENT_INBOX_MESSAGE_PREFIX;
+use codex_protocol::protocol::AgentInboxPayload;
use codex_protocol::protocol::AgentMessageDeltaEvent;
use codex_protocol::protocol::AgentMessageEvent;
use codex_protocol::protocol::AgentReasoningDeltaEvent;
@@ -149,6 +154,7 @@ use codex_protocol::protocol::PatchApplyBeginEvent;
use codex_protocol::protocol::PatchApplyEndEvent;
use codex_protocol::protocol::PatchApplyStatus as CorePatchApplyStatus;
use codex_protocol::protocol::RateLimitWindow;
+use codex_protocol::protocol::RawResponseItemEvent;
use codex_protocol::protocol::ReadOnlyAccess;
use codex_protocol::protocol::RealtimeConversationClosedEvent;
use codex_protocol::protocol::RealtimeConversationRealtimeEvent;
@@ -292,6 +298,27 @@ fn snapshot(percent: f64) -> RateLimitSnapshot {
}
}
+fn agent_inbox_function_call_output(sender: ThreadId, message: &str) -> ResponseItem {
+ let payload = serde_json::to_string(&AgentInboxPayload::new(sender, message.to_string()))
+ .expect("agent inbox payload should serialize");
+ ResponseItem::FunctionCallOutput {
+ call_id: "call-agent-inbox".to_string(),
+ output: FunctionCallOutputPayload::from_text(payload),
+ }
+}
+
+fn agent_inbox_message(sender: ThreadId, message: &str) -> ResponseItem {
+ ResponseItem::Message {
+ id: None,
+ role: "assistant".to_string(),
+ content: vec![ContentItem::OutputText {
+ text: format!("{AGENT_INBOX_MESSAGE_PREFIX}{sender}] {message}"),
+ }],
+ end_turn: None,
+ phase: None,
+ }
+}
+
#[tokio::test]
async fn resumed_initial_messages_render_history() {
let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
@@ -397,6 +424,44 @@ async fn thread_snapshot_replay_does_not_duplicate_agent_message_history() {
);
}
+#[tokio::test]
+async fn thread_snapshot_replay_deduplicates_agent_inbox_compatibility_items() {
+ let (mut chat, mut rx, _ops) = make_chatwidget_manual(None).await;
+
+ let sender =
+ ThreadId::from_string("019cbff7-558b-77d3-8653-8238ab5361ec").expect("valid thread id");
+ let message = "Please review the latest diff";
+
+ chat.handle_codex_event_replay(Event {
+ id: "evt-agent-output".into(),
+ msg: EventMsg::RawResponseItem(RawResponseItemEvent {
+ item: agent_inbox_function_call_output(sender, message),
+ }),
+ });
+ chat.handle_codex_event_replay(Event {
+ id: "evt-agent-message".into(),
+ msg: EventMsg::RawResponseItem(RawResponseItemEvent {
+ item: agent_inbox_message(sender, message),
+ }),
+ });
+
+ let cells = drain_insert_history(&mut rx);
+ assert_eq!(
+ cells.len(),
+ 1,
+ "expected replayed agent inbox compatibility items to render one history row"
+ );
+ let rendered = cells
+ .iter()
+ .map(|lines| lines_to_single_string(lines))
+ .collect::>()
+ .join("\n");
+ assert_snapshot!(
+ "thread_snapshot_replay_agent_inbox_dedupes_compatibility_items",
+ rendered
+ );
+}
+
#[tokio::test]
async fn replayed_user_message_preserves_text_elements_and_local_images() {
let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
@@ -2192,6 +2257,7 @@ async fn make_chatwidget_manual(
status_line_branch_lookup_complete: false,
external_editor_state: ExternalEditorState::Closed,
realtime_conversation: RealtimeConversationUiState::default(),
+ last_replayed_agent_inbox_message: None,
last_rendered_user_message_event: None,
last_non_retry_error: None,
};
diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs
index 4aadbf06d1..8a16ef4b98 100644
--- a/codex-rs/tui/src/history_cell.rs
+++ b/codex-rs/tui/src/history_cell.rs
@@ -25,6 +25,8 @@ use crate::render::line_utils::line_to_static;
use crate::render::line_utils::prefix_lines;
use crate::render::line_utils::push_owned_lines;
use crate::render::renderable::Renderable;
+use crate::shimmer::shimmer_spans;
+use crate::status_indicator_widget::fmt_elapsed_compact;
use crate::style::proposed_plan_style;
use crate::style::user_message_style;
#[cfg(test)]
@@ -62,6 +64,7 @@ use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
use codex_protocol::plan_tool::PlanItemArg;
use codex_protocol::plan_tool::StepStatus;
use codex_protocol::plan_tool::UpdatePlanArgs;
+use codex_protocol::protocol::AgentStatus;
use codex_protocol::protocol::FileChange;
use codex_protocol::protocol::McpAuthStatus;
use codex_protocol::protocol::McpInvocation;
@@ -85,8 +88,8 @@ use std::collections::HashMap;
use std::io::Cursor;
use std::path::Path;
use std::path::PathBuf;
-#[cfg(test)]
use std::sync::Arc;
+use std::sync::Mutex;
use std::time::Duration;
use std::time::Instant;
use tracing::error;
@@ -497,6 +500,248 @@ impl HistoryCell for PlainHistoryCell {
}
}
+#[cfg_attr(not(test), allow(dead_code))]
+#[derive(Clone, Debug)]
+pub(crate) struct SubagentPanelAgent {
+ pub(crate) ordinal: i32,
+ pub(crate) name: String,
+ pub(crate) status: AgentStatus,
+ pub(crate) is_watchdog: bool,
+ pub(crate) preview: String,
+ pub(crate) latest_update_at: Instant,
+}
+
+#[cfg_attr(not(test), allow(dead_code))]
+#[derive(Clone, Debug)]
+pub(crate) struct SubagentPanelState {
+ pub(crate) started_at: Instant,
+ pub(crate) total_agents: i32,
+ pub(crate) running_count: i32,
+ pub(crate) running_agents: Vec,
+}
+
+impl SubagentPanelState {
+ #[cfg_attr(not(test), allow(dead_code))]
+ pub(crate) fn running_count(&self) -> i32 {
+ self.running_count
+ }
+
+ #[cfg_attr(not(test), allow(dead_code))]
+ pub(crate) fn has_animating_agents(&self, now: Instant) -> bool {
+ self.running_agents
+ .iter()
+ .any(|agent| should_shimmer(agent, now))
+ }
+}
+
+#[cfg_attr(not(test), allow(dead_code))]
+#[derive(Clone, Debug)]
+pub(crate) struct SubagentStatusCell {
+ state: Arc>,
+ animations_enabled: bool,
+}
+
+impl SubagentStatusCell {
+ #[cfg_attr(not(test), allow(dead_code))]
+ pub(crate) fn new(
+ state: Arc>,
+ animations_enabled: bool,
+ ) -> SubagentStatusCell {
+ SubagentStatusCell {
+ state,
+ animations_enabled,
+ }
+ }
+
+ #[allow(dead_code)]
+ pub(crate) fn state_handle(&self) -> Arc> {
+ Arc::clone(&self.state)
+ }
+
+ #[allow(dead_code)]
+ pub(crate) fn matches_state(&self, other: &Arc>) -> bool {
+ Arc::ptr_eq(&self.state, other)
+ }
+}
+
+impl HistoryCell for SubagentStatusCell {
+ fn display_lines(&self, width: u16) -> Vec> {
+ let state = match self.state.lock() {
+ Ok(guard) => guard.clone(),
+ Err(poisoned) => poisoned.into_inner().clone(),
+ };
+ if state.running_agents.is_empty() {
+ return Vec::new();
+ }
+
+ let elapsed = fmt_elapsed_compact(state.started_at.elapsed().as_secs());
+ let running_count = state.running_count();
+ let total_agents = state.total_agents.max(running_count);
+ let count_label = subagent_count_label(total_agents, running_count);
+ let header_suffix = format!("({elapsed} • {count_label} • esc to interrupt)");
+
+ let mut lines = Vec::new();
+ lines.push(Line::from(vec![
+ "• ".dim(),
+ "Subagents".bold(),
+ " ".into(),
+ header_suffix.dim(),
+ ]));
+
+ let mut running_agents = state.running_agents;
+ running_agents.sort_by(|left, right| left.ordinal.cmp(&right.ordinal));
+ let preview_budget = running_preview_budget(width);
+ let now = Instant::now();
+ lines.extend(running_agents.into_iter().map(|agent| {
+ let preview = truncate_text(agent.preview.trim(), preview_budget);
+ let mut spans: Vec> =
+ vec!["• ".dim(), format!("[#{}] ", agent.ordinal).dim()];
+ if agent.is_watchdog {
+ spans.push("[watchdog] ".magenta().dim());
+ }
+ spans.push(Span::from(agent.name.clone()));
+ spans.push(" ".into());
+ spans.push(status_span_for_panel(&agent));
+ spans.push(" — ".dim());
+ if self.animations_enabled && should_shimmer(&agent, now) {
+ spans.extend(shimmer_spans(&preview));
+ } else {
+ spans.push(Span::from(preview));
+ }
+ Line::from(spans)
+ }));
+
+ lines
+ }
+
+ fn transcript_animation_tick(&self) -> Option {
+ if !self.animations_enabled {
+ return None;
+ }
+ let guard = match self.state.lock() {
+ Ok(guard) => guard,
+ Err(poisoned) => poisoned.into_inner(),
+ };
+ let now = Instant::now();
+ if !guard.has_animating_agents(now) {
+ return None;
+ }
+ Some((now.duration_since(guard.started_at).as_millis() / 100) as u64)
+ }
+}
+
+#[allow(dead_code)]
+pub(crate) fn new_subagent_spawned_cell(name: &str, prompt_preview: &str) -> PlainHistoryCell {
+ let mut lines = Vec::new();
+ lines.push(Line::from(vec![
+ "• ".dim(),
+ "Spawned subagent ".into(),
+ Span::from(name.to_string()).bold(),
+ ]));
+
+ let preview = truncate_text(prompt_preview.trim(), 240);
+ if !preview.is_empty() {
+ lines.push(Line::from(vec![
+ " └ ".dim(),
+ Span::from(format!("\"{preview}\"" )).dim(),
+ ]));
+ }
+
+ PlainHistoryCell::new(lines)
+}
+
+#[allow(dead_code)]
+pub(crate) fn new_subagent_update_cell(
+ name: &str,
+ status: &AgentStatus,
+ summary: &str,
+) -> PlainHistoryCell {
+ let mut spans: Vec> = vec![
+ "• ".dim(),
+ "Subagent update: ".into(),
+ Span::from(name.to_string()).bold(),
+ " ".into(),
+ status_label_span(status),
+ ];
+
+ let summary = truncate_text(summary.trim(), 240);
+ if !summary.is_empty() {
+ spans.push(" — ".dim());
+ spans.push(Span::from(summary));
+ }
+
+ PlainHistoryCell::new(vec![Line::from(spans)])
+}
+
+#[cfg_attr(not(test), allow(dead_code))]
+fn running_preview_budget(width: u16) -> usize {
+ let width = width as usize;
+ width.saturating_sub(24).clamp(60, 160)
+}
+
+#[cfg_attr(not(test), allow(dead_code))]
+fn is_running_status(status: &AgentStatus) -> bool {
+ matches!(status, AgentStatus::PendingInit | AgentStatus::Running)
+}
+
+#[cfg_attr(not(test), allow(dead_code))]
+fn status_span_for_panel(agent: &SubagentPanelAgent) -> Span<'static> {
+ match &agent.status {
+ AgentStatus::PendingInit if agent.is_watchdog => "idle".dim(),
+ AgentStatus::PendingInit | AgentStatus::Running => "running".cyan().bold(),
+ AgentStatus::Interrupted => "interrupted".yellow(),
+ AgentStatus::Completed(_) => "completed".green(),
+ AgentStatus::Errored(_) => "errored".red(),
+ AgentStatus::Shutdown => "shutdown".dim(),
+ AgentStatus::NotFound => "not found".red(),
+ }
+}
+
+#[cfg_attr(not(test), allow(dead_code))]
+const SUBAGENT_SHIMMER_WINDOW: Duration = Duration::from_secs(1);
+
+#[cfg_attr(not(test), allow(dead_code))]
+fn should_shimmer(agent: &SubagentPanelAgent, now: Instant) -> bool {
+ if agent.is_watchdog && matches!(agent.status, AgentStatus::PendingInit) {
+ return false;
+ }
+ is_running_status(&agent.status)
+ && now.saturating_duration_since(agent.latest_update_at) <= SUBAGENT_SHIMMER_WINDOW
+}
+
+#[allow(dead_code)]
+fn status_label_span(status: &AgentStatus) -> Span<'static> {
+ match status {
+ AgentStatus::PendingInit | AgentStatus::Running => "running".cyan().bold(),
+ AgentStatus::Interrupted => "interrupted".yellow(),
+ AgentStatus::Completed(_) => "completed".green(),
+ AgentStatus::Errored(_) => "errored".red(),
+ AgentStatus::Shutdown => "shutdown".dim(),
+ AgentStatus::NotFound => "not found".red(),
+ }
+}
+
+#[cfg_attr(not(test), allow(dead_code))]
+fn subagent_count_label(total: i32, running: i32) -> String {
+ if total <= 0 || running <= 0 {
+ return "no subagents running".to_string();
+ }
+ let total_label = subagent_pluralize(total, "subagent");
+ if running >= total {
+ return format!("{total_label} running");
+ }
+ format!("{total_label}, {running} running")
+}
+
+#[cfg_attr(not(test), allow(dead_code))]
+fn subagent_pluralize(count: i32, singular: &str) -> String {
+ if count == 1 {
+ format!("1 {singular}")
+ } else {
+ format!("{count} {singular}s")
+ }
+}
+
#[cfg_attr(debug_assertions, allow(dead_code))]
#[derive(Debug)]
pub(crate) struct UpdateAvailableHistoryCell {
@@ -2781,6 +3026,7 @@ mod tests {
use codex_protocol::account::PlanType;
use codex_protocol::models::WebSearchAction;
use codex_protocol::parse_command::ParsedCommand;
+ use codex_protocol::protocol::AgentStatus;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::McpAuthStatus;
use codex_protocol::protocol::SandboxPolicy;
@@ -2790,6 +3036,8 @@ mod tests {
use serde_json::json;
use std::collections::HashMap;
use std::path::PathBuf;
+ use std::sync::Arc;
+ use std::sync::Mutex;
use codex_protocol::mcp::CallToolResult;
use codex_protocol::mcp::Tool;
@@ -3356,6 +3604,94 @@ mod tests {
assert_eq!(cell.desired_transcript_height(/*width*/ 80), 1);
}
+ #[test]
+ fn subagent_panel_renders_watchdog_handle_as_idle() {
+ let state = Arc::new(Mutex::new(SubagentPanelState {
+ started_at: Instant::now(),
+ total_agents: 1,
+ running_count: 0,
+ running_agents: vec![SubagentPanelAgent {
+ ordinal: 1,
+ name: "watchdog-agent".to_string(),
+ status: AgentStatus::PendingInit,
+ is_watchdog: true,
+ preview: "monitor parent progress".to_string(),
+ latest_update_at: Instant::now(),
+ }],
+ }));
+ let cell = SubagentStatusCell::new(state, true);
+ let lines = render_lines(&cell.display_lines(120));
+
+ assert!(lines[0].contains("no subagents running"));
+ assert!(lines[1].contains("[watchdog] watchdog-agent idle"));
+ }
+
+ #[test]
+ fn subagent_panel_animation_tick_ignores_idle_watchdogs() {
+ let state = Arc::new(Mutex::new(SubagentPanelState {
+ started_at: Instant::now(),
+ total_agents: 1,
+ running_count: 0,
+ running_agents: vec![SubagentPanelAgent {
+ ordinal: 1,
+ name: "watchdog-agent".to_string(),
+ status: AgentStatus::PendingInit,
+ is_watchdog: true,
+ preview: "monitor parent progress".to_string(),
+ latest_update_at: Instant::now(),
+ }],
+ }));
+ let cell = SubagentStatusCell::new(state, true);
+
+ assert_eq!(cell.transcript_animation_tick(), None);
+ }
+
+ #[test]
+ fn subagent_panel_animation_tick_runs_for_recent_running_updates() {
+ let state = Arc::new(Mutex::new(SubagentPanelState {
+ started_at: Instant::now(),
+ total_agents: 1,
+ running_count: 1,
+ running_agents: vec![SubagentPanelAgent {
+ ordinal: 1,
+ name: "worker-agent".to_string(),
+ status: AgentStatus::Running,
+ is_watchdog: false,
+ preview: "working".to_string(),
+ latest_update_at: Instant::now(),
+ }],
+ }));
+ let cell = SubagentStatusCell::new(state, true);
+
+ assert!(
+ cell.transcript_animation_tick().is_some(),
+ "recent running updates should animate"
+ );
+ }
+
+ #[test]
+ fn subagent_panel_animation_tick_stops_when_updates_are_stale() {
+ let stale_update = Instant::now()
+ .checked_sub(Duration::from_secs(2))
+ .unwrap_or_else(Instant::now);
+ let state = Arc::new(Mutex::new(SubagentPanelState {
+ started_at: Instant::now(),
+ total_agents: 1,
+ running_count: 1,
+ running_agents: vec![SubagentPanelAgent {
+ ordinal: 1,
+ name: "worker-agent".to_string(),
+ status: AgentStatus::Running,
+ is_watchdog: false,
+ preview: "working".to_string(),
+ latest_update_at: stale_update,
+ }],
+ }));
+ let cell = SubagentStatusCell::new(state, true);
+
+ assert_eq!(cell.transcript_animation_tick(), None);
+ }
+
#[test]
fn prefixed_wrapped_history_cell_indents_wrapped_lines() {
let summary = Line::from(vec![
diff --git a/codex-rs/tui/src/multi_agents.rs b/codex-rs/tui/src/multi_agents.rs
index 293c80fcf0..d56e79019d 100644
--- a/codex-rs/tui/src/multi_agents.rs
+++ b/codex-rs/tui/src/multi_agents.rs
@@ -9,6 +9,7 @@ use crate::render::line_utils::prefix_lines;
use crate::text_formatting::truncate_text;
use codex_protocol::ThreadId;
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
+use codex_protocol::protocol::AgentSpawnMode;
use codex_protocol::protocol::AgentStatus;
use codex_protocol::protocol::CollabAgentInteractionEndEvent;
use codex_protocol::protocol::CollabAgentRef;
@@ -611,6 +612,7 @@ mod tests {
prompt: "Compute 11! and reply with just the integer result.".to_string(),
model: "gpt-5".to_string(),
reasoning_effort: ReasoningEffortConfig::High,
+ spawn_mode: AgentSpawnMode::Spawn,
status: AgentStatus::PendingInit,
},
Some(&SpawnRequestSummary {
@@ -749,6 +751,7 @@ mod tests {
prompt: String::new(),
model: "gpt-5".to_string(),
reasoning_effort: ReasoningEffortConfig::High,
+ spawn_mode: AgentSpawnMode::Spawn,
status: AgentStatus::PendingInit,
},
Some(&SpawnRequestSummary {