From f072119ccfcdab05b69dd7559d1df0b2df7b57e8 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Mon, 4 May 2026 09:00:30 -0700 Subject: [PATCH] Speed up /side parent restore replay (#20815) ## Why Returning from a `/side` conversation restores the parent thread by replaying its snapshot into the TUI. For very long parent threads, replaying every transcript row can take noticeable time even though most rows immediately scroll out of terminal history. ## What Changed - Buffer thread-switch replay for parent restores when terminal resize reflow is enabled. - Reuse the existing resize-reflow tail renderer so only the retained transcript tail is written back to scrollback when a row cap is configured. --- codex-rs/tui/src/app.rs | 1 + codex-rs/tui/src/app/event_dispatch.rs | 3 +++ codex-rs/tui/src/app/resize_reflow.rs | 32 ++++++++++++++++++++++++++ codex-rs/tui/src/app/tests.rs | 27 ++++++++++++++++++++++ codex-rs/tui/src/app/thread_routing.rs | 10 ++++++++ codex-rs/tui/src/app_event.rs | 4 ++++ 6 files changed, 77 insertions(+) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 8e17571ac..1327c73e0 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -423,6 +423,7 @@ struct SessionSummary { #[derive(Debug, Default)] struct InitialHistoryReplayBuffer { retained_lines: VecDeque>, + render_from_transcript_tail: bool, } pub(crate) struct App { diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 6bdc41372..5af8e7d2a 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -182,6 +182,9 @@ impl App { AppEvent::BeginInitialHistoryReplayBuffer => { self.begin_initial_history_replay_buffer(); } + AppEvent::BeginThreadSwitchHistoryReplayBuffer => { + self.begin_thread_switch_history_replay_buffer(); + } AppEvent::InsertHistoryCell(cell) => { let cell: Arc = cell.into(); if let Some(Overlay::Transcript(t)) = &mut self.overlay { diff --git a/codex-rs/tui/src/app/resize_reflow.rs b/codex-rs/tui/src/app/resize_reflow.rs index b2702f470..58b2e21df 100644 --- a/codex-rs/tui/src/app/resize_reflow.rs +++ b/codex-rs/tui/src/app/resize_reflow.rs @@ -119,6 +119,23 @@ impl App { } } + /// Start retaining a thread-switch transcript replay without rendering each historical cell. + /// + /// Thread switches already rebuild `transcript_cells` from source. When a row cap exists, we can + /// defer terminal writes until the replay is complete and reuse the resize-reflow tail renderer + /// so only the rows the terminal would retain are formatted and inserted. + pub(super) fn begin_thread_switch_history_replay_buffer(&mut self) { + if self.terminal_resize_reflow_enabled() + && self.resize_reflow_max_rows().is_some() + && self.overlay.is_none() + { + self.initial_history_replay_buffer = Some(InitialHistoryReplayBuffer { + retained_lines: VecDeque::new(), + render_from_transcript_tail: true, + }); + } + } + /// Flush retained initial resume replay rows into terminal scrollback. /// /// The buffer stores display lines, not cells, because the cap is measured in terminal rows. @@ -130,6 +147,13 @@ impl App { }; if buffer.retained_lines.is_empty() { + if buffer.render_from_transcript_tail { + let width = tui.terminal.last_known_screen_size.width; + let reflowed_lines = self.render_transcript_lines_for_reflow(width).lines; + if !reflowed_lines.is_empty() { + tui.insert_history_lines(reflowed_lines); + } + } return; } @@ -143,6 +167,14 @@ impl App { cell: &dyn HistoryCell, width: u16, ) { + if self + .initial_history_replay_buffer + .as_ref() + .is_some_and(|buffer| buffer.render_from_transcript_tail) + { + return; + } + let display = self.display_lines_for_history_insert(cell, width); if display.is_empty() { diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 9411cb03d..799ac69e3 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -4022,6 +4022,33 @@ async fn initial_replay_buffer_keeps_recent_rows_when_row_cap_present() { ); } +#[tokio::test] +async fn thread_switch_replay_buffer_uses_transcript_tail_mode_when_row_cap_present() { + let (mut app, _rx, _op_rx) = make_test_app_with_channels().await; + enable_terminal_resize_reflow(&mut app); + app.config.terminal_resize_reflow.max_rows = TerminalResizeReflowMaxRows::Limit(3); + + app.begin_thread_switch_history_replay_buffer(); + + let buffer = app + .initial_history_replay_buffer + .as_ref() + .expect("thread switch replay buffer should be active"); + assert!(buffer.render_from_transcript_tail); + assert!(buffer.retained_lines.is_empty()); +} + +#[tokio::test] +async fn thread_switch_replay_buffer_is_disabled_without_row_cap() { + let (mut app, _rx, _op_rx) = make_test_app_with_channels().await; + enable_terminal_resize_reflow(&mut app); + app.config.terminal_resize_reflow.max_rows = TerminalResizeReflowMaxRows::Disabled; + + app.begin_thread_switch_history_replay_buffer(); + + assert!(app.initial_history_replay_buffer.is_none()); +} + #[tokio::test] async fn height_shrink_schedules_resize_reflow() { let (mut app, _rx, _op_rx) = make_test_app_with_channels().await; diff --git a/codex-rs/tui/src/app/thread_routing.rs b/codex-rs/tui/src/app/thread_routing.rs index 84da7aced..acd79989b 100644 --- a/codex-rs/tui/src/app/thread_routing.rs +++ b/codex-rs/tui/src/app/thread_routing.rs @@ -1238,6 +1238,12 @@ impl App { snapshot: ThreadEventSnapshot, resume_restored_queue: bool, ) { + let should_buffer_replay = self.terminal_resize_reflow_enabled() + && (!snapshot.turns.is_empty() || !snapshot.events.is_empty()); + if should_buffer_replay { + self.app_event_tx + .send(AppEvent::BeginThreadSwitchHistoryReplayBuffer); + } let suppress_replay_notices = replay_filter::snapshot_has_pending_interactive_request(&snapshot); if let Some(session) = snapshot.session { @@ -1263,6 +1269,10 @@ impl App { } self.handle_thread_event_replay(event); } + if should_buffer_replay { + self.app_event_tx + .send(AppEvent::EndInitialHistoryReplayBuffer); + } self.chat_widget .set_queue_autosend_suppressed(/*suppressed*/ false); self.chat_widget diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 5e45bf38e..1032823f2 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -485,6 +485,10 @@ pub(crate) enum AppEvent { /// Begin buffering initial resume replay rows before they are written to scrollback. BeginInitialHistoryReplayBuffer, + /// Begin buffering thread-switch replay cells so the final scrollback write can reuse the + /// resize-reflow tail renderer. + BeginThreadSwitchHistoryReplayBuffer, + InsertHistoryCell(Box), /// Finish buffering initial resume replay after all replay events have been queued.