Avoid redundant TUI redraws while streaming (#34049)

## What changed

- Redraw assistant and plan stream tails only when a completed line changes the visible tail.
- Cache the first reasoning header and update the status indicator only when its visible state changes, while restoring the cached header if the indicator is recreated.
- Keep regular commit ticks able to clear stale stream tails.

## Testing

- Add coverage for unchanged partial deltas, changed newline-delimited tails, reasoning-header redraws, recreated status indicators, stale plan tails, and history-line draw scheduling.

GitOrigin-RevId: 9016175f97ef2db2edc2fd5ba13a4d731dc2d98c
This commit is contained in:
Charlie Marsh
2026-07-18 18:19:39 +00:00
committed by copyberry
parent 028edf8c1e
commit 35eaf3ffb0
18 changed files with 360 additions and 45 deletions
+5 -2
View File
@@ -917,19 +917,22 @@ impl BottomPane {
/// Update the status indicator header (defaults to "Working") and details below it.
///
/// Passing `None` clears any existing details. No-ops if the status indicator is not active.
/// Passing `None` clears any existing details. Returns whether the active status indicator
/// was updated and requested a redraw.
pub(crate) fn update_status(
&mut self,
header: String,
details: Option<String>,
details_capitalization: StatusDetailsCapitalization,
details_max_lines: usize,
) {
) -> bool {
if let Some(status) = self.status.as_mut() {
status.update_header(header);
status.update_details(details, details_capitalization, details_max_lines.max(1));
self.request_redraw();
return true;
}
false
}
/// Show the transient "press again to quit" hint for `key`.
+2
View File
@@ -630,6 +630,8 @@ pub(crate) struct ChatWidget {
interrupts: InterruptManager,
// Accumulates the current reasoning block text to extract a header
reasoning_buffer: String,
// Caches the first completed bold header so later deltas do not rescan the whole block.
reasoning_header: Option<String>,
// Preserves reasoning-summary part boundaries for transcript-only recording.
reasoning_summary_parts: Vec<String>,
status_state: StatusState,
@@ -179,6 +179,7 @@ impl ChatWidget {
newly_installed_marketplace_tab_id: None,
interrupts: InterruptManager::new(),
reasoning_buffer: String::new(),
reasoning_header: None,
reasoning_summary_parts: Vec::new(),
status_state: StatusState::default(),
review: ReviewState::default(),
@@ -42,6 +42,7 @@ impl ChatWidget {
// Submitted is emitted when user submits.
// Reset any reasoning header only when we are actually submitting a turn.
self.reasoning_buffer.clear();
self.reasoning_header = None;
self.reasoning_summary_parts.clear();
self.set_status_header(String::from("Working"));
self.submit_user_message(user_message);
@@ -739,6 +739,7 @@ impl ChatWidget {
);
if self.is_session_configured() {
self.reasoning_buffer.clear();
self.reasoning_header = None;
self.reasoning_summary_parts.clear();
self.set_status_header(String::from("Working"));
self.submit_user_message(user_message);
@@ -0,0 +1,11 @@
---
source: tui/src/chatwidget/tests/status_and_layout.rs
expression: normalized_backend_snapshot(terminal.backend())
---
" "
"• Checking files (0s • esc to interrupt) · 1 background terminal running · /ps …"
" "
" "
"› Ask Codex to do anything "
" "
" gpt-5.6-sol default · /tmp/project "
@@ -9,14 +9,15 @@ use super::*;
impl ChatWidget {
/// Update the status indicator header and details.
///
/// Passing `None` clears any existing details.
/// Passing `None` clears any existing details. Returns whether the visible status indicator
/// requested a redraw.
pub(super) fn set_status(
&mut self,
header: String,
details: Option<String>,
details_capitalization: StatusDetailsCapitalization,
details_max_lines: usize,
) {
) -> bool {
let details = details
.filter(|details| !details.is_empty())
.map(|details| {
@@ -33,7 +34,7 @@ impl ChatWidget {
details: details.clone(),
details_max_lines,
});
self.bottom_pane.update_status(
let status_indicator_updated = self.bottom_pane.update_status(
header,
details,
StatusDetailsCapitalization::Preserve,
@@ -51,17 +52,19 @@ impl ChatWidget {
if title_uses_status {
self.refresh_status_surfaces();
}
status_indicator_updated
}
/// Convenience wrapper around [`Self::set_status`];
/// updates the status indicator header and clears any existing details.
pub(super) fn set_status_header(&mut self, header: String) {
/// updates the status indicator header and clears any existing details, returning whether the
/// visible status indicator requested a redraw.
pub(super) fn set_status_header(&mut self, header: String) -> bool {
self.set_status(
header,
/*details*/ None,
StatusDetailsCapitalization::CapitalizeFirst,
STATUS_DETAILS_DEFAULT_MAX_LINES,
);
)
}
/// Sets the currently rendered footer status-line value.
+86 -31
View File
@@ -7,7 +7,10 @@ use super::*;
impl ChatWidget {
pub(super) fn restore_reasoning_status_header(&mut self) {
if let Some(header) = extract_first_bold(&self.reasoning_buffer) {
if self.reasoning_header.is_none() {
self.reasoning_header = extract_first_bold(&self.reasoning_buffer);
}
if let Some(header) = self.reasoning_header.clone() {
self.status_state.terminal_title_status_kind = TerminalTitleStatusKind::Thinking;
self.set_status_header(header);
} else if self.bottom_pane.is_task_running() {
@@ -149,8 +152,10 @@ impl ChatWidget {
self.app_event_tx.send(AppEvent::StartCommitAnimation);
self.run_catch_up_commit_tick();
}
self.sync_active_stream_tail();
self.request_redraw();
// Unterminated source is buffered by the controller and cannot change the visible tail.
if delta.contains('\n') && self.sync_active_stream_tail() {
self.request_redraw();
}
}
pub(super) fn on_plan_item_completed(&mut self, text: String) {
@@ -213,24 +218,41 @@ impl ChatWidget {
self.reasoning_buffer.push_str(&delta);
if self.safety_buffering_is_waiting() {
self.request_redraw();
return;
}
if self.unified_exec_wait_streak.is_some() {
// Unified exec waiting should take precedence over reasoning-derived status headers.
self.request_redraw();
return;
}
if let Some(header) = extract_first_bold(&self.reasoning_buffer) {
// Update the shimmer header to the extracted reasoning chunk header.
self.status_state.terminal_title_status_kind = TerminalTitleStatusKind::Thinking;
self.set_status_header(header);
} else {
// Fallback while we don't yet have a bold header: leave existing header as-is.
if self.reasoning_header.is_none() {
self.reasoning_header = extract_first_bold(&self.reasoning_buffer);
}
let Some(header) = self.reasoning_header.as_deref() else {
// Fallback while we don't yet have a bold header: leave existing header as-is.
return;
};
let status = &self.status_state.current_status;
if self.status_state.terminal_title_status_kind == TerminalTitleStatusKind::Thinking
&& status.header == header
&& status.details.is_none()
&& status.details_max_lines == STATUS_DETAILS_DEFAULT_MAX_LINES
&& self
.bottom_pane
.status_widget()
.is_none_or(|status| status.header() == header)
{
return;
}
// Update the shimmer header to the extracted reasoning chunk header.
let header = header.to_string();
self.status_state.terminal_title_status_kind = TerminalTitleStatusKind::Thinking;
if !self.set_status_header(header) {
self.request_redraw();
}
self.request_redraw();
}
pub(super) fn on_agent_reasoning_final(&mut self) {
@@ -245,6 +267,7 @@ impl ChatWidget {
self.add_boxed_history(cell);
}
self.reasoning_buffer.clear();
self.reasoning_header = None;
self.reasoning_summary_parts.clear();
self.request_redraw();
}
@@ -255,6 +278,7 @@ impl ChatWidget {
self.reasoning_summary_parts
.push(std::mem::take(&mut self.reasoning_buffer));
}
self.reasoning_header = None;
}
pub(super) fn on_stream_error(&mut self, message: String, additional_details: Option<String>) {
@@ -352,7 +376,9 @@ impl ChatWidget {
self.bottom_pane.hide_status_indicator();
self.add_boxed_history(cell);
}
self.sync_active_stream_tail();
if scope == CommitTickScope::AnyMode || outcome.has_controller {
self.sync_active_stream_tail();
}
if outcome.has_controller && outcome.all_idle {
self.maybe_restore_status_indicator_after_stream_idle();
@@ -434,8 +460,10 @@ impl ChatWidget {
self.app_event_tx.send(AppEvent::StartCommitAnimation);
self.run_catch_up_commit_tick();
}
self.sync_active_stream_tail();
self.request_redraw();
// Unterminated source is buffered by the controller and cannot change the visible tail.
if delta.contains('\n') && self.sync_active_stream_tail() {
self.request_redraw();
}
}
pub(super) fn active_cell_is_stream_tail(&self) -> bool {
@@ -450,47 +478,74 @@ impl ChatWidget {
&& self.active_cell_is_stream_tail()
}
pub(super) fn sync_active_stream_tail(&mut self) {
pub(super) fn sync_active_stream_tail(&mut self) -> bool {
if let Some(controller) = self.stream_controller.as_ref() {
let tail_lines = controller.current_tail_lines();
if tail_lines.is_empty() {
self.clear_active_stream_tail();
return;
return self.clear_active_stream_tail();
}
self.bottom_pane.hide_status_indicator();
self.transcript.active_cell =
Some(Box::new(history_cell::StreamingAgentTailCell::new(
tail_lines,
controller.tail_starts_stream(),
)));
let cell = history_cell::StreamingAgentTailCell::new(
tail_lines,
controller.tail_starts_stream(),
);
if self
.transcript
.active_cell
.as_ref()
.and_then(|active| {
active
.as_any()
.downcast_ref::<history_cell::StreamingAgentTailCell>()
})
.is_some_and(|active| active == &cell)
{
return false;
}
self.transcript.active_cell = Some(Box::new(cell));
self.bump_active_cell_revision();
return;
return true;
}
if let Some(controller) = self.plan_stream_controller.as_ref() {
let tail_lines = controller.current_tail_display_lines();
if tail_lines.is_empty() {
self.clear_active_stream_tail();
return;
return self.clear_active_stream_tail();
}
self.bottom_pane.hide_status_indicator();
self.transcript.active_cell = Some(Box::new(history_cell::StreamingPlanTailCell::new(
let cell = history_cell::StreamingPlanTailCell::new(
tail_lines,
!controller.tail_starts_stream(),
)));
);
if self
.transcript
.active_cell
.as_ref()
.and_then(|active| {
active
.as_any()
.downcast_ref::<history_cell::StreamingPlanTailCell>()
})
.is_some_and(|active| active == &cell)
{
return false;
}
self.transcript.active_cell = Some(Box::new(cell));
self.bump_active_cell_revision();
return;
return true;
}
self.clear_active_stream_tail();
self.clear_active_stream_tail()
}
pub(super) fn clear_active_stream_tail(&mut self) {
pub(super) fn clear_active_stream_tail(&mut self) -> bool {
if self.active_cell_is_stream_tail() {
self.transcript.active_cell = None;
self.bump_active_cell_revision();
return true;
}
false
}
}
+3 -1
View File
@@ -154,6 +154,7 @@ pub(super) async fn make_chatwidget_manual(
model_override,
/*has_chatgpt_account*/ false,
/*has_codex_backend_auth*/ false,
FrameRequester::test_dummy(),
)
.await
}
@@ -162,6 +163,7 @@ pub(super) async fn make_chatwidget_manual_with_auth(
model_override: Option<&str>,
has_chatgpt_account: bool,
has_codex_backend_auth: bool,
frame_requester: FrameRequester,
) -> (
ChatWidget,
tokio::sync::mpsc::UnboundedReceiver<AppEvent>,
@@ -181,7 +183,7 @@ pub(super) async fn make_chatwidget_manual_with_auth(
let model_catalog = test_model_catalog(&cfg);
let common = ChatWidgetInit {
config: cfg,
frame_requester: FrameRequester::test_dummy(),
frame_requester,
app_event_tx,
workspace_command_runner: None,
initial_user_message: None,
@@ -1174,6 +1174,28 @@ async fn plan_completion_restores_status_indicator_after_streaming_plan_output()
assert_eq!(chat.bottom_pane.is_task_running(), true);
}
#[tokio::test]
async fn unterminated_plan_delta_does_not_redraw_unchanged_stream_tail() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true);
let plan_mask = collaboration_modes::mask_for_kind(chat.model_catalog.as_ref(), ModeKind::Plan)
.expect("expected plan collaboration mask");
chat.set_collaboration_mask(plan_mask);
chat.on_plan_delta("| Step | Owner |\n".to_string());
assert!(chat.active_cell_is_stream_tail());
let revision = chat.transcript.active_cell_revision;
let (frame_requester, mut draw_rx) = FrameRequester::test_channel();
chat.frame_requester = frame_requester;
chat.on_plan_delta("| partial".to_string());
assert_eq!(chat.transcript.active_cell_revision, revision);
assert!(matches!(
draw_rx.try_recv(),
Err(tokio::sync::mpsc::error::TryRecvError::Empty)
));
}
#[tokio::test]
async fn submit_user_message_queues_while_compaction_turn_is_running() {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
@@ -1308,8 +1308,10 @@ async fn usage_command_runs_with_backend_auth_without_chatgpt_account_flag() {
#[tokio::test]
async fn usage_command_runs_with_backend_auth_from_widget_init() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual_with_auth(
/*model_override*/ None, /*has_chatgpt_account*/ false,
/*model_override*/ None,
/*has_chatgpt_account*/ false,
/*has_codex_backend_auth*/ true,
FrameRequester::test_dummy(),
)
.await;
@@ -3767,6 +3767,190 @@ async fn deltas_then_same_final_message_are_rendered_snapshot() {
);
}
#[tokio::test]
async fn unterminated_agent_delta_does_not_redraw_unchanged_stream_tail() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.handle_streaming_delta("| Step | Owner |\n".to_string());
assert!(chat.active_cell_is_stream_tail());
let revision = chat.transcript.active_cell_revision;
let (frame_requester, mut draw_rx) = FrameRequester::test_channel();
chat.frame_requester = frame_requester;
chat.handle_streaming_delta("| partial".to_string());
assert_eq!(chat.transcript.active_cell_revision, revision);
assert!(matches!(
draw_rx.try_recv(),
Err(tokio::sync::mpsc::error::TryRecvError::Empty)
));
}
#[tokio::test]
async fn newline_agent_delta_redraws_stream_tail_after_noop_catch_up() {
let (frame_requester, mut draw_rx) = FrameRequester::test_channel();
let (mut chat, _rx, _op_rx) = make_chatwidget_manual_with_auth(
/*model_override*/ None,
/*has_chatgpt_account*/ false,
/*has_codex_backend_auth*/ false,
frame_requester,
)
.await;
chat.on_task_started();
chat.handle_streaming_delta("Earlier line\n".to_string());
chat.on_commit_tick();
assert!(!chat.bottom_pane.status_indicator_visible());
while draw_rx.try_recv().is_ok() {}
chat.handle_streaming_delta("Intro line\n| Step | Owner |\n".to_string());
assert!(chat.active_cell_is_stream_tail());
assert!(
draw_rx.try_recv().is_ok(),
"expected the changed assistant stream tail to schedule a redraw",
);
}
#[tokio::test]
async fn newline_plan_delta_redraws_stream_tail_after_noop_catch_up() {
let (frame_requester, mut draw_rx) = FrameRequester::test_channel();
let (mut chat, _rx, _op_rx) = make_chatwidget_manual_with_auth(
/*model_override*/ Some("gpt-5"),
/*has_chatgpt_account*/ false,
/*has_codex_backend_auth*/ false,
frame_requester,
)
.await;
chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true);
let plan_mask = collaboration_modes::mask_for_kind(chat.model_catalog.as_ref(), ModeKind::Plan)
.expect("expected plan collaboration mask");
chat.set_collaboration_mask(plan_mask);
chat.on_task_started();
chat.on_plan_delta("Earlier line\n".to_string());
chat.on_commit_tick();
assert!(!chat.bottom_pane.status_indicator_visible());
while draw_rx.try_recv().is_ok() {}
chat.on_plan_delta("Intro line\n| Step | Owner |\n".to_string());
assert!(chat.active_cell_is_stream_tail());
assert!(
draw_rx.try_recv().is_ok(),
"expected the changed plan stream tail to schedule a redraw",
);
}
#[tokio::test]
async fn regular_commit_tick_clears_orphaned_plan_stream_tail() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await;
chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true);
let plan_mask = collaboration_modes::mask_for_kind(chat.model_catalog.as_ref(), ModeKind::Plan)
.expect("expected plan collaboration mask");
chat.set_collaboration_mask(plan_mask);
chat.on_task_started();
chat.on_plan_delta("| Step | Owner |\n".to_string());
assert!(chat.active_cell_is_stream_tail());
chat.on_task_started();
assert!(chat.active_cell_is_stream_tail());
chat.on_commit_tick();
assert!(!chat.active_cell_is_stream_tail());
}
#[tokio::test]
async fn reasoning_delta_redraws_only_when_header_becomes_visible() {
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.on_agent_reasoning_delta("still looking".to_string());
assert!(matches!(
draw_rx.try_recv(),
Err(tokio::sync::mpsc::error::TryRecvError::Empty)
));
assert_eq!(chat.reasoning_header, None);
chat.on_agent_reasoning_delta(" **Checking".to_string());
assert!(matches!(
draw_rx.try_recv(),
Err(tokio::sync::mpsc::error::TryRecvError::Empty)
));
chat.on_agent_reasoning_delta(" files**".to_string());
assert!(draw_rx.try_recv().is_ok());
assert_eq!(chat.reasoning_header.as_deref(), Some("Checking files"));
chat.on_agent_reasoning_delta(" and preparing a response".to_string());
assert!(matches!(
draw_rx.try_recv(),
Err(tokio::sync::mpsc::error::TryRecvError::Empty)
));
}
#[tokio::test]
async fn reasoning_delta_does_not_double_schedule_visible_status_redraw() {
let (frame_requester, mut draw_rx) = FrameRequester::test_channel();
let (mut chat, _rx, _op_rx) = make_chatwidget_manual_with_auth(
/*model_override*/ None,
/*has_chatgpt_account*/ false,
/*has_codex_backend_auth*/ false,
frame_requester,
)
.await;
chat.on_task_started();
assert!(chat.bottom_pane.status_indicator_visible());
while draw_rx.try_recv().is_ok() {}
chat.on_agent_reasoning_delta("**Checking files**".to_string());
assert_eq!(chat.reasoning_header.as_deref(), Some("Checking files"));
assert!(draw_rx.try_recv().is_ok());
assert!(matches!(
draw_rx.try_recv(),
Err(tokio::sync::mpsc::error::TryRecvError::Empty)
));
}
#[tokio::test]
async fn reasoning_delta_restores_recreated_status_indicator_header() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.on_task_started();
chat.on_agent_reasoning_delta("**Checking files**".to_string());
chat.on_agent_message_delta("Preamble line\n".to_string());
chat.on_commit_tick();
drain_insert_history(&mut rx);
assert!(!chat.bottom_pane.status_indicator_visible());
begin_unified_exec_startup(&mut chat, "call-1", "proc-1", "sleep 2");
let status = chat
.bottom_pane
.status_widget()
.expect("status indicator should be recreated");
assert_eq!(status.header(), "Working");
chat.on_agent_reasoning_delta(" and preparing a response".to_string());
let status = chat
.bottom_pane
.status_widget()
.expect("status indicator should remain visible");
assert_eq!(status.header(), "Checking files");
let width: u16 = 80;
let height = chat.desired_height(width);
let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(width, height))
.expect("create terminal");
terminal.set_viewport_area(Rect::new(/*x*/ 0, /*y*/ 0, width, height));
terminal
.draw(|frame| chat.render(frame.area(), frame.buffer_mut()))
.expect("draw restored reasoning status");
assert_chatwidget_snapshot!(
"reasoning_delta_restores_recreated_status_indicator",
normalized_backend_snapshot(terminal.backend())
);
}
#[tokio::test]
async fn user_prompt_submit_app_server_hook_notifications_render_snapshot() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
@@ -82,6 +82,7 @@ impl ChatWidget {
}
self.reasoning_summary_parts.clear();
self.reasoning_buffer.clear();
self.reasoning_header = None;
self.set_ambient_pet_notification(
crate::pets::PetNotificationKind::Running,
/*body*/ None,
+2 -2
View File
@@ -438,8 +438,8 @@ impl HistoryCell for AgentMarkdownCell {
///
/// During streaming, lines that have not yet been committed to scrollback because they belong to
/// an in-progress table are displayed via this cell in the `active_cell` slot. It is replaced on
/// every delta and cleared when the stream finalizes.
#[derive(Debug)]
/// deltas that change the visible tail and cleared when the stream finalizes.
#[derive(Debug, Eq, PartialEq)]
pub(crate) struct StreamingAgentTailCell {
lines: Vec<HyperlinkLine>,
is_first_line: bool,
+1 -1
View File
@@ -7,7 +7,7 @@ use super::*;
/// The controller prepares the full styled plan lines because plan tails need the same header,
/// padding, and background treatment as committed `ProposedPlanStreamCell`s while remaining
/// preview-only during streaming.
#[derive(Debug)]
#[derive(Debug, Eq, PartialEq)]
pub(crate) struct StreamingPlanTailCell {
lines: Vec<HyperlinkLine>,
is_stream_continuation: bool,
@@ -139,7 +139,6 @@ impl StatusIndicatorWidget {
.filter(|message| !message.is_empty());
}
#[cfg(test)]
pub(crate) fn header(&self) -> &str {
&self.header
}
+15
View File
@@ -105,6 +105,7 @@ mod tests {
use codex_config::types::NotificationCondition;
use ratatui::layout::Position;
use ratatui::layout::Rect;
use ratatui::text::Line;
#[test]
fn unfocused_notification_condition_is_suppressed_when_focused() {
@@ -170,6 +171,20 @@ mod tests {
"expected stale cells inside the new viewport to be cleared, rows: {rows:?}"
);
}
#[tokio::test(flavor = "current_thread", start_paused = true)]
async fn inserting_history_lines_schedules_a_draw() {
let mut tui = crate::tui::test_support::make_test_tui().expect("test tui");
let mut draw_rx = tui.draw_tx.subscribe();
tui.insert_history_lines(vec![Line::from("committed stream line")]);
tokio::time::advance(std::time::Duration::from_millis(20)).await;
assert!(matches!(
tokio::time::timeout(std::time::Duration::from_millis(50), draw_rx.recv()).await,
Ok(Ok(()))
));
}
}
pub fn set_modes() -> Result<()> {
+13
View File
@@ -133,6 +133,19 @@ mod tests {
use tokio::time;
use tokio_util::time::FutureExt;
impl FrameRequester {
/// Create a frame requester and expose its request channel for deterministic tests.
pub(crate) fn test_channel() -> (Self, mpsc::UnboundedReceiver<Instant>) {
let (tx, rx) = mpsc::unbounded_channel();
(
FrameRequester {
frame_schedule_tx: tx,
},
rx,
)
}
}
#[tokio::test(flavor = "current_thread", start_paused = true)]
async fn test_schedule_frame_immediate_triggers_once() {
let (draw_tx, mut draw_rx) = broadcast::channel(16);