diff --git a/codex-rs/tui/src/app/startup.rs b/codex-rs/tui/src/app/startup.rs index 2fa3aed86d..d17f58a30d 100644 --- a/codex-rs/tui/src/app/startup.rs +++ b/codex-rs/tui/src/app/startup.rs @@ -800,6 +800,17 @@ See the Codex keymap documentation for supported actions and examples." } AppRunControl::Continue } + () = async { + match app.chat_widget.terminal_title_next_refresh { + Some(deadline) => { + tokio::time::sleep_until(tokio::time::Instant::from_std(deadline)).await; + } + None => std::future::pending().await, + } + } => { + app.chat_widget.refresh_terminal_title(); + AppRunControl::Continue + } }; if App::should_stop_waiting_for_initial_session( waiting_for_initial_session_configured, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 15c9b321ec..6f0367cd46 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -740,6 +740,8 @@ pub(crate) struct ChatWidget { terminal_title_setup_original_items: Option>>, // Baseline instant used to animate spinner-prefixed title statuses. terminal_title_animation_origin: Instant, + // The foreground loop refreshes the title at this deadline without drawing a frame. + pub(crate) terminal_title_next_refresh: Option, // Cached project-root display name keyed by cwd for status/title rendering. status_line_project_root_name_cache: Option, // Cached git branch name for the status line (None if unknown). diff --git a/codex-rs/tui/src/chatwidget/constructor.rs b/codex-rs/tui/src/chatwidget/constructor.rs index 0fde03a218..c4fa4c9c77 100644 --- a/codex-rs/tui/src/chatwidget/constructor.rs +++ b/codex-rs/tui/src/chatwidget/constructor.rs @@ -234,6 +234,7 @@ impl ChatWidget { last_terminal_title_requires_action: false, terminal_title_setup_original_items: None, terminal_title_animation_origin: Instant::now(), + terminal_title_next_refresh: None, status_line_project_root_name_cache: None, status_line_branch: None, status_line_branch_cwd: None, diff --git a/codex-rs/tui/src/chatwidget/status_surfaces.rs b/codex-rs/tui/src/chatwidget/status_surfaces.rs index 8f37252bf3..e79c0d3ca6 100644 --- a/codex-rs/tui/src/chatwidget/status_surfaces.rs +++ b/codex-rs/tui/src/chatwidget/status_surfaces.rs @@ -244,11 +244,15 @@ impl ChatWidget { /// Empty selections clear the managed title. Non-empty selections render the /// current values in configured order, skip unavailable segments, and cache /// the last successfully written title so redundant OSC writes are avoided. - /// When the `activity` item is present in an animated running state, this also - /// schedules the next frame so the title animation keeps advancing. + /// Animated titles record their next refresh for the foreground loop, independently + /// of full TUI redraws. fn refresh_terminal_title_from_selections(&mut self, selections: &StatusSurfaceSelections) { self.last_terminal_title_requires_action = self.terminal_title_shows_action_required_with_selections(selections); + let now = Instant::now(); + self.terminal_title_next_refresh = self + .terminal_title_animation_interval_with_selections(selections) + .map(|interval| now + interval); if selections.terminal_title_items.is_empty() { if let Err(err) = self.clear_managed_terminal_title() { tracing::debug!(error = %err, "failed to clear terminal title"); @@ -256,13 +260,8 @@ impl ChatWidget { return; } - let now = Instant::now(); let title = self.terminal_title_text_for_selections(selections, now); - let animation_interval = self.terminal_title_animation_interval_with_selections(selections); if self.last_terminal_title == title { - if let Some(interval) = animation_interval { - self.frame_requester.schedule_frame_in(interval); - } return; } match title { @@ -285,10 +284,6 @@ impl ChatWidget { } } } - - if let Some(interval) = animation_interval { - self.frame_requester.schedule_frame_in(interval); - } } /// Recomputes both status surfaces from one shared config snapshot. diff --git a/codex-rs/tui/src/chatwidget/tests/terminal_title.rs b/codex-rs/tui/src/chatwidget/tests/terminal_title.rs index 7445162759..731fe0e593 100644 --- a/codex-rs/tui/src/chatwidget/tests/terminal_title.rs +++ b/codex-rs/tui/src/chatwidget/tests/terminal_title.rs @@ -6,8 +6,20 @@ use pretty_assertions::assert_eq; #[tokio::test] async fn terminal_title_shows_action_required_while_exec_approval_is_pending() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let (frame_requester, mut draw_rx) = FrameRequester::test_channel(); + chat.frame_requester = frame_requester; chat.bottom_pane.set_task_running(/*running*/ true); + let before_refresh = Instant::now(); chat.refresh_terminal_title(); + let spinner_interval = std::time::Duration::from_millis(/*millis*/ 100); + assert!( + (before_refresh + spinner_interval..=Instant::now() + spinner_interval) + .contains(&chat.terminal_title_next_refresh.expect("spinner deadline")) + ); + assert_eq!( + draw_rx.try_recv(), + Err(tokio::sync::mpsc::error::TryRecvError::Empty) + ); let request = ExecApprovalRequestEvent { call_id: "call-action-required".into(), @@ -25,7 +37,17 @@ async fn terminal_title_shows_action_required_while_exec_approval_is_pending() { }; handle_exec_approval_request(&mut chat, "sub-action-required", request); + let before_refresh = Instant::now(); + chat.terminal_title_animation_origin = before_refresh; chat.pre_draw_tick(); + let blink_interval = std::time::Duration::from_secs(/*secs*/ 1); + assert!( + (before_refresh + blink_interval..=Instant::now() + blink_interval).contains( + &chat + .terminal_title_next_refresh + .expect("action-required deadline") + ) + ); assert_eq!( chat.last_terminal_title, @@ -43,6 +65,18 @@ async fn terminal_title_shows_action_required_while_exec_approval_is_pending() { assert!(title.contains("project")); assert!(!title.contains("Action Required")); assert!(chat.should_animate_terminal_title_spinner()); + + for (animations, title_items) in [(false, None), (true, Some(Vec::new()))] { + chat.config.animations = true; + chat.config.tui_terminal_title = None; + chat.refresh_terminal_title(); + assert!(chat.terminal_title_next_refresh.is_some()); + + chat.config.animations = animations; + chat.config.tui_terminal_title = title_items; + chat.refresh_terminal_title(); + assert!(chat.terminal_title_next_refresh.is_none()); + } } #[tokio::test] @@ -78,7 +112,6 @@ async fn terminal_title_action_required_respects_spinner_setting() { async fn terminal_title_action_required_blinks_when_animations_are_enabled() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.bottom_pane.set_task_running(/*running*/ true); - chat.terminal_title_animation_origin = Instant::now() - std::time::Duration::from_millis(1500); chat.refresh_terminal_title(); let request = ExecApprovalRequestEvent { @@ -97,6 +130,8 @@ async fn terminal_title_action_required_blinks_when_animations_are_enabled() { }; handle_exec_approval_request(&mut chat, "sub-blink", request); + chat.terminal_title_animation_origin = + Instant::now() - std::time::Duration::from_millis(/*millis*/ 1500); chat.pre_draw_tick(); assert_eq!(