From 45bf65d0076c8ff52dee07eef17e3acdd31d95b4 Mon Sep 17 00:00:00 2001 From: Friel Date: Sun, 29 Mar 2026 21:11:31 +0000 Subject: [PATCH] Render watchdog countdown in subagent panel --- codex-rs/tui/src/app.rs | 3 + ..._while_placeholder_active_cell_exists.snap | 4 +- ...ent_inbox_dedupes_compatibility_items.snap | 6 ++ codex-rs/tui/src/chatwidget/tests.rs | 2 + codex-rs/tui/src/history_cell.rs | 68 +++++++++++++++++-- 5 files changed, 77 insertions(+), 6 deletions(-) create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__thread_snapshot_replay_agent_inbox_dedupes_compatibility_items.snap diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 70b845cf41..bf709cafe5 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -1121,6 +1121,9 @@ impl SubagentRegistry { name: info.name.clone(), status: info.status.clone(), is_watchdog: info.is_watchdog(), + watchdog_countdown_started_at: info + .is_watchdog() + .then_some(info.running_started_at()), preview: running_preview(info), latest_update_at: info.latest_update_at, }) diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__subagent_panel_mounts_while_placeholder_active_cell_exists.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__subagent_panel_mounts_while_placeholder_active_cell_exists.snap index 895663fc41..379fc8b812 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__subagent_panel_mounts_while_placeholder_active_cell_exists.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__subagent_panel_mounts_while_placeholder_active_cell_exists.snap @@ -1,7 +1,9 @@ --- source: tui/src/chatwidget/tests.rs +assertion_line: 2568 expression: terminal.backend().vt100().screen().contents() --- + ╭───────────────────────────────────────╮ │ >_ OpenAI Codex (v0.0.0) │ │ │ @@ -10,7 +12,7 @@ expression: terminal.backend().vt100().screen().contents() ╰───────────────────────────────────────╯ • Subagents (0s • no subagents running • esc to interrupt) -• [#1] [watchdog] watchdog-agent idle — watchdog idle +• [#1] [watchdog] watchdog-agent idle (59s) — watchdog idle › show current subagent state diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__thread_snapshot_replay_agent_inbox_dedupes_compatibility_items.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__thread_snapshot_replay_agent_inbox_dedupes_compatibility_items.snap new file mode 100644 index 0000000000..40102b3e02 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__thread_snapshot_replay_agent_inbox_dedupes_compatibility_items.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/chatwidget/tests.rs +assertion_line: 464 +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 cb1dbb5632..f2aa5a3886 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -2483,6 +2483,7 @@ async fn subagent_panel_is_not_flushed_into_transcript_history() { name: "user-request-derisk-implement".to_string(), status: AgentStatus::PendingInit, is_watchdog: true, + watchdog_countdown_started_at: Some(Instant::now()), preview: "watchdog idle".to_string(), latest_update_at: Instant::now(), }], @@ -2532,6 +2533,7 @@ async fn subagent_panel_mounts_while_placeholder_active_cell_exists_snapshot() { name: "watchdog-agent".to_string(), status: AgentStatus::PendingInit, is_watchdog: true, + watchdog_countdown_started_at: Some(Instant::now()), preview: "watchdog idle".to_string(), latest_update_at: Instant::now(), }], diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 55337860c3..35b677ea5f 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -507,6 +507,7 @@ pub(crate) struct SubagentPanelAgent { pub(crate) name: String, pub(crate) status: AgentStatus, pub(crate) is_watchdog: bool, + pub(crate) watchdog_countdown_started_at: Option, pub(crate) preview: String, pub(crate) latest_update_at: Instant, } @@ -530,7 +531,7 @@ impl SubagentPanelState { pub(crate) fn has_animating_agents(&self, now: Instant) -> bool { self.running_agents .iter() - .any(|agent| should_shimmer(agent, now)) + .any(|agent| should_shimmer(agent, now) || has_watchdog_countdown(agent, now)) } } @@ -604,7 +605,7 @@ impl HistoryCell for SubagentStatusCell { } spans.push(Span::from(agent.name.clone())); spans.push(" ".into()); - spans.push(status_span_for_panel(&agent)); + spans.push(status_span_for_panel(&agent, now)); spans.push(" — ".dim()); if self.animations_enabled && should_shimmer(&agent, now) { spans.extend(shimmer_spans(&preview)); @@ -708,9 +709,15 @@ fn is_running_status(status: &AgentStatus) -> bool { } #[cfg_attr(not(test), allow(dead_code))] -fn status_span_for_panel(agent: &SubagentPanelAgent) -> Span<'static> { +fn status_span_for_panel(agent: &SubagentPanelAgent, now: Instant) -> Span<'static> { match &agent.status { - AgentStatus::PendingInit if agent.is_watchdog => "idle".dim(), + AgentStatus::PendingInit if agent.is_watchdog => { + if let Some(countdown) = watchdog_countdown_remaining(agent, now) { + format!("idle ({})", fmt_elapsed_compact(countdown.as_secs())).dim() + } else { + "idle".dim() + } + } AgentStatus::PendingInit | AgentStatus::Running => "running".cyan().bold(), AgentStatus::Interrupted => "interrupted".magenta(), AgentStatus::Completed(_) => "completed".green(), @@ -722,6 +729,7 @@ fn status_span_for_panel(agent: &SubagentPanelAgent) -> Span<'static> { #[cfg_attr(not(test), allow(dead_code))] const SUBAGENT_SHIMMER_WINDOW: Duration = Duration::from_secs(1); +const WATCHDOG_COUNTDOWN: Duration = Duration::from_secs(60); #[cfg_attr(not(test), allow(dead_code))] fn should_shimmer(agent: &SubagentPanelAgent, now: Instant) -> bool { @@ -732,6 +740,26 @@ fn should_shimmer(agent: &SubagentPanelAgent, now: Instant) -> bool { && now.saturating_duration_since(agent.latest_update_at) <= SUBAGENT_SHIMMER_WINDOW } +#[cfg_attr(not(test), allow(dead_code))] +fn has_watchdog_countdown(agent: &SubagentPanelAgent, now: Instant) -> bool { + watchdog_countdown_remaining(agent, now).is_some_and(|remaining| remaining > Duration::ZERO) +} + +#[cfg_attr(not(test), allow(dead_code))] +fn watchdog_countdown_remaining(agent: &SubagentPanelAgent, now: Instant) -> Option { + if !agent.is_watchdog { + return None; + } + if !matches!(agent.status, AgentStatus::PendingInit) { + return None; + } + let Some(started_at) = agent.watchdog_countdown_started_at else { + return None; + }; + let elapsed = now.saturating_duration_since(started_at); + Some(WATCHDOG_COUNTDOWN.saturating_sub(elapsed)) +} + #[allow(dead_code)] fn status_label_span(status: &AgentStatus) -> Span<'static> { match status { @@ -3638,6 +3666,7 @@ mod tests { name: "watchdog-agent".to_string(), status: AgentStatus::PendingInit, is_watchdog: true, + watchdog_countdown_started_at: Some(Instant::now()), preview: "monitor parent progress".to_string(), latest_update_at: Instant::now(), }], @@ -3647,10 +3676,11 @@ mod tests { assert!(lines[0].contains("no subagents running")); assert!(lines[1].contains("[watchdog] watchdog-agent idle")); + assert!(lines[1].contains("idle (")); } #[test] - fn subagent_panel_animation_tick_ignores_idle_watchdogs() { + fn subagent_panel_animation_tick_ticks_idle_watchdogs() { let state = Arc::new(Mutex::new(SubagentPanelState { started_at: Instant::now(), total_agents: 1, @@ -3660,6 +3690,32 @@ mod tests { name: "watchdog-agent".to_string(), status: AgentStatus::PendingInit, is_watchdog: true, + watchdog_countdown_started_at: Some(Instant::now()), + preview: "monitor parent progress".to_string(), + latest_update_at: Instant::now(), + }], + })); + let cell = SubagentStatusCell::new(state, /*animations_enabled*/ true); + + assert!(cell.transcript_animation_tick().is_some()); + } + + #[test] + fn subagent_panel_animation_tick_stops_after_countdown_expires() { + 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, + watchdog_countdown_started_at: Some( + Instant::now() + .checked_sub(Duration::from_secs(61)) + .unwrap_or_else(Instant::now), + ), preview: "monitor parent progress".to_string(), latest_update_at: Instant::now(), }], @@ -3680,6 +3736,7 @@ mod tests { name: "worker-agent".to_string(), status: AgentStatus::Running, is_watchdog: false, + watchdog_countdown_started_at: None, preview: "working".to_string(), latest_update_at: Instant::now(), }], @@ -3706,6 +3763,7 @@ mod tests { name: "worker-agent".to_string(), status: AgentStatus::Running, is_watchdog: false, + watchdog_countdown_started_at: None, preview: "working".to_string(), latest_update_at: stale_update, }],