From 2764e83626efe55f64e04d153fc99a157327f3c2 Mon Sep 17 00:00:00 2001 From: Benjamin Carlsson Date: Wed, 26 Aug 2026 01:15:16 +0000 Subject: [PATCH] Preserve transcript overlay state across updates (#40751) ## What changed - Detach and restore the live tail when rebuilding transcript renderables after history replacement, consolidation, removal, or highlighting. - Keep an overlay that is following the bottom pinned to the tail when the composer height changes. - Update cached content height after prepending history so subsequent scroll calculations use the rebuilt content. ## Testing - Cover history rebuilds with absent, empty, and active live tails. - Cover bottom-following across terminal height changes and subsequent committed output. GitOrigin-RevId: be4bf4d0676261cc52864da9acb567573e34a799 --- codex-rs/tui/src/pager_overlay.rs | 100 +++++++++++++++--- ...__transcript_overlay_completed_stream.snap | 13 +++ ...anscript_overlay_follows_resized_tail.snap | 18 ++++ 3 files changed, 115 insertions(+), 16 deletions(-) create mode 100644 codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_completed_stream.snap create mode 100644 codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_follows_resized_tail.snap diff --git a/codex-rs/tui/src/pager_overlay.rs b/codex-rs/tui/src/pager_overlay.rs index 1dc90e63d8..8bb78b0253 100644 --- a/codex-rs/tui/src/pager_overlay.rs +++ b/codex-rs/tui/src/pager_overlay.rs @@ -536,7 +536,8 @@ impl TranscriptOverlay { .iter() .any(|cell| cell.as_any().is::()) { - self.rebuild_renderables(); + let live_tail = self.take_live_tail_renderable(); + self.rebuild_renderables(live_tail); } previous } @@ -670,19 +671,16 @@ impl TranscriptOverlay { *index = index.saturating_add(added_cells); } } - self.rebuild_renderables(); - if let Some(live_tail) = live_tail { - self.view.renderables.push(live_tail); - } + self.rebuild_renderables(live_tail); + let content_height = self.view.content_height(width); self.view.scroll_offset = if follow_bottom { usize::MAX } else { - self.view.scroll_offset.saturating_add( - self.view - .content_height(width) - .saturating_sub(previous_height), - ) + self.view + .scroll_offset + .saturating_add(content_height.saturating_sub(previous_height)) }; + self.view.last_rendered_height = Some(content_height); insert_at } @@ -693,6 +691,7 @@ impl TranscriptOverlay { /// transcript overlay immediately reflects the same committed cells as the main transcript. pub(crate) fn replace_cells(&mut self, cells: Vec>) { let follow_bottom = self.view.is_scrolled_to_bottom(); + let live_tail = self.take_live_tail_renderable(); self.cells = cells; if self .highlight_cell @@ -700,7 +699,7 @@ impl TranscriptOverlay { { self.highlight_cell = None; } - self.rebuild_renderables(); + self.rebuild_renderables(live_tail); if follow_bottom { self.view.scroll_offset = usize::MAX; } @@ -724,6 +723,7 @@ impl TranscriptOverlay { let clamped_end = range.end.min(self.cells.len()); let clamped_start = range.start.min(clamped_end); if clamped_start < clamped_end { + let live_tail = self.take_live_tail_renderable(); let removed = clamped_end - clamped_start; if let Some(highlight_cell) = self.highlight_cell.as_mut() && *highlight_cell >= clamped_start @@ -742,7 +742,7 @@ impl TranscriptOverlay { { self.highlight_cell = None; } - self.rebuild_renderables(); + self.rebuild_renderables(live_tail); } if follow_bottom { self.view.scroll_offset = usize::MAX; @@ -798,8 +798,9 @@ impl TranscriptOverlay { } pub(crate) fn set_highlight_cell(&mut self, cell: Option) { + let live_tail = self.take_live_tail_renderable(); self.highlight_cell = cell; - self.rebuild_renderables(); + self.rebuild_renderables(live_tail); if let Some(idx) = self.highlight_cell { self.view.scroll_chunk_into_view(idx); } @@ -813,8 +814,8 @@ impl TranscriptOverlay { self.view.is_scrolled_to_bottom() } - fn rebuild_renderables(&mut self) { - let tail_renderable = self.take_live_tail_renderable(); + // Detach the live tail before changing cells: their old count identifies the tail renderable. + fn rebuild_renderables(&mut self, tail_renderable: Option>) { self.view.renderables = Self::render_cells(&self.cells, self.highlight_cell, self.history_state); if let Some(tail) = tail_renderable { @@ -878,6 +879,10 @@ impl TranscriptOverlay { } pub(crate) fn render(&mut self, area: Rect, buf: &mut Buffer) { + // Preserve following the tail before the composer changes the available height. + if self.view.is_scrolled_to_bottom() { + self.view.scroll_offset = usize::MAX; + } let top_h = area.height.saturating_sub(3); let top = Rect::new(area.x, area.y, area.width, top_h); let bottom = Rect::new(area.x, area.y + top_h, area.width, 3); @@ -1457,11 +1462,21 @@ mod tests { "expected initial render to leave view at bottom" ); + for height in [9, 14] { + term.backend_mut().resize(/*width*/ 40, height); + term.draw(|f| overlay.render(f.area(), f.buffer_mut())) + .expect("draw after composer height change"); + assert!(overlay.is_scrolled_to_bottom()); + } + overlay.insert_cell(Arc::new(TestCell { lines: vec!["tail".into()], })); assert_eq!(overlay.view.scroll_offset, usize::MAX); + term.draw(|f| overlay.render(f.area(), f.buffer_mut())) + .expect("draw committed tail"); + assert_snapshot!("transcript_overlay_follows_resized_tail", term.backend()); } #[test] @@ -1495,7 +1510,7 @@ mod tests { let visible_before = buffer_to_text(term.backend().buffer(), content_area); overlay.prepend( vec![Arc::new(TestCell { - lines: vec!["older first".into(), "older second".into()], + lines: (0..40).map(|i| Line::from(format!("older {i}"))).collect(), })], /*width*/ 40, ); @@ -1531,6 +1546,59 @@ mod tests { assert_eq!(height_calls.load(Ordering::Relaxed), 1); } + #[test] + fn transcript_overlay_history_rebuild_preserves_only_the_live_tail() { + for replace in [false, true] { + for tail in [ + None, + Some(Vec::new()), + Some(vec![HyperlinkLine::from("live")]), + ] { + let mut overlay = transcript_overlay( + ["first", "last"] + .map(|line| { + Arc::new(TestCell { + lines: vec![line.into()], + }) as Arc + }) + .to_vec(), + ); + let key = tail.as_ref().map(|_| ActiveCellTranscriptKey { + revision: 1, + is_stream_continuation: false, + animation_tick: None, + }); + overlay.sync_live_tail(/*width*/ 40, key, |_| tail.clone()); + let consolidated = Arc::new(TestCell { + lines: vec!["first".into(), "last".into()], + }); + if replace { + overlay.replace_cells(vec![consolidated]); + } else { + overlay.consolidate_cells(0..2, consolidated); + } + // A draw may arrive after the active tail has already been cleared. + overlay.sync_live_tail(/*width*/ 40, key, |_| tail.clone()); + let mut reopened = transcript_overlay(overlay.cells.clone()); + reopened.sync_live_tail(/*width*/ 40, key, |_| tail.clone()); + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 40, /*height*/ 10, + ); + let mut actual = Buffer::empty(area); + let mut expected = Buffer::empty(area); + overlay.render(area, &mut actual); + reopened.render(area, &mut expected); + assert_eq!(actual, expected); + if tail.is_none() { + assert_snapshot!( + "transcript_overlay_completed_stream", + buffer_to_text(&actual, area) + ); + } + } + } + } + #[test] fn transcript_overlay_consolidation_remaps_highlight_inside_range() { let mut overlay = transcript_overlay( diff --git a/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_completed_stream.snap b/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_completed_stream.snap new file mode 100644 index 0000000000..a30b67d208 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_completed_stream.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/pager_overlay.rs +expression: "buffer_to_text(&actual, area)" +--- +/ T R A N S C R I P T / / / / / / / / / +first +last +~ +~ +~ +───────────────────────────────── 100% ─ + ↑/↓ to scroll pgup/pgdn to page hom + q to quit esc to edit prev diff --git a/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_follows_resized_tail.snap b/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_follows_resized_tail.snap new file mode 100644 index 0000000000..8ef139bb02 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_follows_resized_tail.snap @@ -0,0 +1,18 @@ +--- +source: tui/src/pager_overlay.rs +expression: term.backend() +--- +"/ T R A N S C R I P T / / / / / / / / / " +"line16 " +" " +"line17 " +" " +"line18 " +" " +"line19 " +" " +"tail " +"───────────────────────────────── 100% ─" +" ↑/↓ to scroll pgup/pgdn to page hom" +" q to quit esc to edit prev " +" "