mirror of
https://github.com/openai/codex.git
synced 2026-10-25 15:56:38 +00:00
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:
committed by
copyberry
parent
ed42068c45
commit
6525b95dae
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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!(
|
||||
|
||||
Reference in New Issue
Block a user