Refresh animated terminal titles without redrawing the TUI (#40618)

## Why

Terminal title animations only need to update the title, but each animation tick
previously requested a full TUI frame.

## What changed

- Track the next terminal title refresh deadline on `ChatWidget`.
- Have the foreground event loop refresh the title when that deadline expires.
- Clear the deadline when title animations are disabled or the configured title is
  empty.

## Testing

- Verify spinner and action-required animations set the expected refresh deadlines.
- Verify refreshing an animated title does not enqueue a TUI draw request.

GitOrigin-RevId: 269347cd48de4ad9ba9a0aef9ed105c25d1a7dae
This commit is contained in:
Tamir Duberstein
2026-08-25 14:24:18 +00:00
committed by copyberry
parent ed42068c45
commit 6525b95dae
5 changed files with 56 additions and 12 deletions
+11
View File
@@ -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,
+2
View File
@@ -740,6 +740,8 @@ pub(crate) struct ChatWidget {
terminal_title_setup_original_items: Option<Option<Vec<String>>>,
// 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<Instant>,
// Cached project-root display name keyed by cwd for status/title rendering.
status_line_project_root_name_cache: Option<CachedProjectRootName>,
// Cached git branch name for the status line (None if unknown).
@@ -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,
+6 -11
View File
@@ -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.
@@ -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!(