Render only visible rows in the transcript pager (#39063)

## Why

Scrolling the transcript pager previously rendered a scratch buffer containing
all rows through the visible viewport, including hidden content above it. This
made rendering work and buffer size grow with the scroll offset.

## What changed

- Add viewport-aware rendering for committed transcript cells and the live tail.
- Forward scrolled rendering through cached and inset renderables while keeping
  the full-height fallback for renderables that do not support it.
- Keep hyperlink metadata aligned with scrolled text.

## Testing

Add coverage comparing viewport-aware output with the full-height fallback
across wrapping widths, offsets, insets, styles, hyperlinks, and live tails.

GitOrigin-RevId: 630c30c4a61969a50f2b34701ca1dd87788a4298
This commit is contained in:
Charlie Marsh
2026-08-17 19:19:20 +00:00
committed by copyberry
parent 386a7b629c
commit b6e153c985
4 changed files with 471 additions and 82 deletions

View File

@@ -15,13 +15,14 @@
//! recomputed. `ChatWidget` is responsible for producing a key that changes when the active cell
//! mutates in place or when its transcript output is time-dependent.
mod scrolling;
use std::io::Result;
use std::sync::Arc;
use crate::chatwidget::ActiveCellTranscriptKey;
use crate::history_cell::HistoryCell;
use crate::history_cell::SessionInfoCell;
use crate::history_cell::UserHistoryCell;
use crate::key_hint;
use crate::key_hint::KeyBinding;
use crate::key_hint::KeyBindingListExt;
@@ -30,10 +31,7 @@ use crate::keymap::PagerKeymap;
use crate::render::Insets;
use crate::render::renderable::InsetRenderable;
use crate::render::renderable::Renderable;
use crate::style::user_message_style;
use crate::terminal_hyperlinks::HyperlinkLine;
use crate::terminal_hyperlinks::mark_buffer_hyperlinks;
use crate::terminal_hyperlinks::visible_lines_ref;
use crate::tui;
use crate::tui::TuiEvent;
use crossterm::event::KeyCode;
@@ -41,7 +39,6 @@ use crossterm::event::KeyEvent;
use ratatui::buffer::Buffer;
use ratatui::buffer::Cell;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
@@ -50,6 +47,9 @@ use ratatui::widgets::Clear;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget;
use ratatui::widgets::Wrap;
use scrolling::CellRenderable;
use scrolling::HyperlinkLinesRenderable;
use scrolling::render_offset_content;
pub(crate) enum Overlay {
Transcript(TranscriptOverlay),
@@ -417,6 +417,11 @@ impl Renderable for CachedRenderable {
fn render(&self, area: Rect, buf: &mut Buffer) {
self.renderable.render(area, buf);
}
fn render_scrolled(&self, area: Rect, buf: &mut Buffer, scroll_offset: u16) -> bool {
self.renderable.render_scrolled(area, buf, scroll_offset)
}
fn desired_height(&self, width: u16) -> u16 {
if self.last_width.get() != Some(width) {
let height = self.renderable.desired_height(width);
@@ -427,56 +432,6 @@ impl Renderable for CachedRenderable {
}
}
struct CellRenderable {
cell: Arc<dyn HistoryCell>,
highlighted: bool,
}
impl Renderable for CellRenderable {
fn render(&self, area: Rect, buf: &mut Buffer) {
let hyperlink_lines = self.cell.transcript_hyperlink_lines(area.width);
let style = if self.cell.as_any().is::<UserHistoryCell>() {
if self.highlighted {
user_message_style().reversed()
} else {
user_message_style()
}
} else {
Style::default()
};
let p = Paragraph::new(Text::from(visible_lines_ref(&hyperlink_lines)))
.style(style)
.wrap(Wrap { trim: false });
p.render(area, buf);
mark_buffer_hyperlinks(buf, area, &hyperlink_lines, /*scroll_rows*/ 0);
}
fn desired_height(&self, width: u16) -> u16 {
self.cell.desired_transcript_height(width)
}
}
struct HyperlinkLinesRenderable {
lines: Vec<HyperlinkLine>,
}
impl Renderable for HyperlinkLinesRenderable {
fn render(&self, area: Rect, buf: &mut Buffer) {
Paragraph::new(Text::from(visible_lines_ref(&self.lines)))
.wrap(Wrap { trim: false })
.render(area, buf);
mark_buffer_hyperlinks(buf, area, &self.lines, /*scroll_rows*/ 0);
}
fn desired_height(&self, width: u16) -> u16 {
Paragraph::new(Text::from(visible_lines_ref(&self.lines)))
.wrap(Wrap { trim: false })
.line_count(width)
.try_into()
.unwrap_or(/*default*/ 0)
}
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub(crate) enum TranscriptHistoryState {
#[default]
@@ -1055,33 +1010,6 @@ impl StaticOverlay {
}
}
fn render_offset_content(
area: Rect,
buf: &mut Buffer,
renderable: &dyn Renderable,
scroll_offset: u16,
) -> u16 {
let height = renderable.desired_height(area.width);
let mut tall_buf = Buffer::empty(Rect::new(
0,
0,
area.width,
height.min(area.height + scroll_offset),
));
renderable.render(*tall_buf.area(), &mut tall_buf);
let copy_height = area
.height
.min(tall_buf.area().height.saturating_sub(scroll_offset));
for y in 0..copy_height {
let src_y = y + scroll_offset;
for x in 0..area.width {
buf[(area.x + x, area.y + y)] = tall_buf[(x, src_y)].clone();
}
}
copy_height
}
#[cfg(test)]
mod tests {
use super::*;

View File

@@ -0,0 +1,123 @@
//! Viewport-aware transcript rendering and the fallback for generic pager content.
use std::sync::Arc;
use crate::history_cell::HistoryCell;
use crate::history_cell::UserHistoryCell;
use crate::render::renderable::Renderable;
use crate::style::user_message_style;
use crate::terminal_hyperlinks::HyperlinkLine;
use crate::terminal_hyperlinks::mark_buffer_hyperlinks;
use crate::terminal_hyperlinks::visible_lines_ref;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::text::Text;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget;
use ratatui::widgets::Wrap;
/// Renders a committed history cell directly into the visible transcript viewport.
pub(super) struct CellRenderable {
pub(super) cell: Arc<dyn HistoryCell>,
pub(super) highlighted: bool,
}
impl Renderable for CellRenderable {
fn render(&self, area: Rect, buf: &mut Buffer) {
self.render_scrolled(area, buf, /*scroll_offset*/ 0);
}
/// Scroll visible text and hyperlink metadata together without rendering hidden rows.
fn render_scrolled(&self, area: Rect, buf: &mut Buffer, scroll_offset: u16) -> bool {
let hyperlink_lines = self.cell.transcript_hyperlink_lines(area.width);
let style = if self.cell.as_any().is::<UserHistoryCell>() {
if self.highlighted {
user_message_style().reversed()
} else {
user_message_style()
}
} else {
Style::default()
};
let p = Paragraph::new(Text::from(visible_lines_ref(&hyperlink_lines)))
.style(style)
.wrap(Wrap { trim: false })
.scroll((scroll_offset, 0));
p.render(area, buf);
mark_buffer_hyperlinks(buf, area, &hyperlink_lines, usize::from(scroll_offset));
true
}
fn desired_height(&self, width: u16) -> u16 {
self.cell.desired_transcript_height(width)
}
}
/// Renders the optional in-flight transcript tail without allocating hidden rows.
pub(super) struct HyperlinkLinesRenderable {
pub(super) lines: Vec<HyperlinkLine>,
}
impl Renderable for HyperlinkLinesRenderable {
fn render(&self, area: Rect, buf: &mut Buffer) {
self.render_scrolled(area, buf, /*scroll_offset*/ 0);
}
/// Keep live-tail hyperlinks aligned with the same visible rows as their text.
fn render_scrolled(&self, area: Rect, buf: &mut Buffer, scroll_offset: u16) -> bool {
Paragraph::new(Text::from(visible_lines_ref(&self.lines)))
.wrap(Wrap { trim: false })
.scroll((scroll_offset, 0))
.render(area, buf);
mark_buffer_hyperlinks(buf, area, &self.lines, usize::from(scroll_offset));
true
}
fn desired_height(&self, width: u16) -> u16 {
Paragraph::new(Text::from(visible_lines_ref(&self.lines)))
.wrap(Wrap { trim: false })
.line_count(width)
.try_into()
.unwrap_or(/*default*/ 0)
}
}
/// Render visible rows directly when supported, preserving the legacy scratch-buffer fallback.
pub(super) fn render_offset_content(
area: Rect,
buf: &mut Buffer,
renderable: &dyn Renderable,
scroll_offset: u16,
) -> u16 {
let height = renderable.desired_height(area.width);
let copy_height = area.height.min(height.saturating_sub(scroll_offset));
if copy_height == 0 {
return 0;
}
let visible_area = Rect::new(area.x, area.y, area.width, copy_height);
if renderable.render_scrolled(visible_area, buf, scroll_offset) {
return copy_height;
}
let mut tall_buf = Buffer::empty(Rect::new(
/*x*/ 0,
/*y*/ 0,
area.width,
scroll_offset + copy_height,
));
renderable.render(*tall_buf.area(), &mut tall_buf);
for y in 0..copy_height {
let src_y = y + scroll_offset;
for x in 0..area.width {
buf[(area.x + x, area.y + y)] = tall_buf[(x, src_y)].clone();
}
}
copy_height
}
#[cfg(test)]
#[path = "scrolling_tests.rs"]
mod tests;

View File

@@ -0,0 +1,284 @@
use super::super::CachedRenderable;
use super::super::TranscriptOverlay;
use super::CellRenderable;
use super::HyperlinkLinesRenderable;
use super::render_offset_content;
use crate::chatwidget::ActiveCellTranscriptKey;
use crate::history_cell::HistoryCell;
use crate::history_cell::PlainHistoryCell;
use crate::history_cell::UserHistoryCell;
use crate::keymap::RuntimeKeymap;
use crate::render::Insets;
use crate::render::renderable::InsetRenderable;
use crate::render::renderable::Renderable;
use crate::terminal_hyperlinks::HyperlinkLine;
use crate::terminal_hyperlinks::visible_lines;
use pretty_assertions::assert_eq;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Text;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Wrap;
use std::sync::Arc;
#[derive(Debug)]
struct HyperlinkTestCell {
lines: Vec<HyperlinkLine>,
}
impl HistoryCell for HyperlinkTestCell {
fn display_lines(&self, _width: u16) -> Vec<Line<'static>> {
visible_lines(self.lines.clone())
}
fn transcript_hyperlink_lines(&self, _width: u16) -> Vec<HyperlinkLine> {
self.lines.clone()
}
fn raw_lines(&self) -> Vec<Line<'static>> {
visible_lines(self.lines.clone())
}
fn has_stable_transcript_height(&self) -> bool {
false
}
}
/// Forces the unchanged full-height fallback while preserving the wrapped renderable's output.
struct LegacyOnlyRenderable {
inner: Box<dyn Renderable>,
}
impl Renderable for LegacyOnlyRenderable {
fn render(&self, area: Rect, buf: &mut Buffer) {
self.inner.render(area, buf);
}
fn desired_height(&self, width: u16) -> u16 {
self.inner.desired_height(width)
}
}
fn scrolled_hyperlink_lines() -> Vec<HyperlinkLine> {
let mut linked = HyperlinkLine::new(Line::from(vec![
"prefix ".green(),
"漢字 ".bold(),
"ガ ".italic(),
]));
linked.push_span(
"clickable 漢字 ガ".cyan().underlined(),
Some("https://example.com/a/wrapped-private-destination"),
);
linked.push_span(
" suffix words that continue wrapping".blue(),
/*destination*/ None,
);
vec![
HyperlinkLine::new(Line::from("first styled row".magenta())),
linked,
HyperlinkLine::new(Line::default()),
HyperlinkLine::new(Line::from("last 漢字 ガ row".red())),
]
}
fn scrolled_test_renderables(lines: &[HyperlinkLine]) -> Vec<(&'static str, Box<dyn Renderable>)> {
let cell: Arc<dyn HistoryCell> = Arc::new(HyperlinkTestCell {
lines: lines.to_vec(),
});
let user: Arc<dyn HistoryCell> = Arc::new(UserHistoryCell {
message: "highlighted 漢字 user message that wraps\nsecond user line".to_string(),
text_elements: Vec::new(),
local_image_paths: Vec::new(),
remote_image_urls: Vec::new(),
});
vec![
(
"uncached history cell",
Box::new(CellRenderable {
cell: cell.clone(),
highlighted: false,
}),
),
(
"highlighted cached user history cell",
Box::new(CachedRenderable::new(CellRenderable {
cell: user,
highlighted: true,
})),
),
(
"inset cached history cell",
Box::new(InsetRenderable::new(
Box::new(CachedRenderable::new(CellRenderable {
cell,
highlighted: false,
})) as Box<dyn Renderable>,
Insets::tlbr(
/*top*/ 2, /*left*/ 1, /*bottom*/ 1, /*right*/ 1,
),
)),
),
(
"live tail",
Box::new(HyperlinkLinesRenderable {
lines: lines.to_vec(),
}),
),
(
"inset cached live tail",
Box::new(InsetRenderable::new(
Box::new(CachedRenderable::new(HyperlinkLinesRenderable {
lines: lines.to_vec(),
})) as Box<dyn Renderable>,
Insets::tlbr(
/*top*/ 1, /*left*/ 1, /*bottom*/ 1, /*right*/ 1,
),
)),
),
(
"unsupported paragraph fallback",
Box::new(
Paragraph::new(Text::from(visible_lines(lines.to_vec())))
.wrap(Wrap { trim: false }),
),
),
]
}
#[test]
fn scrolled_transcript_renderables_match_full_height_fallback() {
let lines = scrolled_hyperlink_lines();
for width in [5, 7, 13, 28] {
for ((name, renderable), (_, legacy_inner)) in scrolled_test_renderables(&lines)
.into_iter()
.zip(scrolled_test_renderables(&lines))
{
let legacy = LegacyOnlyRenderable {
inner: legacy_inner,
};
let height = renderable.desired_height(width);
for offset in [
0,
1,
2,
3,
height.saturating_sub(/*rhs*/ 2),
height.saturating_sub(/*rhs*/ 1),
height,
] {
for (x, y, visible_height) in [(0, 0, 4), (3, 2, 3)] {
let area = Rect::new(x, y, width, visible_height);
let full_area = Rect::new(
/*x*/ 0,
/*y*/ 0,
area.right().saturating_add(/*rhs*/ 2),
area.bottom().saturating_add(/*rhs*/ 2),
);
let mut expected = Buffer::empty(full_area);
let mut actual = Buffer::empty(full_area);
let expected_height =
render_offset_content(area, &mut expected, &legacy, offset);
let actual_height =
render_offset_content(area, &mut actual, &*renderable, offset);
assert_eq!(
(actual_height, actual),
(expected_height, expected),
"renderable={name}, width={width}, offset={offset}, area={area:?}",
);
}
}
}
}
}
#[test]
fn transcript_overlay_scrolled_cells_and_live_tail_match_full_height_fallback() {
let lines = scrolled_hyperlink_lines();
let cell: Arc<dyn HistoryCell> = Arc::new(HyperlinkTestCell {
lines: lines.clone(),
});
for width in [7, 13, 28] {
let cells: Vec<Arc<dyn HistoryCell>> = vec![
Arc::new(PlainHistoryCell::new(vec![Line::from(
"leading stable history",
)])),
cell.clone(),
];
let mut actual = TranscriptOverlay::new(cells.clone(), RuntimeKeymap::defaults().pager);
let mut expected = TranscriptOverlay::new(cells, RuntimeKeymap::defaults().pager);
for overlay in [&mut actual, &mut expected] {
overlay.sync_live_tail(
width,
Some(ActiveCellTranscriptKey {
revision: 1,
is_stream_continuation: false,
animation_tick: None,
}),
|_| Some(lines.clone()),
);
}
expected.view.renderables = expected
.view
.renderables
.into_iter()
.map(|inner| Box::new(LegacyOnlyRenderable { inner }) as Box<dyn Renderable>)
.collect();
let area = Rect::new(/*x*/ 2, /*y*/ 1, width, /*height*/ 10);
let full_area = Rect::new(
/*x*/ 0,
/*y*/ 0,
area.right().saturating_add(/*rhs*/ 1),
area.bottom().saturating_add(/*rhs*/ 1),
);
let total_height = actual.view.content_height(width);
for offset in [0, 1, 3, total_height.saturating_sub(/*rhs*/ 2), usize::MAX] {
actual.view.scroll_offset = offset;
expected.view.scroll_offset = offset;
let mut actual_buffer = Buffer::empty(full_area);
let mut expected_buffer = Buffer::empty(full_area);
actual.render(area, &mut actual_buffer);
expected.render(area, &mut expected_buffer);
assert_eq!(
actual_buffer, expected_buffer,
"width={width}, offset={offset}",
);
}
}
}
#[test]
fn fallback_handles_offsets_near_maximum_height() {
struct MaximumHeightRenderable;
impl Renderable for MaximumHeightRenderable {
fn render(&self, area: Rect, buf: &mut Buffer) {
let last_row = area.bottom().saturating_sub(/*rhs*/ 1);
buf[(area.x, last_row)].set_symbol("x");
}
fn desired_height(&self, _width: u16) -> u16 {
u16::MAX
}
}
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 1, /*height*/ 2,
);
let mut actual = Buffer::empty(area);
let mut expected = Buffer::empty(area);
expected[(area.x, area.y)].set_symbol("x");
let height = render_offset_content(area, &mut actual, &MaximumHeightRenderable, u16::MAX - 1);
assert_eq!((height, actual), (1, expected));
}

View File

@@ -16,6 +16,13 @@ use crate::render::RectExt as _;
pub trait Renderable {
fn render(&self, area: Rect, buf: &mut Buffer);
fn desired_height(&self, width: u16) -> u16;
/// Renders visible rows after `scroll_offset` when direct scrolling is supported.
///
/// Implementations returning `false` must leave `buf` unchanged so callers can use their
/// existing full-height rendering fallback. Supporting wrappers must forward this method.
fn render_scrolled(&self, _area: Rect, _buf: &mut Buffer, _scroll_offset: u16) -> bool {
false
}
fn cursor_pos(&self, _area: Rect) -> Option<(u16, u16)> {
None
}
@@ -44,6 +51,13 @@ impl<'a> Renderable for RenderableItem<'a> {
}
}
fn render_scrolled(&self, area: Rect, buf: &mut Buffer, scroll_offset: u16) -> bool {
match self {
RenderableItem::Owned(child) => child.render_scrolled(area, buf, scroll_offset),
RenderableItem::Borrowed(child) => child.render_scrolled(area, buf, scroll_offset),
}
}
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
match self {
RenderableItem::Owned(child) => child.cursor_pos(area),
@@ -479,6 +493,46 @@ impl<'a> Renderable for InsetRenderable<'a> {
+ self.insets.top
+ self.insets.bottom
}
/// Preserve clipped inset padding while forwarding only visible child rows.
fn render_scrolled(&self, area: Rect, buf: &mut Buffer, scroll_offset: u16) -> bool {
let top_padding = self.insets.top.saturating_sub(scroll_offset);
let child_width = area
.width
.saturating_sub(self.insets.left.saturating_add(self.insets.right));
if child_width == 0 || top_padding >= area.height {
return true;
}
let child_offset = scroll_offset.saturating_sub(self.insets.top);
// The fallback applies bottom padding to its clipped scratch buffer, even mid-scroll.
let child_height = self
.child
.desired_height(child_width)
.saturating_sub(child_offset)
.min(
area.height
.saturating_sub(top_padding)
.saturating_sub(self.insets.bottom),
);
if child_height == 0 {
return true;
}
let child_area = Rect::new(
area.x.saturating_add(self.insets.left),
area.y.saturating_add(top_padding),
child_width,
child_height,
);
if child_offset == 0 {
self.child.render(child_area, buf);
true
} else {
self.child.render_scrolled(child_area, buf, child_offset)
}
}
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
self.child.cursor_pos(area.inset(self.insets))
}