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
This commit is contained in:
Benjamin Carlsson
2026-08-26 01:20:40 +00:00
committed by copyberry
parent 00b7152a60
commit 2764e83626
3 changed files with 115 additions and 16 deletions
+84 -16
View File
@@ -536,7 +536,8 @@ impl TranscriptOverlay {
.iter()
.any(|cell| cell.as_any().is::<SessionInfoCell>())
{
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<Arc<dyn HistoryCell>>) {
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<usize>) {
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<Box<dyn Renderable>>) {
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<dyn HistoryCell>
})
.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(
@@ -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
@@ -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 "
" "