From 17e8101699c5062117d0d37f504313e8af53b043 Mon Sep 17 00:00:00 2001 From: Ian MacLeod Date: Mon, 31 Aug 2026 20:39:46 +0000 Subject: [PATCH] Preserve TUI status timing when the status row is hidden (#41913) ## Why Streaming can temporarily hide the status row, but its elapsed time is still needed when the row returns and when the final `Worked for` duration falls back to the local clock. Approval prompts must also keep that clock paused. ## What changed - Own the pause-aware status timer in the bottom pane independently of the optional status row. - Preserve elapsed time and modal pause state while hiding or restoring the row, and reset the clock for new turns and Windows sandbox setup. - Use the independent timer as the fallback for the final turn duration. ## Testing Add timer unit coverage and TUI tests for hidden rows, outstanding approvals, fallback turn durations, and fresh Windows sandbox setup clocks. GitOrigin-RevId: a77931e465d5aedd4a8cd606a3a3117d1bb37ce7 --- codex-rs/tui/src/bottom_pane/mod.rs | 96 ++++++++++--- ...sts__status_timer_survives_hidden_row.snap | 11 ++ ...ts__windows_sandbox_setup_fresh_clock.snap | 12 ++ .../tui/src/chatwidget/tests/exec_flow.rs | 60 ++++---- .../tui/src/chatwidget/tests/permissions.rs | 22 +++ codex-rs/tui/src/chatwidget/turn_runtime.rs | 5 +- .../src/chatwidget/windows_sandbox_prompts.rs | 9 +- codex-rs/tui/src/status_indicator_widget.rs | 136 +++++------------- .../tui/src/status_indicator_widget/timer.rs | 57 ++++++++ .../status_indicator_widget/timer_tests.rs | 34 +++++ 10 files changed, 295 insertions(+), 147 deletions(-) create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_timer_survives_hidden_row.snap create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__windows_sandbox_setup_fresh_clock.snap create mode 100644 codex-rs/tui/src/status_indicator_widget/timer.rs create mode 100644 codex-rs/tui/src/status_indicator_widget/timer_tests.rs diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 75d4ff466e..a594384ff5 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -249,6 +249,8 @@ pub(crate) struct BottomPane { /// Inline status indicator shown above the composer while a task is running. status: Option, inline_banner: Option, + /// Streaming may drop the row without losing its elapsed time or modal pause. + status_timer: crate::status_indicator_widget::StatusTimer, /// Unified exec session summary source. /// /// When a status row exists, this summary is mirrored inline in that row; @@ -320,6 +322,7 @@ impl BottomPane { is_task_running: false, status: None, inline_banner: None, + status_timer: crate::status_indicator_widget::StatusTimer::default(), unified_exec_footer: UnifiedExecFooter::new(), pending_input_preview: PendingInputPreview::new(), pending_thread_approvals: PendingThreadApprovals::new(), @@ -546,6 +549,15 @@ impl BottomPane { self.status.as_ref() } + pub(crate) fn status_elapsed(&self) -> Option { + self.is_task_running + .then(|| self.status_timer.elapsed_at(Instant::now())) + } + + pub(crate) fn reset_status_timer(&mut self, elapsed: Duration) { + self.status_timer.reset(elapsed); + } + pub fn skills(&self) -> Option<&Vec> { self.composer.skills() } @@ -1054,7 +1066,7 @@ impl BottomPane { #[cfg(test)] pub(crate) fn status_indicator_visible(&self) -> bool { - self.status.is_some() + self.status_widget().is_some() } #[cfg(test)] @@ -1085,6 +1097,7 @@ impl BottomPane { if running { if !was_running { + self.status_timer.reset(Duration::ZERO); if self.status.is_none() { self.status = Some(StatusIndicatorWidget::new( self.app_event_tx.clone(), @@ -1121,11 +1134,13 @@ impl BottomPane { pub(crate) fn ensure_status_indicator(&mut self) { if self.status.is_none() { - self.status = Some(StatusIndicatorWidget::new( - self.app_event_tx.clone(), - self.frame_requester.clone(), - self.animations_enabled, - )); + self.status.get_or_insert_with(|| { + StatusIndicatorWidget::new( + self.app_event_tx.clone(), + self.frame_requester.clone(), + self.animations_enabled, + ) + }); if let Some(status) = self.status.as_mut() { status.set_interrupt_binding( self.keymap @@ -1453,7 +1468,7 @@ impl BottomPane { && !(is_agent_command && key_event.code == KeyCode::Esc) && self.no_modal_or_popup_active() && !self.composer_should_handle_vim_insert_escape(key_event) - && self.status.is_some() + && self.status_widget().is_some() } pub(crate) fn terminal_title_requires_action(&self) -> bool { @@ -1714,15 +1729,12 @@ impl BottomPane { } fn pause_status_timer_for_modal(&mut self) { - if let Some(status) = self.status.as_mut() { - status.pause_timer(); - } + self.status_timer.pause_at(Instant::now()); } fn resume_status_timer_after_modal(&mut self) { - if let Some(status) = self.status.as_mut() { - status.resume_timer(); - } + self.status_timer.resume_at(Instant::now()); + self.request_redraw(); } /// Height (terminal rows) required by the current bottom pane. @@ -1854,12 +1866,15 @@ impl BottomPane { { flex.push(/*flex*/ 0, RenderableItem::Borrowed(banner)); } - if let Some(status) = &self.status { - flex.push(/*flex*/ 0, RenderableItem::Borrowed(status)); + if let Some(status) = self.status_widget() { + flex.push( + /*flex*/ 0, + RenderableItem::Owned(Box::new(status.with_timer(&self.status_timer))), + ); } // Avoid double-surfacing the same summary and avoid adding an extra // row while the status line is already visible. - if self.status.is_none() && !self.unified_exec_footer.is_empty() { + if self.status_widget().is_none() && !self.unified_exec_footer.is_empty() { flex.push( /*flex*/ 0, RenderableItem::Borrowed(&self.unified_exec_footer), @@ -1870,7 +1885,7 @@ impl BottomPane { || !self.pending_input_preview.pending_steers.is_empty() || !self.pending_input_preview.rejected_steers.is_empty(); let has_status_or_footer = - self.status.is_some() || !self.unified_exec_footer.is_empty(); + self.status_widget().is_some() || !self.unified_exec_footer.is_empty(); let has_inline_previews = has_pending_thread_approvals || has_pending_input; if has_inline_previews && has_status_or_footer { flex.push(/*flex*/ 0, RenderableItem::Owned("".into())); @@ -2663,6 +2678,53 @@ mod tests { let bufs = snapshot_buffer(&buf); assert!(bufs.contains("• Working"), "expected Working header"); + + pane.reset_status_timer(Duration::from_secs(/*secs*/ 42)); + pane.hide_status_indicator(); + pane.pause_status_timer_for_modal(); + let paused = pane.status_timer.elapsed_at(Instant::now()); + pane.ensure_status_indicator(); + assert_snapshot!( + "status_timer_survives_hidden_row", + render_snapshot(&pane, area) + ); + assert_eq!( + pane.status_timer + .elapsed_at(Instant::now() + Duration::from_secs(/*secs*/ 10)), + paused + ); + pane.resume_status_timer_after_modal(); + assert!( + pane.status_timer + .elapsed_at(Instant::now() + Duration::from_secs(/*secs*/ 10)) + >= paused + Duration::from_secs(/*secs*/ 10) + ); + pane.set_task_running(/*running*/ false); + pane.set_task_running(/*running*/ true); + assert!(pane.status_timer.elapsed_at(Instant::now()) < paused); + } + + #[test] + fn turn_start_keeps_an_outstanding_approval_paused() { + let (tx, _rx) = unbounded_channel::(); + let mut pane = test_pane(AppEventSender::new(tx)); + // MCP startup can already own the running row when an agent turn starts. + pane.set_task_running(/*running*/ true); + pane.push_approval_request(exec_request(), &Features::default()); + pane.hide_status_indicator(); + pane.set_task_running(/*running*/ true); + pane.reset_status_timer(Duration::ZERO); + pane.ensure_status_indicator(); + let later = Instant::now() + Duration::from_secs(/*secs*/ 120); + assert_eq!(pane.status_timer.elapsed_at(later), Duration::ZERO); + + pane.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(pane.no_modal_or_popup_active()); + assert!( + pane.status_timer + .elapsed_at(Instant::now() + Duration::from_secs(/*secs*/ 10)) + >= Duration::from_secs(/*secs*/ 10) + ); } #[test] diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_timer_survives_hidden_row.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_timer_survives_hidden_row.snap new file mode 100644 index 0000000000..13ce9387f4 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_timer_survives_hidden_row.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/bottom_pane/mod.rs +assertion_line: 2570 +expression: "render_snapshot(&pane, area)" +--- +• Working (42s • esc to interrupt) + + +› Ask Codex to do anything + + ? for shortcuts 100% context left diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__windows_sandbox_setup_fresh_clock.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__windows_sandbox_setup_fresh_clock.snap new file mode 100644 index 0000000000..b740b092cf --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__windows_sandbox_setup_fresh_clock.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/chatwidget/tests/permissions.rs +assertion_line: 610 +expression: "setup.replace(&chat.config.cwd.display().to_string(), \"[CWD]\")" +--- +• Setting up sandbox... (0s) + └ Hang tight, this may take a few minutes + + +› Input disabled until setup completes. + + gpt-5.6-sol default · [CWD] diff --git a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs index 396cffe6f5..8a10378e52 100644 --- a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs +++ b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs @@ -875,35 +875,43 @@ async fn unified_exec_wait_before_streamed_agent_message_snapshot() { #[tokio::test] async fn final_worked_for_uses_cumulative_turn_duration_snapshot() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - handle_turn_started(&mut chat, "turn-1"); + for duration_ms in [Some(125_000), None] { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + handle_turn_started(&mut chat, "turn-1"); - let exec = begin_exec_with_source( - &mut chat, - "call-1", - "echo preparing", - ExecCommandSource::Agent, - ); - end_exec(&mut chat, exec, "preparing\n", "", /*exit_code*/ 0); + let exec = begin_exec_with_source( + &mut chat, + "call-1", + "echo preparing", + ExecCommandSource::Agent, + ); + end_exec(&mut chat, exec, "preparing\n", "", /*exit_code*/ 0); - complete_assistant_message( - &mut chat, - "msg-final", - "Final response.", - Some(MessagePhase::FinalAnswer), - ); - handle_turn_completed(&mut chat, "turn-1", Some(125_000)); + chat.bottom_pane + .reset_status_timer(Duration::from_secs(/*secs*/ 125)); + handle_agent_message_delta(&mut chat, "Final response.\n"); + chat.on_commit_tick(); + assert!(!chat.bottom_pane.status_indicator_visible()); - let cells = drain_insert_history(&mut rx); - let combined = cells - .iter() - .map(|lines| lines_to_single_string(lines)) - .collect::(); - assert!( - combined.contains("Worked for 2m 05s"), - "expected final separator to use cumulative turn duration, got:\n{combined}" - ); - assert_chatwidget_snapshot!("final_worked_for_uses_cumulative_turn_duration", combined); + complete_assistant_message( + &mut chat, + "msg-final", + "Final response.", + Some(MessagePhase::FinalAnswer), + ); + handle_turn_completed(&mut chat, "turn-1", duration_ms); + + let cells = drain_insert_history(&mut rx); + let combined = cells + .iter() + .map(|lines| lines_to_single_string(lines)) + .collect::(); + assert!( + combined.contains("Worked for 2m 05s"), + "expected final separator to use cumulative turn duration, got:\n{combined}" + ); + assert_chatwidget_snapshot!("final_worked_for_uses_cumulative_turn_duration", combined); + } } #[tokio::test] diff --git a/codex-rs/tui/src/chatwidget/tests/permissions.rs b/codex-rs/tui/src/chatwidget/tests/permissions.rs index 5ba2723577..9ac937db61 100644 --- a/codex-rs/tui/src/chatwidget/tests/permissions.rs +++ b/codex-rs/tui/src/chatwidget/tests/permissions.rs @@ -598,6 +598,28 @@ async fn fragmented_terminal_response_cannot_acknowledge_world_writable_warning( )); } +#[tokio::test] +async fn windows_sandbox_setup_starts_a_fresh_status_clock() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.bottom_pane + .reset_status_timer(Duration::from_secs(/*secs*/ 125)); + + chat.show_windows_sandbox_setup_status(); + let setup = render_bottom_popup(&chat, /*width*/ 80); + assert!(setup.contains("Setting up sandbox")); + assert!(setup.contains("(0s")); + assert_chatwidget_snapshot!( + "windows_sandbox_setup_fresh_clock", + setup.replace(&chat.config.cwd.display().to_string(), "[CWD]") + ); + + chat.clear_windows_sandbox_setup_status(); + chat.bottom_pane.set_task_running(/*running*/ true); + let working = render_bottom_popup(&chat, /*width*/ 80); + assert!(working.contains("Working")); + assert!(!working.contains("Setting up sandbox")); +} + #[tokio::test] async fn required_windows_sandbox_setup_defers_configured_initial_prompt() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/chatwidget/turn_runtime.rs b/codex-rs/tui/src/chatwidget/turn_runtime.rs index 4fc542fc82..2d3467476d 100644 --- a/codex-rs/tui/src/chatwidget/turn_runtime.rs +++ b/codex-rs/tui/src/chatwidget/turn_runtime.rs @@ -82,6 +82,7 @@ impl ChatWidget { self.quit_shortcut_expires_at = None; self.quit_shortcut_key = None; self.update_task_running_state(); + self.bottom_pane.reset_status_timer(Duration::ZERO); self.status_state.retry_status_header = None; self.clear_active_hook_cell(); self.status_state.pending_status_indicator_restore = false; @@ -156,8 +157,8 @@ impl ChatWidget { .map(|duration_ms| duration_ms / 1_000) .or_else(|| { self.bottom_pane - .status_widget() - .map(crate::status_indicator_widget::StatusIndicatorWidget::elapsed_seconds) + .status_elapsed() + .map(|elapsed| elapsed.as_secs()) }) } else { None diff --git a/codex-rs/tui/src/chatwidget/windows_sandbox_prompts.rs b/codex-rs/tui/src/chatwidget/windows_sandbox_prompts.rs index e11bc47d82..68fcf51c72 100644 --- a/codex-rs/tui/src/chatwidget/windows_sandbox_prompts.rs +++ b/codex-rs/tui/src/chatwidget/windows_sandbox_prompts.rs @@ -479,7 +479,7 @@ impl ChatWidget { #[cfg(not(target_os = "windows"))] pub(crate) fn maybe_prompt_windows_sandbox_enable(&mut self, _show_now: bool) {} - #[cfg(target_os = "windows")] + #[cfg(any(target_os = "windows", test))] pub(crate) fn show_windows_sandbox_setup_status(&mut self) { // While elevated sandbox setup runs, prevent typing so the user doesn't // accidentally queue messages that will run under an unexpected mode. @@ -487,6 +487,7 @@ impl ChatWidget { /*enabled*/ false, Some("Input disabled until setup completes.".to_string()), ); + self.bottom_pane.reset_status_timer(Duration::ZERO); self.bottom_pane.ensure_status_indicator(); self.bottom_pane .set_interrupt_hint_visible(/*visible*/ false); @@ -499,11 +500,11 @@ impl ChatWidget { self.request_redraw(); } - #[cfg(not(target_os = "windows"))] + #[cfg(not(any(target_os = "windows", test)))] #[allow(dead_code)] pub(crate) fn show_windows_sandbox_setup_status(&mut self) {} - #[cfg(target_os = "windows")] + #[cfg(any(target_os = "windows", test))] pub(crate) fn clear_windows_sandbox_setup_status(&mut self) { self.bottom_pane .set_composer_input_enabled(/*enabled*/ true, /*placeholder*/ None); @@ -511,6 +512,6 @@ impl ChatWidget { self.request_redraw(); } - #[cfg(not(target_os = "windows"))] + #[cfg(not(any(target_os = "windows", test)))] pub(crate) fn clear_windows_sandbox_setup_status(&mut self) {} } diff --git a/codex-rs/tui/src/status_indicator_widget.rs b/codex-rs/tui/src/status_indicator_widget.rs index 6e4de3bcab..8f3e8c8977 100644 --- a/codex-rs/tui/src/status_indicator_widget.rs +++ b/codex-rs/tui/src/status_indicator_widget.rs @@ -1,6 +1,6 @@ //! A live task status row rendered above the composer while the agent is busy. //! -//! The row owns spinner timing, the optional interrupt hint, and short inline +//! The row renders a separately owned clock, the optional interrupt hint, and short inline //! context (for example, the unified-exec background-process summary). Keeping //! these pieces on one line avoids vertical layout churn in the bottom pane. @@ -32,6 +32,9 @@ use crate::tui::FrameRequester; use crate::wrapping::RtOptions; use crate::wrapping::word_wrap_lines; +mod timer; +pub(crate) use timer::StatusTimer; + pub(crate) const STATUS_DETAILS_DEFAULT_MAX_LINES: usize = 3; const DETAILS_PREFIX: &str = " └ "; @@ -52,9 +55,6 @@ pub(crate) struct StatusIndicatorWidget { show_interrupt_hint: bool, interrupt_binding: Option, - elapsed_running: Duration, - last_resume_at: Instant, - is_paused: bool, app_event_tx: AppEventSender, frame_requester: FrameRequester, animations_enabled: bool, @@ -90,10 +90,6 @@ impl StatusIndicatorWidget { inline_message: None, show_interrupt_hint: true, interrupt_binding: Some(key_hint::plain(KeyCode::Esc).into()), - elapsed_running: Duration::ZERO, - last_resume_at: Instant::now(), - is_paused: false, - app_event_tx, frame_requester, animations_enabled, @@ -156,45 +152,8 @@ impl StatusIndicatorWidget { self.interrupt_binding = binding; } - pub(crate) fn pause_timer(&mut self) { - self.pause_timer_at(Instant::now()); - } - - pub(crate) fn resume_timer(&mut self) { - self.resume_timer_at(Instant::now()); - } - - pub(crate) fn pause_timer_at(&mut self, now: Instant) { - if self.is_paused { - return; - } - self.elapsed_running += now.saturating_duration_since(self.last_resume_at); - self.is_paused = true; - } - - pub(crate) fn resume_timer_at(&mut self, now: Instant) { - if !self.is_paused { - return; - } - self.last_resume_at = now; - self.is_paused = false; - self.frame_requester.schedule_frame(); - } - - fn elapsed_duration_at(&self, now: Instant) -> Duration { - let mut elapsed = self.elapsed_running; - if !self.is_paused { - elapsed += now.saturating_duration_since(self.last_resume_at); - } - elapsed - } - - fn elapsed_seconds_at(&self, now: Instant) -> u64 { - self.elapsed_duration_at(now).as_secs() - } - - pub fn elapsed_seconds(&self) -> u64 { - self.elapsed_seconds_at(Instant::now()) + pub(crate) fn with_timer<'a>(&'a self, timer: &'a StatusTimer) -> impl Renderable + 'a { + StatusIndicator { row: self, timer } } /// Wrap the details text into a fixed width and return the lines, truncating if necessary. @@ -230,9 +189,14 @@ impl StatusIndicatorWidget { } } -impl Renderable for StatusIndicatorWidget { +struct StatusIndicator<'a> { + row: &'a StatusIndicatorWidget, + timer: &'a StatusTimer, +} + +impl Renderable for StatusIndicator<'_> { fn desired_height(&self, width: u16) -> u16 { - 1 + u16::try_from(self.wrapped_details_lines(width).len()).unwrap_or(0) + 1 + u16::try_from(self.row.wrapped_details_lines(width).len()).unwrap_or(0) } fn render(&self, area: Rect, buf: &mut Buffer) { @@ -240,31 +204,32 @@ impl Renderable for StatusIndicatorWidget { return; } - if self.animations_enabled { + let row = self.row; + if row.animations_enabled { // Schedule next animation frame. - self.frame_requester + row.frame_requester .schedule_frame_in(Duration::from_millis(32)); } let now = Instant::now(); - let elapsed_duration = self.elapsed_duration_at(now); + let elapsed_duration = self.timer.elapsed_at(now); let pretty_elapsed = fmt_elapsed_compact(elapsed_duration.as_secs()); - let motion_mode = MotionMode::from_animations_enabled(self.animations_enabled); + let motion_mode = MotionMode::from_animations_enabled(row.animations_enabled); let mut spans = Vec::with_capacity(5); if let Some(indicator) = activity_indicator( - Some(self.last_resume_at), + Some(self.timer.last_resume_at), motion_mode, ReducedMotionIndicator::Hidden, ) { spans.push(indicator); spans.push(" ".into()); } - spans.extend(shimmer_text(&self.header, motion_mode)); + spans.extend(shimmer_text(&row.header, motion_mode)); if !spans.is_empty() { spans.push(" ".into()); } - if self.show_interrupt_hint - && let Some(interrupt_binding) = self.interrupt_binding + if row.show_interrupt_hint + && let Some(interrupt_binding) = row.interrupt_binding { spans.extend(vec![ format!("({pretty_elapsed} • ").dim(), @@ -274,7 +239,7 @@ impl Renderable for StatusIndicatorWidget { } else { spans.push(format!("({pretty_elapsed})").dim()); } - if let Some(message) = &self.inline_message { + if let Some(message) = &row.inline_message { // Keep optional context after elapsed/interrupt text so that core // interrupt affordances stay in a fixed visual location. spans.push(" · ".dim()); @@ -288,7 +253,7 @@ impl Renderable for StatusIndicatorWidget { )); if area.height > 1 { // If there is enough space, add the details lines below the header. - let details = self.wrapped_details_lines(area.width); + let details = row.wrapped_details_lines(area.width); let max_details = usize::from(area.height.saturating_sub(1)); lines.extend(details.into_iter().take(max_details)); } @@ -304,8 +269,6 @@ mod tests { use crate::app_event_sender::AppEventSender; use ratatui::Terminal; use ratatui::backend::TestBackend; - use std::time::Duration; - use std::time::Instant; use tokio::sync::mpsc::unbounded_channel; use pretty_assertions::assert_eq; @@ -328,6 +291,7 @@ mod tests { fn renders_with_working_header() { let (tx_raw, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); + let timer = StatusTimer::default(); let w = StatusIndicatorWidget::new( tx, crate::tui::FrameRequester::test_dummy(), @@ -337,7 +301,7 @@ mod tests { // Render into a fixed-size test terminal and snapshot the backend. let mut terminal = Terminal::new(TestBackend::new(80, 2)).expect("terminal"); terminal - .draw(|f| w.render(f.area(), f.buffer_mut())) + .draw(|f| w.with_timer(&timer).render(f.area(), f.buffer_mut())) .expect("draw"); insta::assert_snapshot!(terminal.backend()); } @@ -346,6 +310,7 @@ mod tests { fn renders_truncated() { let (tx_raw, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); + let timer = StatusTimer::default(); let w = StatusIndicatorWidget::new( tx, crate::tui::FrameRequester::test_dummy(), @@ -355,7 +320,7 @@ mod tests { // Render into a fixed-size test terminal and snapshot the backend. let mut terminal = Terminal::new(TestBackend::new(20, 2)).expect("terminal"); terminal - .draw(|f| w.render(f.area(), f.buffer_mut())) + .draw(|f| w.with_timer(&timer).render(f.area(), f.buffer_mut())) .expect("draw"); insta::assert_snapshot!(terminal.backend()); } @@ -377,14 +342,14 @@ mod tests { w.set_interrupt_hint_visible(/*visible*/ false); // Freeze time-dependent rendering (elapsed + spinner) to keep the snapshot stable. - w.is_paused = true; - w.elapsed_running = Duration::ZERO; + let mut timer = StatusTimer::default(); + timer.pause_at(timer.last_resume_at); // Prefix is 4 columns, so a width of 30 yields a content width of 26: one column // short of fitting the whole phrase (27 cols), forcing exactly one wrap without ellipsis. let mut terminal = Terminal::new(TestBackend::new(30, 3)).expect("terminal"); terminal - .draw(|f| w.render(f.area(), f.buffer_mut())) + .draw(|f| w.with_timer(&timer).render(f.area(), f.buffer_mut())) .expect("draw"); insta::assert_snapshot!(terminal.backend()); } @@ -393,17 +358,17 @@ mod tests { fn renders_without_spinner_when_animations_disabled() { let (tx_raw, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); - let mut w = StatusIndicatorWidget::new( + let w = StatusIndicatorWidget::new( tx, crate::tui::FrameRequester::test_dummy(), /*animations_enabled*/ false, ); - w.is_paused = true; - w.elapsed_running = Duration::ZERO; + let mut timer = StatusTimer::default(); + timer.pause_at(timer.last_resume_at); let mut terminal = Terminal::new(TestBackend::new(80, 1)).expect("terminal"); terminal - .draw(|f| w.render(f.area(), f.buffer_mut())) + .draw(|f| w.with_timer(&timer).render(f.area(), f.buffer_mut())) .expect("draw"); let line = terminal.backend().buffer().content()[..80] .iter() @@ -423,41 +388,16 @@ mod tests { /*animations_enabled*/ false, ); w.set_interrupt_binding(Some(key_hint::plain(KeyCode::F(12)).into())); - w.is_paused = true; - w.elapsed_running = Duration::ZERO; + let mut timer = StatusTimer::default(); + timer.pause_at(timer.last_resume_at); let mut terminal = Terminal::new(TestBackend::new(80, 1)).expect("terminal"); terminal - .draw(|f| w.render(f.area(), f.buffer_mut())) + .draw(|f| w.with_timer(&timer).render(f.area(), f.buffer_mut())) .expect("draw"); insta::assert_snapshot!(terminal.backend()); } - #[test] - fn timer_pauses_when_requested() { - let (tx_raw, _rx) = unbounded_channel::(); - let tx = AppEventSender::new(tx_raw); - let mut widget = StatusIndicatorWidget::new( - tx, - crate::tui::FrameRequester::test_dummy(), - /*animations_enabled*/ true, - ); - - let baseline = Instant::now(); - widget.last_resume_at = baseline; - - let before_pause = widget.elapsed_seconds_at(baseline + Duration::from_secs(5)); - assert_eq!(before_pause, 5); - - widget.pause_timer_at(baseline + Duration::from_secs(5)); - let paused_elapsed = widget.elapsed_seconds_at(baseline + Duration::from_secs(10)); - assert_eq!(paused_elapsed, before_pause); - - widget.resume_timer_at(baseline + Duration::from_secs(10)); - let after_resume = widget.elapsed_seconds_at(baseline + Duration::from_secs(13)); - assert_eq!(after_resume, before_pause + 3); - } - #[test] fn details_overflow_adds_ellipsis() { let (tx_raw, _rx) = unbounded_channel::(); diff --git a/codex-rs/tui/src/status_indicator_widget/timer.rs b/codex-rs/tui/src/status_indicator_widget/timer.rs new file mode 100644 index 0000000000..5cb3a2c69d --- /dev/null +++ b/codex-rs/tui/src/status_indicator_widget/timer.rs @@ -0,0 +1,57 @@ +//! Pause-aware status time owned independently of the optional status row. + +use std::time::Duration; +use std::time::Instant; + +#[derive(Debug)] +pub(crate) struct StatusTimer { + pub(super) elapsed_running: Duration, + pub(super) last_resume_at: Instant, + pub(super) is_paused: bool, +} + +impl Default for StatusTimer { + fn default() -> Self { + Self { + elapsed_running: Duration::ZERO, + last_resume_at: Instant::now(), + is_paused: false, + } + } +} + +impl StatusTimer { + pub(crate) fn reset(&mut self, elapsed: Duration) { + self.elapsed_running = elapsed; + self.last_resume_at = Instant::now(); + // A turn can start while an MCP elicitation or approval is still open. + // Reset elapsed time without changing that modal's pause state. + } + + pub(crate) fn pause_at(&mut self, now: Instant) { + if !self.is_paused { + self.elapsed_running += now.saturating_duration_since(self.last_resume_at); + self.is_paused = true; + } + } + + pub(crate) fn resume_at(&mut self, now: Instant) { + if self.is_paused { + self.last_resume_at = now; + self.is_paused = false; + } + } + + pub(crate) fn elapsed_at(&self, now: Instant) -> Duration { + self.elapsed_running + + if self.is_paused { + Duration::ZERO + } else { + now.saturating_duration_since(self.last_resume_at) + } + } +} + +#[cfg(test)] +#[path = "timer_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/status_indicator_widget/timer_tests.rs b/codex-rs/tui/src/status_indicator_widget/timer_tests.rs new file mode 100644 index 0000000000..25144c9102 --- /dev/null +++ b/codex-rs/tui/src/status_indicator_widget/timer_tests.rs @@ -0,0 +1,34 @@ +use super::*; +use pretty_assertions::assert_eq; + +#[test] +fn pauses_and_resumes_without_counting_wait_time() { + let baseline = Instant::now(); + let mut timer = StatusTimer { + last_resume_at: baseline, + ..StatusTimer::default() + }; + timer.pause_at(baseline + Duration::from_secs(5)); + timer.pause_at(baseline + Duration::from_secs(8)); + assert_eq!( + timer.elapsed_at(baseline + Duration::from_secs(10)), + Duration::from_secs(5) + ); + timer.resume_at(baseline + Duration::from_secs(10)); + timer.resume_at(baseline + Duration::from_secs(12)); + assert_eq!( + timer.elapsed_at(baseline + Duration::from_secs(13)), + Duration::from_secs(8) + ); + timer.reset(Duration::from_secs(24)); + assert_eq!( + timer.elapsed_at(timer.last_resume_at), + Duration::from_secs(24) + ); + timer.pause_at(timer.last_resume_at); + timer.reset(Duration::ZERO); + assert_eq!( + timer.elapsed_at(timer.last_resume_at + Duration::from_secs(/*secs*/ 120)), + Duration::ZERO + ); +}