From be23fe1353cd97384824962351a2c743b5d3ee2c Mon Sep 17 00:00:00 2001 From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com> Date: Thu, 4 Sep 2025 12:37:43 -0700 Subject: [PATCH] Pause status timer while modals are open (#3131) Summary: - pause the status timer while waiting on approval modals - expose deterministic pause/resume helpers to avoid sleep-based tests - simplify bottom pane timer handling now that the widget owns the clock --- codex-rs/tui/src/bottom_pane/mod.rs | 21 +++++++ codex-rs/tui/src/status_indicator_widget.rs | 70 ++++++++++++++++++++- 2 files changed, 88 insertions(+), 3 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 5ab32849..d5daea2e 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -162,6 +162,8 @@ impl BottomPane { view.handle_key_event(self, key_event); if !view.is_complete() { self.active_view = Some(view); + } else { + self.on_active_view_complete(); } self.request_redraw(); InputResult::None @@ -201,6 +203,8 @@ impl BottomPane { CancellationEvent::Handled => { if !view.is_complete() { self.active_view = Some(view); + } else { + self.on_active_view_complete(); } self.show_ctrl_c_quit_hint(); } @@ -381,10 +385,27 @@ impl BottomPane { // Otherwise create a new approval modal overlay. let modal = ApprovalModalView::new(request, self.app_event_tx.clone()); + self.pause_status_timer_for_modal(); self.active_view = Some(Box::new(modal)); self.request_redraw() } + fn on_active_view_complete(&mut self) { + self.resume_status_timer_after_modal(); + } + + fn pause_status_timer_for_modal(&mut self) { + if let Some(status) = self.status.as_mut() { + status.pause_timer(); + } + } + + fn resume_status_timer_after_modal(&mut self) { + if let Some(status) = self.status.as_mut() { + status.resume_timer(); + } + } + /// Height (terminal rows) required by the current bottom pane. pub(crate) fn request_redraw(&self) { self.frame_requester.schedule_frame(); diff --git a/codex-rs/tui/src/status_indicator_widget.rs b/codex-rs/tui/src/status_indicator_widget.rs index 126f3d12..07ecba0c 100644 --- a/codex-rs/tui/src/status_indicator_widget.rs +++ b/codex-rs/tui/src/status_indicator_widget.rs @@ -26,7 +26,9 @@ pub(crate) struct StatusIndicatorWidget { /// Queued user messages to display under the status line. queued_messages: Vec, - start_time: Instant, + elapsed_running: Duration, + last_resume_at: Instant, + is_paused: bool, app_event_tx: AppEventSender, frame_requester: FrameRequester, } @@ -36,7 +38,9 @@ impl StatusIndicatorWidget { Self { header: String::from("Working"), queued_messages: Vec::new(), - start_time: Instant::now(), + elapsed_running: Duration::ZERO, + last_resume_at: Instant::now(), + is_paused: false, app_event_tx, frame_requester, @@ -88,6 +92,43 @@ impl StatusIndicatorWidget { // Ensure a redraw so changes are visible. self.frame_requester.schedule_frame(); } + + 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_seconds_at(&self, now: Instant) -> u64 { + let mut elapsed = self.elapsed_running; + if !self.is_paused { + elapsed += now.saturating_duration_since(self.last_resume_at); + } + elapsed.as_secs() + } + + fn elapsed_seconds(&self) -> u64 { + self.elapsed_seconds_at(Instant::now()) + } } impl WidgetRef for StatusIndicatorWidget { @@ -99,7 +140,7 @@ impl WidgetRef for StatusIndicatorWidget { // Schedule next animation frame. self.frame_requester .schedule_frame_in(Duration::from_millis(32)); - let elapsed = self.start_time.elapsed().as_secs(); + let elapsed = self.elapsed_seconds(); // Plain rendering: no borders or padding so the live cell is visually indistinguishable from terminal scrollback. let mut spans = vec![" ".into()]; @@ -147,6 +188,8 @@ 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; #[test] @@ -191,4 +234,25 @@ mod tests { .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()); + + 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); + } }