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.
This commit is contained in:
Eric Traut
2026-05-04 09:00:30 -07:00
committed by GitHub
Unverified
parent 3c2dcbef85
commit f072119ccf
6 changed files with 77 additions and 0 deletions
+1
View File
@@ -423,6 +423,7 @@ struct SessionSummary {
#[derive(Debug, Default)]
struct InitialHistoryReplayBuffer {
retained_lines: VecDeque<Line<'static>>,
render_from_transcript_tail: bool,
}
pub(crate) struct App {
+3
View File
@@ -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<dyn HistoryCell> = cell.into();
if let Some(Overlay::Transcript(t)) = &mut self.overlay {
+32
View File
@@ -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() {
+27
View File
@@ -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;
+10
View File
@@ -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
+4
View File
@@ -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<dyn HistoryCell>),
/// Finish buffering initial resume replay after all replay events have been queued.