Anchor transcript scrolling to entries and bound viewport rendering (#46721)

## Why

Transcript-wide row offsets depend on content that can change during pagination, streaming, and resizing. Computing an exact scroll percentage also requires laying out offscreen history.

## What changed

- Replace the transcript overlay's generic pager with `TranscriptView`, tracking entry identity and a display-row offset through history updates and stream consolidation. Clamp offsets within the entry on resize or replacement.
- Render through bounded layout caches and show `100%` only when following the latest output with no unloaded history.
- Keep the reading position while `Home` loads older pages, then jump to the beginning when loading completes.
- Refresh animations whenever live output is visible, including while scrolled up.
- Reveal selected prompts when backtracking and highlight their text without reversing gutters or padding.

## Testing

Add regression coverage for reading anchors across pagination and live-output completion, resize and consolidation, prompt visibility and highlighting, navigation before the first draw, and avoiding offscreen formatting for the percentage indicator.

GitOrigin-RevId: 105cb66f93c742ee537152419060a87f55896b44
This commit is contained in:
Eric Traut
2026-09-19 21:43:37 +00:00
committed by copyberry
parent 30ab0ee65b
commit a63ae005d4
19 changed files with 1584 additions and 1373 deletions
+1 -1
View File
@@ -232,7 +232,7 @@ impl App {
self.scrollback_has_older_history = app_server.has_older_history(thread_id);
let mut continue_to_start = false;
if let Some(Overlay::Transcript(overlay)) = self.overlay.as_mut() {
let index = overlay.prepend(cells.clone(), width);
let index = overlay.prepend(cells.clone());
self.transcript_cells.splice(index..index, cells);
let previous_state = overlay.set_history_state(if self.scrollback_has_older_history {
TranscriptHistoryState::Partial
@@ -2651,10 +2651,6 @@ async fn underfilled_scrollback_fetches_older_pages_without_opening_the_transcri
.await;
assert!(app.scrollback_has_older_history);
if let Some(Overlay::Transcript(overlay)) = app.overlay.as_mut() {
overlay.handle_event(
&mut tui,
TuiEvent::Key(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE)),
)?;
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 100, /*height*/ 16,
);
@@ -2670,6 +2666,9 @@ async fn underfilled_scrollback_fetches_older_pages_without_opening_the_transcri
.collect::<Vec<_>>()
.join("\n")
};
// Inspect the loaded start without Home, which now requests all older pages.
render_overlay(overlay);
overlay.scroll(isize::MIN);
let partial = render_overlay(overlay);
assert!(partial.contains("Earlier messages are available — scroll up to load them"));
assert!(!partial.contains("OpenAI Codex"));
+1 -1
View File
@@ -328,7 +328,7 @@ impl App {
let close_overlay = t.is_done();
if !close_overlay
&& active_key.is_some_and(|key| key.animation_tick.is_some())
&& t.is_scrolled_to_bottom()
&& t.live_tail_visible()
{
tui.frame_requester()
.schedule_frame_in(std::time::Duration::from_millis(50));
@@ -13,7 +13,7 @@ impl App {
event: TuiEvent,
) -> Result<bool> {
if let TuiEvent::Key(key_event) = &event
&& let Some(Overlay::Transcript(overlay)) = self.overlay.as_ref()
&& let Some(Overlay::Transcript(overlay)) = self.overlay.as_mut()
&& (overlay.should_load_older(*key_event)
|| (self.backtrack.overlay_preview_active
&& self.backtrack.nth_user_message == 0
+11 -175
View File
@@ -31,8 +31,6 @@ use crate::key_hint::KeyBinding;
use crate::key_hint::KeyBindingListExt;
use crate::key_hint::ShortcutHint;
use crate::keymap::PagerKeymap;
use crate::render::Insets;
use crate::render::renderable::InsetRenderable;
use crate::render::renderable::Renderable;
use crate::terminal_hyperlinks::HyperlinkLine;
use crate::tui;
@@ -50,19 +48,17 @@ 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),
Static(StaticOverlay),
Transcript(Box<TranscriptOverlay>),
Static(Box<StaticOverlay>),
Analytics(Box<crate::analytics::AnalyticsView>),
}
impl Overlay {
pub(crate) fn new_transcript(cells: Vec<Arc<dyn HistoryCell>>, keymap: PagerKeymap) -> Self {
Self::Transcript(TranscriptOverlay::new(cells, keymap))
Self::Transcript(Box::new(TranscriptOverlay::new(cells, keymap)))
}
pub(crate) fn new_static_with_lines(
@@ -70,7 +66,7 @@ impl Overlay {
title: String,
keymap: PagerKeymap,
) -> Self {
Self::Static(StaticOverlay::with_title(lines, title, keymap))
Self::Static(Box::new(StaticOverlay::with_title(lines, title, keymap)))
}
pub(crate) fn new_static_with_renderables(
@@ -78,7 +74,11 @@ impl Overlay {
title: String,
keymap: PagerKeymap,
) -> Self {
Self::Static(StaticOverlay::with_renderables(renderables, title, keymap))
Self::Static(Box::new(StaticOverlay::with_renderables(
renderables,
title,
keymap,
)))
}
pub(crate) fn handle_event(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> {
@@ -159,11 +159,6 @@ struct PagerView {
title: String,
keymap: PagerKeymap,
last_content_height: Option<usize>,
last_rendered_height: Option<usize>,
/// Percentages are meaningful only when the full scrollable history is known.
scroll_percentage_visible: bool,
/// If set, on next render ensure this chunk is visible.
pending_scroll_chunk: Option<usize>,
}
impl PagerView {
@@ -179,9 +174,6 @@ impl PagerView {
title,
keymap,
last_content_height: None,
last_rendered_height: None,
scroll_percentage_visible: true,
pending_scroll_chunk: None,
}
}
@@ -198,12 +190,6 @@ impl PagerView {
let content_area = self.content_area(area);
self.update_last_content_height(content_area.height);
let content_height = self.content_height(content_area.width);
self.last_rendered_height = Some(content_height);
// If there is a pending request to scroll a specific chunk into view,
// satisfy it now that wrapping is up to date for this width.
if let Some(idx) = self.pending_scroll_chunk.take() {
self.ensure_chunk_visible(idx, content_area);
}
self.scroll_offset = self
.scroll_offset
.min(content_height.saturating_sub(content_area.height as usize));
@@ -270,9 +256,6 @@ impl PagerView {
Span::from("─".repeat(sep_rect.width as usize))
.dim()
.render(sep_rect, buf);
if !self.scroll_percentage_visible {
return;
}
let percent = if total_len == 0 {
100
} else {
@@ -359,53 +342,6 @@ impl PagerView {
}
}
impl PagerView {
fn is_scrolled_to_bottom(&self) -> bool {
if self.scroll_offset == usize::MAX {
return true;
}
let Some(height) = self.last_content_height else {
return false;
};
if self.renderables.is_empty() {
return true;
}
let Some(total_height) = self.last_rendered_height else {
return false;
};
if total_height <= height {
return true;
}
let max_scroll = total_height.saturating_sub(height);
self.scroll_offset >= max_scroll
}
/// Request that the given text chunk index be scrolled into view on next render.
fn scroll_chunk_into_view(&mut self, chunk_index: usize) {
self.pending_scroll_chunk = Some(chunk_index);
}
fn ensure_chunk_visible(&mut self, idx: usize, area: Rect) {
if area.height == 0 || idx >= self.renderables.len() {
return;
}
let first = self
.renderables
.iter()
.take(idx)
.map(|r| r.desired_height(area.width) as usize)
.sum();
let last = first + self.renderables[idx].desired_height(area.width) as usize;
let current_top = self.scroll_offset;
let current_bottom = current_top.saturating_add(area.height.saturating_sub(1) as usize);
if first < current_top {
self.scroll_offset = first;
} else if last > current_bottom {
self.scroll_offset = last.saturating_sub(area.height.saturating_sub(1) as usize);
}
}
}
/// A renderable that caches its desired height.
struct CachedRenderable {
renderable: Box<dyn Renderable>,
@@ -454,14 +390,14 @@ pub(crate) enum TranscriptHistoryState {
}
impl TranscriptHistoryState {
fn has_unloaded_history(self) -> bool {
pub(crate) fn has_unloaded_history(self) -> bool {
matches!(
self,
Self::LoadingOlder | Self::LoadingBeginning | Self::Partial | Self::Failed
)
}
fn session_header_placeholder(self) -> Option<&'static str> {
pub(crate) fn session_header_placeholder(self) -> Option<&'static str> {
match self {
Self::LoadingOlder | Self::LoadingBeginning => Some("Loading earlier messages..."),
Self::Partial => Some("Earlier messages are available — scroll up to load them"),
@@ -605,26 +541,6 @@ mod tests {
);
}
fn buffer_to_text(buf: &Buffer, area: Rect) -> String {
let mut out = String::new();
for y in area.y..area.bottom() {
for x in area.x..area.right() {
let symbol = buf[(x, y)].symbol();
if symbol.is_empty() {
out.push(' ');
} else {
out.push(symbol.chars().next().unwrap_or(' '));
}
}
// Trim trailing spaces for stability.
while out.ends_with(' ') {
out.pop();
}
out.push('\n');
}
out
}
#[test]
fn static_overlay_snapshot_basic() {
// Prepare a static overlay with a few lines and a title
@@ -665,84 +581,4 @@ mod tests {
assert_eq!(pv.content_height(/*width*/ 80), 5);
}
#[test]
fn pager_view_ensure_chunk_visible_scrolls_down_when_needed() {
let mut pv = pager_view(
vec![
paragraph_block("a", /*lines*/ 1),
paragraph_block("b", /*lines*/ 3),
paragraph_block("c", /*lines*/ 3),
],
"T",
/*scroll_offset*/ 0,
);
let area = Rect::new(0, 0, 20, 8);
pv.scroll_offset = 0;
let content_area = pv.content_area(area);
pv.ensure_chunk_visible(/*idx*/ 2, content_area);
let mut buf = Buffer::empty(area);
pv.render(area, &mut buf);
let rendered = buffer_to_text(&buf, area);
assert!(
rendered.contains("c0"),
"expected chunk top in view: {rendered:?}"
);
assert!(
rendered.contains("c1"),
"expected chunk middle in view: {rendered:?}"
);
assert!(
rendered.contains("c2"),
"expected chunk bottom in view: {rendered:?}"
);
}
#[test]
fn pager_view_ensure_chunk_visible_scrolls_up_when_needed() {
let mut pv = pager_view(
vec![
paragraph_block("a", /*lines*/ 2),
paragraph_block("b", /*lines*/ 3),
paragraph_block("c", /*lines*/ 3),
],
"T",
/*scroll_offset*/ 0,
);
let area = Rect::new(0, 0, 20, 3);
pv.scroll_offset = 6;
pv.ensure_chunk_visible(/*idx*/ 0, area);
assert_eq!(pv.scroll_offset, 0);
}
#[test]
fn pager_view_is_scrolled_to_bottom_accounts_for_wrapped_height() {
let mut pv = pager_view(
vec![paragraph_block("a", /*lines*/ 10)],
"T",
/*scroll_offset*/ 0,
);
let area = Rect::new(0, 0, 20, 8);
let mut buf = Buffer::empty(area);
pv.render(area, &mut buf);
assert!(
!pv.is_scrolled_to_bottom(),
"expected view to report not at bottom when offset < max"
);
pv.scroll_offset = usize::MAX;
pv.render(area, &mut buf);
assert!(
pv.is_scrolled_to_bottom(),
"expected view to report at bottom after scrolling to end"
);
}
}
@@ -0,0 +1,175 @@
//! Reading and prompt backtracking follow current entries through live updates and completion.
use super::*;
use crate::history_cell::PlainHistoryCell;
use crate::history_cell::UserHistoryCell;
use crate::keymap::RuntimeKeymap;
use pretty_assertions::assert_eq;
use ratatui::style::Modifier;
fn user(message: &str) -> Arc<dyn HistoryCell> {
Arc::new(UserHistoryCell {
message: message.to_owned(),
text_elements: Vec::new(),
local_image_paths: Vec::new(),
remote_image_urls: Vec::new(),
spoken: false,
})
}
fn render(overlay: &mut TranscriptOverlay, area: Rect) -> Buffer {
let mut buffer = Buffer::empty(area);
overlay.render(area, &mut buffer);
buffer
}
fn content_rows(overlay: &TranscriptOverlay, buffer: &Buffer) -> Vec<String> {
let area = overlay.content_area;
(area.top()..area.bottom())
.map(|y| {
(area.left()..area.right())
.map(|x| buffer[(x, y)].symbol())
.collect::<String>()
.trim_end()
.to_owned()
})
.collect()
}
#[test]
fn reading_live_output_updates_in_place_and_stays_on_the_committed_entry() {
let mut overlay =
TranscriptOverlay::new(vec![user("first prompt")], RuntimeKeymap::defaults().pager);
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 40, /*height*/ 9,
);
for revision in ["streaming", "updated"] {
let lines = (0..8)
.map(|row| HyperlinkLine::from(format!("{revision} row {row}")))
.collect();
overlay.sync_live_tail(area.width, /*key*/ None, |_| Some(lines));
if revision == "streaming" {
render(&mut overlay, area);
overlay.scroll(/*rows*/ -2);
}
let buffer = render(&mut overlay, area);
assert_eq!(
content_rows(&overlay, &buffer),
(2..6)
.map(|row| format!("{revision} row {row}"))
.collect::<Vec<_>>()
);
assert!(!overlay.view.is_following());
}
overlay.insert_cell(Arc::new(PlainHistoryCell::new(
(0..8)
.map(|row| Line::from(format!("completed row {row}")))
.collect(),
)));
overlay.sync_live_tail(area.width, /*key*/ None, |_| None);
overlay.insert_cell(user("queued prompt"));
let completed = render(&mut overlay, area);
assert_eq!(
content_rows(&overlay, &completed),
[
"completed row 2",
"completed row 3",
"completed row 4",
"completed row 5"
]
);
overlay.set_highlight_cell(Some(2));
let highlighted = render(&mut overlay, area);
assert!(
content_rows(&overlay, &highlighted)
.iter()
.any(|row| row == "› queued prompt")
);
assert!(!overlay.view.is_following());
overlay.view.jump_to_latest();
let latest = render(&mut overlay, area);
let latest = content_rows(&overlay, &latest).join("\n");
assert!(latest.contains("queued prompt"));
assert!(!latest.contains("updated row"));
}
#[test]
fn backtracking_reveals_a_prompt_when_only_its_final_padding_is_visible() {
let mut overlay = TranscriptOverlay::new(
vec![
user("first prompt"),
Arc::new(PlainHistoryCell::new(vec![
"tail zero".into(),
"tail one".into(),
"tail two".into(),
])),
],
RuntimeKeymap::defaults().pager,
);
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 40, /*height*/ 10,
);
let before = render(&mut overlay, area);
assert_eq!(
content_rows(&overlay, &before),
["", "", "tail zero", "tail one", "tail two"]
);
let first_prompt_index = 0;
overlay.set_highlight_cell(Some(first_prompt_index));
let highlighted = render(&mut overlay, area);
let rows = content_rows(&overlay, &highlighted);
let prompt_row = rows
.iter()
.position(|row| row == "› first prompt")
.expect("backtracking reveals the prompt body, not just its padding");
let y = overlay.content_area.y + prompt_row as u16;
assert!(
(2..2 + "first prompt".len() as u16)
.all(|x| highlighted[(x, y)].modifier.contains(Modifier::REVERSED))
);
}
#[test]
fn prompt_highlight_leaves_gutters_and_padding_unreversed() {
crate::terminal_palette::with_test_default_colors(
crate::terminal_probe::DefaultColors {
fg: (32, 32, 32),
bg: (255, 255, 255),
},
|| {
let mut snapshots = Vec::new();
for (width, height) in [(40, 10), (12, 12)] {
let mut overlay = TranscriptOverlay::new(
vec![
Arc::new(PlainHistoryCell::new(vec!["prior".into()])),
user("alpha beta gamma"),
],
RuntimeKeymap::defaults().pager,
);
let first_prompt_index = 1;
overlay.set_highlight_cell(Some(first_prompt_index));
let area = Rect::new(/*x*/ 0, /*y*/ 0, width, height);
let buffer = render(&mut overlay, area);
let content = Buffer {
area: Rect::new(
/*x*/ 0,
/*y*/ 0,
width,
overlay.content_area.height,
),
content: buffer
.content
.chunks(usize::from(width))
.skip(usize::from(overlay.content_area.y))
.take(usize::from(overlay.content_area.height))
.flatten()
.cloned()
.collect(),
};
snapshots.push(format!("{width} columns\n{content:?}"));
}
insta::assert_snapshot!(snapshots.join("\n\n"));
},
);
}
@@ -1,140 +0,0 @@
//! Backtrack selection preserves unrelated layout caches and the complete rendered viewport.
use super::*;
use crate::history_cell::UserHistoryCell;
use crate::keymap::RuntimeKeymap;
use pretty_assertions::assert_eq;
use std::sync::atomic::AtomicUsize;
use std::sync::atomic::Ordering;
#[derive(Debug)]
struct MeasuredCell {
measurements: AtomicUsize,
}
impl HistoryCell for MeasuredCell {
fn display_lines(&self, _width: u16) -> Vec<Line<'static>> {
self.measurements.fetch_add(1, Ordering::Relaxed);
vec!["history".into()]
}
fn raw_lines(&self) -> Vec<Line<'static>> {
vec!["history".into()]
}
}
#[test]
fn moving_highlight_preserves_unaffected_height_caches() {
let cells: Vec<_> = (0..32)
.map(|_| {
Arc::new(MeasuredCell {
measurements: AtomicUsize::new(0),
})
})
.collect();
let mut overlay = TranscriptOverlay::new(
cells
.iter()
.map(|cell| cell.clone() as Arc<dyn HistoryCell>)
.collect(),
RuntimeKeymap::defaults().pager,
);
let mut area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 40, /*height*/ 12,
);
overlay.render(area, &mut Buffer::empty(area));
for selection in [Some(30), Some(28), Some(28), None] {
overlay.set_highlight_cell(selection);
overlay.render(area, &mut Buffer::empty(area));
}
let measurements = || {
cells
.iter()
.map(|cell| cell.measurements.load(Ordering::Relaxed))
.collect::<Vec<_>>()
};
let mut expected = vec![1; cells.len()];
assert_eq!(measurements(), expected);
// Width changes still invalidate every cached height.
area.width = 24;
overlay.render(area, &mut Buffer::empty(area));
for count in &mut expected {
*count += 1;
}
assert_eq!(measurements(), expected);
}
#[test]
fn moving_highlight_matches_full_rebuild_with_live_tail() {
let cells: Vec<Arc<dyn HistoryCell>> = ["first prompt", "second prompt"]
.into_iter()
.map(|message| {
Arc::new(UserHistoryCell {
message: message.to_string(),
text_elements: Vec::new(),
local_image_paths: Vec::new(),
remote_image_urls: Vec::new(),
spoken: false,
}) as Arc<dyn HistoryCell>
})
.collect();
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*/ 40,
Some(ActiveCellTranscriptKey {
cacheable: true,
revision: 1,
is_stream_continuation: false,
animation_tick: None,
}),
|_| Some(vec![HyperlinkLine::from("live tail")]),
);
}
for width in [40, 24, 40] {
for selection in [Some(0), Some(1), Some(1), None, Some(99), Some(0)] {
actual.set_highlight_cell(selection);
let tail = expected.take_live_tail_renderable();
expected.highlight_cell = selection;
expected.rebuild_renderables(tail);
if let Some(index) = selection {
expected.view.scroll_chunk_into_view(index);
}
let area = Rect::new(/*x*/ 0, /*y*/ 0, width, /*height*/ 12);
let mut actual_buffer = Buffer::empty(area);
let mut expected_buffer = Buffer::empty(area);
actual.render(area, &mut actual_buffer);
expected.render(area, &mut expected_buffer);
assert_eq!(actual_buffer, expected_buffer);
assert_eq!(actual.view.scroll_offset, expected.view.scroll_offset);
}
}
actual.set_highlight_cell(Some(1));
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 40, /*height*/ 20,
);
let mut buffer = Buffer::empty(area);
actual.render(area, &mut buffer);
let content = (1..area.height - 4)
.map(|y| {
(0..area.width)
.map(|x| buffer[(x, y)].symbol())
.collect::<String>()
})
.map(|line| line.trim_end().to_string())
.filter(|line| !line.is_empty() && line != "~")
.collect::<Vec<_>>()
.join("\n");
insta::assert_snapshot!(content, @"
› first prompt
› second prompt
live tail
");
}
+1 -81
View File
@@ -1,88 +1,8 @@
//! Viewport-aware transcript rendering and the fallback for generic pager content.
//! Viewport fallback for generic static pager content.
use crate::transcript_view::LayoutCache;
use std::cell::RefCell;
use std::rc::Rc;
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::HyperlinkParagraph;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::widgets::Widget;
/// 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,
pub(super) cache: Rc<RefCell<LayoutCache>>,
}
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 layout = self
.cache
.borrow_mut()
.get(&self.cell, area.width, /*separated*/ false);
let style = if self.cell.as_any().is::<UserHistoryCell>() {
if self.highlighted {
user_message_style().reversed()
} else {
user_message_style()
}
} else {
Style::default()
};
buf.set_style(area, style);
layout.render(area, buf, usize::from(scroll_offset));
true
}
fn desired_height(&self, width: u16) -> u16 {
self.cache
.borrow_mut()
.get(&self.cell, width, /*separated*/ false)
.row_count()
.try_into()
.unwrap_or(u16::MAX)
}
}
/// 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 {
HyperlinkParagraph::new(&self.lines, Style::default())
.scroll(scroll_offset)
.render(area, buf);
true
}
fn desired_height(&self, width: u16) -> u16 {
HyperlinkParagraph::new(&self.lines, Style::default())
.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(
+36 -166
View File
@@ -1,14 +1,10 @@
//! Static pager fallback preserves styled buffer slices at arbitrary scroll offsets.
use super::super::CachedRenderable;
use super::CellRenderable;
use super::HyperlinkLinesRenderable;
use super::render_offset_content;
use crate::history_cell::HistoryCell;
use crate::history_cell::UserHistoryCell;
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;
@@ -17,180 +13,54 @@ 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 {
spoken: false,
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 {
cache: Default::default(),
cell: cell.clone(),
highlighted: false,
}),
),
(
"highlighted cached user history cell",
Box::new(CachedRenderable::new(CellRenderable {
cache: Default::default(),
cell: user,
highlighted: true,
})),
),
(
"inset cached history cell",
#[test]
fn scrolled_static_renderables_match_clipped_buffer_slices() {
let lines = vec![
Line::from(vec![
"prefix ".green(),
"漢字 ガ ".bold(),
"styled words that wrap across rows".blue(),
]),
Line::default(),
Line::from("last 漢字 ガ row".red()),
];
for width in [5, 7, 13, 28] {
let paragraph = || Paragraph::new(Text::from(lines.clone())).wrap(Wrap { trim: false });
let renderables: Vec<Box<dyn Renderable>> = vec![
Box::new(paragraph()),
Box::new(CachedRenderable::new(paragraph())),
Box::new(InsetRenderable::new(
Box::new(CachedRenderable::new(CellRenderable {
cache: Default::default(),
cell,
highlighted: false,
})) as Box<dyn Renderable>,
Box::new(CachedRenderable::new(paragraph())) 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,
};
];
for renderable in renderables {
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 offset in [0, 1, 2, 3, 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 canvas = Rect::new(/*x*/ 0, /*y*/ 0, area.right(), area.bottom());
let mut expected = Buffer::empty(canvas);
let mut actual = Buffer::empty(canvas);
let expected_height = visible_height.min(height.saturating_sub(offset));
let full_area =
Rect::new(/*x*/ 0, /*y*/ 0, width, offset + expected_height);
let mut full = Buffer::empty(full_area);
renderable.render(full_area, &mut full);
for row in 0..expected_height {
for column in 0..width {
expected[(x + column, y + row)] = full[(column, offset + row)].clone();
}
}
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:?}",
"width={width}, offset={offset}, area={area:?}"
);
}
}
@@ -0,0 +1,48 @@
---
source: tui/src/pager_overlay/anchor_tests.rs
assertion_line: 187
expression: "snapshots.join(\"\\n\\n\")"
---
40 columns
Buffer {
area: Rect { x: 0, y: 0, width: 40, height: 5 },
content: [
"prior ",
" ",
" ",
"› alpha beta gamma ",
" ",
],
styles: [
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 2, fg: Reset, bg: Rgb(249, 249, 249), underline: Reset, modifier: NONE,
x: 0, y: 3, fg: Reset, bg: Rgb(249, 249, 249), underline: Reset, modifier: BOLD | DIM,
x: 2, y: 3, fg: Reset, bg: Rgb(249, 249, 249), underline: Reset, modifier: REVERSED,
x: 18, y: 3, fg: Reset, bg: Rgb(249, 249, 249), underline: Reset, modifier: NONE,
]
}
12 columns
Buffer {
area: Rect { x: 0, y: 0, width: 12, height: 7 },
content: [
"prior ",
" ",
" ",
"› alpha ",
" beta ",
" gamma ",
" ",
],
styles: [
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 2, fg: Reset, bg: Rgb(249, 249, 249), underline: Reset, modifier: NONE,
x: 0, y: 3, fg: Reset, bg: Rgb(249, 249, 249), underline: Reset, modifier: BOLD | DIM,
x: 2, y: 3, fg: Reset, bg: Rgb(249, 249, 249), underline: Reset, modifier: REVERSED,
x: 7, y: 3, fg: Reset, bg: Rgb(249, 249, 249), underline: Reset, modifier: NONE,
x: 2, y: 4, fg: Reset, bg: Rgb(249, 249, 249), underline: Reset, modifier: REVERSED,
x: 6, y: 4, fg: Reset, bg: Rgb(249, 249, 249), underline: Reset, modifier: NONE,
x: 2, y: 5, fg: Reset, bg: Rgb(249, 249, 249), underline: Reset, modifier: REVERSED,
x: 7, y: 5, fg: Reset, bg: Rgb(249, 249, 249), underline: Reset, modifier: NONE,
]
}
@@ -10,6 +10,6 @@ expression: snapshot
• Added foo.txt (+2 -0)
1 +hello
2 +world
─────────────────────────────────────────────────────────────────────────── 0% ─
────────────────────────────────────────────────────────────────────────────────
↑/↓ to scroll pgup/pgdn to page home/end to jump
q close esc to edit prev
@@ -1,13 +0,0 @@
---
source: tui/src/pager_overlay/transcript_tests.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 close esc to edit prev
@@ -1,7 +0,0 @@
---
source: tui/src/pager_overlay/transcript_tests.rs
expression: visible_before.trim()
---
line2
line3
+227 -397
View File
@@ -1,432 +1,302 @@
//! Detailed transcript overlay over committed history and the current live tail.
//! Detailed transcript overlay with entry-based scrolling and the existing pager chrome.
//!
//! The overlay owns committed cells and input bindings. Its viewport displays current content
//! and keeps readers on the same entry through pagination and stream consolidation.
use super::*;
use crate::transcript_view::LayoutCache;
use std::cell::RefCell;
use std::rc::Rc;
use crate::transcript_view::TranscriptView;
pub(crate) struct TranscriptOverlay {
/// Pager UI state and the renderables currently displayed.
///
/// The invariant is that `view.renderables` is `render_cells(cells)` plus an optional trailing
/// live-tail renderable appended after the committed cells.
view: PagerView,
cache: Rc<RefCell<LayoutCache>>,
/// Committed transcript cells (does not include the live tail).
view: TranscriptView,
cells: Vec<Arc<dyn HistoryCell>>,
keymap: PagerKeymap,
highlight_cell: Option<usize>,
/// Cache key for the render-only live tail appended after committed cells.
live_tail_key: Option<LiveTailKey>,
history_state: TranscriptHistoryState,
reveal_highlight: bool,
content_area: Rect,
is_done: bool,
}
/// Cache key for the active-cell "live tail" appended to the transcript overlay.
///
/// Changing any field implies a different rendered tail.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct LiveTailKey {
/// Current terminal width, which affects wrapping.
width: u16,
/// Revision that changes on in-place active cell transcript updates.
revision: u64,
/// Whether the tail should be treated as a continuation for spacing.
is_stream_continuation: bool,
/// Optional animation tick to refresh spinners/progress indicators.
animation_tick: Option<u64>,
}
impl TranscriptOverlay {
/// Creates a transcript overlay for a fixed set of committed cells.
///
/// This overlay does not own the "active cell"; callers may optionally append a live tail via
/// `sync_live_tail` during draws to reflect in-flight activity.
pub(crate) fn new(transcript_cells: Vec<Arc<dyn HistoryCell>>, keymap: PagerKeymap) -> Self {
let cache = Rc::new(RefCell::new(LayoutCache::default()));
pub(crate) fn new(cells: Vec<Arc<dyn HistoryCell>>, keymap: PagerKeymap) -> Self {
Self {
view: PagerView::new(
Self::render_cells(
&cache,
&transcript_cells,
/*highlight_cell*/ None,
TranscriptHistoryState::Idle,
),
"T R A N S C R I P T".to_string(),
usize::MAX,
keymap,
),
cache,
cells: transcript_cells,
view: TranscriptView::default(),
cells,
keymap,
highlight_cell: None,
live_tail_key: None,
history_state: TranscriptHistoryState::Idle,
reveal_highlight: false,
content_area: Rect::default(),
is_done: false,
}
}
pub(crate) fn render(&mut self, area: Rect, buf: &mut Buffer) {
Clear.render(area, buf);
let top_height = area.height.saturating_sub(/*rhs*/ 3);
let top = Rect::new(area.x, area.y, area.width, top_height);
self.content_area = Rect::new(
area.x,
area.y.saturating_add(/*rhs*/ 1),
area.width,
top_height.saturating_sub(/*rhs*/ 2),
)
.intersection(area);
let mut rendered =
self.view
.render(self.content_area, buf, &self.cells, self.highlight_cell);
if std::mem::take(&mut self.reveal_highlight)
&& let Some(index) = self.highlight_cell
{
self.view.ensure_entry_visible(&self.cells, index);
rendered = self
.view
.render(self.content_area, buf, &self.cells, self.highlight_cell);
}
if self.view.history == TranscriptHistoryState::LoadingBeginning
&& self.view.is_following()
&& !self.content_area.is_empty()
{
// Home may arrive before the first draw; retain the first real viewport.
self.view.scroll(&self.cells, /*rows*/ 0);
}
if area.width > 0 {
for y in self.content_area.y + rendered..self.content_area.bottom() {
"~".render(Rect::new(area.x, y, /*width*/ 1, /*height*/ 1), buf);
}
}
let header = Rect::new(area.x, area.y, area.width, area.height.min(/*other*/ 1));
Span::from("/ ".repeat(usize::from(area.width) / 2))
.dim()
.render(header, buf);
"/ T R A N S C R I P T".dim().render(header, buf);
self.render_history_state(top, buf);
let separator = Rect::new(
area.x,
self.content_area.bottom(),
area.width,
/*height*/ 1,
)
.intersection(area);
"─"
.repeat(usize::from(separator.width))
.dim()
.render(separator, buf);
// An exact intermediate percentage would require laying out every offscreen cell.
// Only the loaded tail has a known percentage without defeating bounded rendering.
if self.view.is_following()
&& !self.view.history.has_unloaded_history()
&& separator.width >= 7
{
" 100% ".dim().render(
Rect::new(
separator.right() - 7,
separator.y,
/*width*/ 6,
separator.height,
),
buf,
);
}
let footer =
Rect::new(area.x, area.y + top_height, area.width, /*height*/ 3).intersection(area);
self.render_hints(footer, buf);
}
pub(crate) fn handle_event(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> {
match event {
TuiEvent::Key(key) => {
if self.keymap.close.is_pressed(key) || self.keymap.close_transcript.is_pressed(key)
{
self.is_done = true;
} else if self.navigate(key) {
tui.frame_requester()
.schedule_frame_in(crate::tui::TARGET_FRAME_INTERVAL);
}
Ok(())
}
TuiEvent::Draw | TuiEvent::Resume | TuiEvent::Resize(_) | TuiEvent::FocusGained => {
tui.draw(u16::MAX, |frame| self.render(frame.area(), frame.buffer))?;
Ok(())
}
_ => Ok(()),
}
}
pub(crate) fn set_history_state(
&mut self,
state: TranscriptHistoryState,
) -> TranscriptHistoryState {
let previous = self.history_state;
if previous == state {
return previous;
let previous = std::mem::replace(&mut self.view.history, state);
if state == TranscriptHistoryState::LoadingBeginning
&& previous != TranscriptHistoryState::LoadingBeginning
&& !self.content_area.is_empty()
{
// App marks loading before forwarding Home, so record the current position here.
self.view.scroll(&self.cells, /*rows*/ 0);
}
if previous == TranscriptHistoryState::LoadingBeginning
&& state == TranscriptHistoryState::Complete
{
self.view.scroll_offset = 0;
}
self.history_state = state;
self.view.scroll_percentage_visible = !state.has_unloaded_history();
if self
.cells
.iter()
.any(|cell| cell.as_any().is::<SessionInfoCell>())
{
let live_tail = self.take_live_tail_renderable();
self.rebuild_renderables(live_tail);
self.view.jump_to_entry(&self.cells, /*index*/ 0);
}
previous
}
fn render_cells(
cache: &Rc<RefCell<LayoutCache>>,
cells: &[Arc<dyn HistoryCell>],
highlight_cell: Option<usize>,
history_state: TranscriptHistoryState,
) -> Vec<Box<dyn Renderable>> {
cells
.iter()
.enumerate()
.map(|(i, cell)| Self::render_cell(cache, cell, i, highlight_cell, history_state))
.collect()
pub(crate) fn should_load_older(&mut self, key: KeyEvent) -> bool {
self.should_load_from_start(key)
|| (self.view.near_start(&self.cells)
&& [
&self.keymap.scroll_up,
&self.keymap.page_up,
&self.keymap.half_page_up,
]
.iter()
.any(|bindings| bindings.is_pressed(key)))
}
/// Build the renderable for a committed cell, caching its height when the cell is stable.
fn render_cell(
cache: &Rc<RefCell<LayoutCache>>,
cell: &Arc<dyn HistoryCell>,
index: usize,
highlight_cell: Option<usize>,
history_state: TranscriptHistoryState,
) -> Box<dyn Renderable> {
if cell.as_any().is::<SessionInfoCell>()
&& let Some(placeholder) = history_state.session_header_placeholder()
{
return Box::new(Line::from(placeholder).dim());
}
let cell_renderable = CellRenderable {
cache: Rc::clone(cache),
cell: cell.clone(),
highlighted: highlight_cell == Some(index),
};
let mut cell_renderable: Box<dyn Renderable> = if cell.has_stable_transcript_height() {
Box::new(CachedRenderable::new(cell_renderable))
} else {
Box::new(cell_renderable)
};
if !cell.is_stream_continuation() && index > 0 {
cell_renderable = Box::new(InsetRenderable::new(
cell_renderable,
Insets::tlbr(
/*top*/ 1, /*left*/ 0, /*bottom*/ 0, /*right*/ 0,
),
));
}
cell_renderable
pub(crate) fn should_load_from_start(&self, key: KeyEvent) -> bool {
self.keymap.jump_top.is_pressed(key)
}
/// Insert a committed history cell while keeping any cached live tail.
///
/// The live tail is temporarily removed, the new committed cell is appended,
/// then the tail is reattached. If the tail previously had no leading
/// spacing because it was the only renderable, we add the missing inset
/// when the first committed cell arrives.
///
/// This expects `cell` to be a committed transcript cell (not the in-flight active cell). If
/// the overlay was scrolled to bottom before insertion, it remains pinned to bottom after the
/// insertion to preserve the "follow along" behavior.
pub(crate) fn insert_cell(&mut self, cell: Arc<dyn HistoryCell>) {
let follow_bottom = self.view.is_scrolled_to_bottom();
let had_prior_cells = !self.cells.is_empty();
let tail_renderable = self.take_live_tail_renderable();
let cell_renderable = Self::render_cell(
&self.cache,
&cell,
self.cells.len(),
self.highlight_cell,
self.history_state,
);
let index = self.cells.len();
self.view
.replace_range(&self.cells, index..index + 1, &cell);
self.cells.push(cell);
self.view.renderables.push(cell_renderable);
if let Some(tail) = tail_renderable {
let tail = if !had_prior_cells
&& self
.live_tail_key
.is_some_and(|key| !key.is_stream_continuation)
{
// The tail was rendered as the only entry, so it lacks a top
// inset; add one now that it follows a committed cell.
Box::new(InsetRenderable::new(
tail,
Insets::tlbr(
/*top*/ 1, /*left*/ 0, /*bottom*/ 0, /*right*/ 0,
),
)) as Box<dyn Renderable>
} else {
tail
};
self.view.renderables.push(tail);
}
if follow_bottom {
self.view.scroll_offset = usize::MAX;
}
}
/// Returns whether an upward navigation is close enough to request older history.
pub(crate) fn should_load_older(&self, key_event: KeyEvent) -> bool {
self.should_load_from_start(key_event)
|| (self.view.scroll_offset
<= self.view.last_content_height.unwrap_or(/*default*/ 0)
&& (self.view.keymap.scroll_up.is_pressed(key_event)
|| self.view.keymap.page_up.is_pressed(key_event)
|| self.view.keymap.half_page_up.is_pressed(key_event)))
}
pub(crate) fn should_load_from_start(&self, key_event: KeyEvent) -> bool {
self.view.keymap.jump_top.is_pressed(key_event)
}
/// Prepends history without moving visible content and returns its insertion index.
pub(crate) fn prepend(&mut self, cells: Vec<Arc<dyn HistoryCell>>, width: u16) -> usize {
/// Prepends history and returns the insertion index for the canonical transcript.
pub(crate) fn prepend(&mut self, cells: Vec<Arc<dyn HistoryCell>>) -> usize {
if cells.is_empty() {
return 0;
}
let follow_bottom = self.view.is_scrolled_to_bottom();
let previous_height = self.view.content_height(width);
let live_tail = self.take_live_tail_renderable();
let added_cells = cells.len();
let insert_at = self
let index = self
.cells
.iter()
.rposition(|cell| cell.as_any().is::<SessionInfoCell>())
.map_or(/*default*/ 0, |index| index.saturating_add(/*rhs*/ 1));
self.cells.splice(insert_at..insert_at, cells);
for index in [
&mut self.highlight_cell,
&mut self.view.pending_scroll_chunk,
] {
if let Some(index) = index.as_mut()
&& *index >= insert_at
{
*index = index.saturating_add(added_cells);
}
}
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(content_height.saturating_sub(previous_height))
};
self.view.last_rendered_height = Some(content_height);
insert_at
}
/// Replace committed transcript cells while keeping any cached in-progress output that is
/// currently shown at the end of the overlay.
///
/// This is used when existing history is replaced or trimmed so the
/// 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
.map_or(/*default*/ 0, |index| index + 1);
let added = cells.len();
self.cells.splice(index..index, cells);
if let Some(highlight) = self
.highlight_cell
.is_some_and(|idx| idx >= self.cells.len())
.as_mut()
.filter(|highlight| **highlight >= index)
{
self.highlight_cell = None;
}
self.rebuild_renderables(live_tail);
if follow_bottom {
self.view.scroll_offset = usize::MAX;
*highlight += added;
}
self.view.history_changed(&self.cells);
index
}
pub(crate) fn replace_cells(&mut self, cells: Vec<Arc<dyn HistoryCell>>) {
self.cells = cells;
self.view.history_changed(&self.cells);
self.highlight_cell = self
.highlight_cell
.filter(|index| *index < self.cells.len());
}
/// Replace a range of committed cells with a single consolidated cell.
///
/// Mirrors the splice performed on `App::transcript_cells` during
/// `ConsolidateAgentMessage` so the Ctrl+T overlay stays in sync with the
/// main transcript. The range is clamped defensively: cells may have been
/// inserted after the overlay opened, leaving it with fewer entries than
/// the main transcript.
pub(crate) fn consolidate_cells(
&mut self,
range: std::ops::Range<usize>,
consolidated: Arc<dyn HistoryCell>,
) {
let follow_bottom = self.view.is_scrolled_to_bottom();
// Clamp the range to the overlay's cell count to avoid panic if the overlay has fewer
// cells than the main transcript (e.g. cells were inserted after the overlay has opened).
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
{
if *highlight_cell < clamped_end {
*highlight_cell = clamped_start;
} else {
*highlight_cell = highlight_cell.saturating_sub(removed.saturating_sub(1));
}
}
self.cells
.splice(clamped_start..clamped_end, std::iter::once(consolidated));
if self
.highlight_cell
.is_some_and(|highlight_cell| highlight_cell >= self.cells.len())
{
self.highlight_cell = None;
}
self.rebuild_renderables(live_tail);
}
if follow_bottom {
self.view.scroll_offset = usize::MAX;
let end = range.end.min(self.cells.len());
let start = range.start.min(end);
if start == end {
return;
}
self.view
.replace_range(&self.cells, start..end, &consolidated);
self.highlight_cell = self.highlight_cell.map(|index| {
if index < start {
index
} else if index < end {
start
} else {
index - (end - start - 1)
}
});
self.cells.splice(start..end, [consolidated]);
}
/// Sync the active-cell live tail with the current width and cell state.
///
/// Recomputes the tail only when the cache key changes, preserving scroll
/// position and dropping the tail if there is nothing to render.
///
/// The overlay owns committed transcript cells while the live tail is derived from the current
/// active cell, which can mutate in place while streaming. `App` calls this during
/// `TuiEvent::Draw` for `Overlay::Transcript`, passing a key that changes when the active cell
/// mutates or animates so the cached tail stays fresh.
///
/// Passing a key that does not change on in-place active-cell mutations will freeze the tail in
/// `Ctrl+T` while the main viewport continues to update.
pub(crate) fn sync_live_tail(
&mut self,
width: u16,
active_key: Option<ActiveCellTranscriptKey>,
key: Option<ActiveCellTranscriptKey>,
compute_lines: impl FnOnce(u16) -> Option<Vec<HyperlinkLine>>,
) {
let next_key = active_key.map(|key| LiveTailKey {
width,
revision: key.revision,
is_stream_continuation: key.is_stream_continuation,
animation_tick: key.animation_tick,
});
if active_key.is_some_and(|key| key.cacheable) && self.live_tail_key == next_key {
return;
}
let follow_bottom = self.view.is_scrolled_to_bottom();
self.take_live_tail_renderable();
self.live_tail_key = next_key;
if let Some(key) = next_key {
let lines = compute_lines(width).unwrap_or_default();
if !lines.is_empty() {
self.view.renderables.push(Self::live_tail_renderable(
lines,
!self.cells.is_empty(),
key.is_stream_continuation,
));
}
}
if follow_bottom {
self.view.scroll_offset = usize::MAX;
}
self.view.sync_live_tail(width, key, compute_lines);
}
pub(crate) fn set_highlight_cell(&mut self, cell: Option<usize>) {
let previous = self.highlight_cell;
self.highlight_cell = cell;
// Highlighting changes only these cells' styling. Keep the other renderables and their
// cached heights so moving between prompts does not lay out the entire transcript again.
if previous != cell {
for index in [previous, cell].into_iter().flatten() {
if let Some(history_cell) = self.cells.get(index) {
self.view.renderables[index] = Self::render_cell(
&self.cache,
history_cell,
index,
self.highlight_cell,
self.history_state,
);
}
self.highlight_cell = cell.filter(|index| *index < self.cells.len());
self.reveal_highlight = self.highlight_cell.is_some();
}
pub(crate) fn scroll(&mut self, rows: isize) {
if std::mem::take(&mut self.reveal_highlight)
&& let Some(index) = self.highlight_cell
{
self.view.ensure_entry_visible(&self.cells, index);
}
self.view.scroll(&self.cells, rows);
}
fn navigate(&mut self, key: KeyEvent) -> bool {
if self.keymap.jump_top.is_pressed(key) {
if self.content_area.is_empty() && self.view.history.has_unloaded_history() {
self.set_history_state(TranscriptHistoryState::LoadingBeginning);
} else {
self.view.jump_to_beginning(&self.cells);
}
return true;
}
if let Some(idx) = self.highlight_cell {
self.view.scroll_chunk_into_view(idx);
if self.keymap.jump_bottom.is_pressed(key) {
self.view.jump_to_latest();
return true;
}
let page = self.content_area.height.max(/*other*/ 1) as isize;
let half = (page + 1) / 2;
let delta = [
(&self.keymap.scroll_up, -1),
(&self.keymap.scroll_down, 1),
(&self.keymap.page_up, -page),
(&self.keymap.page_down, page),
(&self.keymap.half_page_up, -half),
(&self.keymap.half_page_down, half),
]
.into_iter()
.find_map(|(bindings, delta)| bindings.is_pressed(key).then_some(delta));
let Some(delta) = delta else {
return false;
};
if !self.content_area.is_empty() {
self.scroll(delta);
}
true
}
/// Returns whether the underlying pager view is currently pinned to the bottom.
///
/// The `App` draw loop uses this to decide whether to schedule animation frames for the live
/// tail; if the user has scrolled up, we avoid driving animation work that they cannot see.
pub(crate) fn is_scrolled_to_bottom(&self) -> bool {
self.view.is_scrolled_to_bottom()
pub(crate) fn live_tail_visible(&self) -> bool {
self.view.live_tail_visible()
}
// 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.cache,
&self.cells,
self.highlight_cell,
self.history_state,
);
if let Some(tail) = tail_renderable {
self.view.renderables.push(tail);
}
}
/// Removes and returns the cached live-tail renderable, if present.
///
/// The live tail is represented as a single optional renderable appended after the committed
/// cell renderables, so this relies on the live tail always being the final entry in
/// `view.renderables` when present.
fn take_live_tail_renderable(&mut self) -> Option<Box<dyn Renderable>> {
(self.view.renderables.len() > self.cells.len()).then(|| self.view.renderables.pop())?
}
fn live_tail_renderable(
lines: Vec<HyperlinkLine>,
has_prior_cells: bool,
is_stream_continuation: bool,
) -> Box<dyn Renderable> {
let mut renderable: Box<dyn Renderable> =
Box::new(CachedRenderable::new(HyperlinkLinesRenderable { lines }));
if has_prior_cells && !is_stream_continuation {
renderable = Box::new(InsetRenderable::new(
renderable,
Insets::tlbr(
/*top*/ 1, /*left*/ 0, /*bottom*/ 0, /*right*/ 0,
),
));
}
renderable
pub(crate) fn is_done(&self) -> bool {
self.is_done
}
fn render_hints(&self, area: Rect, buf: &mut Buffer) {
let line1 = Rect::new(area.x, area.y, area.width, 1);
let line2 = Rect::new(area.x, area.y.saturating_add(1), area.width, 1);
render_navigation_hints(line1, buf, &self.view.keymap);
let line1 = Rect::new(area.x, area.y, area.width, /*height*/ 1).intersection(area);
let line2 = Rect::new(
area.x,
area.y.saturating_add(/*rhs*/ 1),
area.width,
/*height*/ 1,
)
.intersection(area);
render_navigation_hints(line1, buf, &self.keymap);
let mut pairs: Vec<(Vec<ShortcutHint>, &str)> = vec![(
first_or_empty(&self.view.keymap, "close", &self.view.keymap.close),
first_or_empty(&self.keymap, "close", &self.keymap.close),
"close",
)];
if self.highlight_cell.is_some() {
@@ -448,25 +318,11 @@ impl TranscriptOverlay {
render_key_hints(line2, buf, &pairs);
}
pub(crate) fn render(&mut self, area: Rect, buf: &mut Buffer) {
self.cache.borrow_mut().begin_frame();
// 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);
self.view.render(top, buf);
self.render_history_state(top, buf);
self.render_hints(bottom, buf);
}
fn render_history_state(&self, area: Rect, buf: &mut Buffer) {
if area.height == 0 {
return;
}
let label = match self.history_state {
let label = match self.view.history {
TranscriptHistoryState::Idle => return,
TranscriptHistoryState::LoadingOlder | TranscriptHistoryState::LoadingBeginning => {
" loading older history... "
@@ -486,36 +342,10 @@ impl TranscriptOverlay {
}
}
impl TranscriptOverlay {
pub(crate) fn handle_event(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> {
match event {
TuiEvent::Key(key_event) => match key_event {
e if self.view.keymap.close.is_pressed(e)
|| self.view.keymap.close_transcript.is_pressed(e) =>
{
self.is_done = true;
Ok(())
}
other => self.view.handle_key_event(tui, other),
},
TuiEvent::Draw | TuiEvent::Resume | TuiEvent::Resize(_) | TuiEvent::FocusGained => {
tui.draw(u16::MAX, |frame| {
self.render(frame.area(), frame.buffer);
})?;
Ok(())
}
_ => Ok(()),
}
}
pub(crate) fn is_done(&self) -> bool {
self.is_done
}
}
#[cfg(test)]
#[path = "transcript_tests.rs"]
mod tests;
#[cfg(test)]
#[path = "highlight_tests.rs"]
mod highlight_tests;
#[path = "anchor_tests.rs"]
mod anchor_tests;
+415 -370
View File
@@ -41,13 +41,13 @@ impl crate::history_cell::HistoryCell for TestCell {
}
#[derive(Debug)]
struct HeightCountingCell {
height_calls: Arc<AtomicUsize>,
struct LayoutCountingCell {
layout_calls: Arc<AtomicUsize>,
}
impl crate::history_cell::HistoryCell for HeightCountingCell {
impl crate::history_cell::HistoryCell for LayoutCountingCell {
fn display_lines(&self, _width: u16) -> Vec<Line<'static>> {
self.height_calls.fetch_add(1, Ordering::Relaxed);
self.layout_calls.fetch_add(/*val*/ 1, Ordering::Relaxed);
vec![Line::from("counted")]
}
@@ -66,7 +66,7 @@ fn transcript_overlay(cells: Vec<Arc<dyn HistoryCell>>) -> TranscriptOverlay {
#[test]
fn jump_top_requests_older_history_from_the_bottom() {
let overlay = transcript_overlay(vec![Arc::new(TestCell {
let mut overlay = transcript_overlay(vec![Arc::new(TestCell {
lines: vec![Line::from("recent")],
})]);
@@ -81,22 +81,149 @@ fn jump_top_requests_older_history_from_the_bottom() {
}
#[test]
fn edit_next_hint_is_visible_when_highlighted() {
let mut overlay = transcript_overlay(vec![Arc::new(TestCell {
lines: vec![Line::from("hello")],
})]);
overlay.set_highlight_cell(Some(0));
// Render into a wide buffer so the footer hints aren't truncated.
let area = Rect::new(0, 0, 120, 10);
let mut buf = Buffer::empty(area);
overlay.render(area, &mut buf);
let s = buffer_to_text(&buf, area);
assert!(
s.contains("edit next"),
"expected 'edit next' hint in overlay footer, got: {s:?}"
fn first_upward_key_requests_history_when_the_loaded_start_is_visible() {
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 40, /*height*/ 10,
);
for committed in [false, true] {
for (line_count, near_start) in [(0, true), (3, true), (7, true), (30, false)] {
let lines = (0..line_count)
.map(|index| Line::from(format!("line {index}")))
.collect::<Vec<_>>();
let cells = if committed && !lines.is_empty() {
vec![Arc::new(TestCell {
lines: lines.clone(),
}) as Arc<dyn HistoryCell>]
} else {
Vec::new()
};
let mut overlay = transcript_overlay(cells);
if !committed {
overlay.sync_live_tail(area.width, /*key*/ None, |_| {
Some(lines.into_iter().map(HyperlinkLine::from).collect())
});
}
overlay.set_history_state(TranscriptHistoryState::Partial);
overlay.render(area, &mut Buffer::empty(area));
assert!(overlay.view.is_following());
// App checks pagination before forwarding the first navigation key.
for key in [
KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),
KeyEvent::new(KeyCode::PageUp, KeyModifiers::NONE),
KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL),
] {
assert_eq!(overlay.should_load_older(key), near_start);
}
assert!(!overlay.should_load_older(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE,)));
if line_count == 30 {
let up = KeyEvent::new(KeyCode::Up, KeyModifiers::NONE);
overlay.scroll(/*rows*/ -1);
assert!(
!overlay.should_load_older(up),
"one row must not prefetch a distant page"
);
overlay.scroll(/*rows*/ -18);
assert!(
!overlay.should_load_older(up),
"row six is outside the five-row prefetch threshold"
);
overlay.scroll(/*rows*/ -1);
assert!(
overlay.should_load_older(up),
"prefetch at row five even before a draw"
);
}
}
}
}
#[tokio::test]
async fn loading_beginning_keeps_the_entry_until_history_is_complete() -> Result<()> {
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 40, /*height*/ 10,
);
let mut tui = crate::tui::test_support::make_test_tui()?;
for already_rendered in [false, true] {
let mut overlay = transcript_overlay(vec![Arc::new(TestCell {
lines: vec!["committed".into()],
})]);
let live = (0..20)
.map(|index| Line::from(format!("live {index}")))
.collect::<Vec<_>>();
overlay.sync_live_tail(area.width, /*key*/ None, |_| {
Some(live.iter().cloned().map(HyperlinkLine::from).collect())
});
overlay.set_history_state(TranscriptHistoryState::Partial);
if already_rendered {
overlay.render(area, &mut Buffer::empty(area));
}
// App marks loading before forwarding Home to the overlay.
overlay.set_history_state(TranscriptHistoryState::LoadingBeginning);
overlay.handle_event(
&mut tui,
TuiEvent::Key(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE)),
)?;
let mut before = Buffer::empty(area);
overlay.render(area, &mut before);
assert!(!overlay.view.is_following());
assert_eq!(
overlay.view.history,
TranscriptHistoryState::LoadingBeginning
);
overlay.insert_cell(Arc::new(TestCell { lines: live }));
overlay.sync_live_tail(area.width, /*key*/ None, |_| {
Some(
(20..40)
.map(|index| HyperlinkLine::from(format!("live {index}")))
.collect(),
)
});
overlay.prepend(vec![Arc::new(TestCell {
lines: vec!["oldest".into()],
})]);
let mut actual = Buffer::empty(area);
overlay.render(area, &mut actual);
assert_eq!(actual, before);
overlay.set_history_state(TranscriptHistoryState::Complete);
overlay.render(area, &mut actual);
assert!(buffer_to_text(&actual, overlay.content_area).starts_with("oldest\n"));
}
Ok(())
}
#[tokio::test]
async fn navigation_before_the_first_draw_preserves_the_full_latest_viewport() -> Result<()> {
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 40, /*height*/ 10,
);
let mut tui = crate::tui::test_support::make_test_tui()?;
for key in [
KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),
KeyEvent::new(KeyCode::Down, KeyModifiers::NONE),
KeyEvent::new(KeyCode::PageUp, KeyModifiers::NONE),
KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE),
KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL),
KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL),
] {
let cells = vec![Arc::new(TestCell {
lines: (0..30)
.map(|index| Line::from(format!("line {index}")))
.collect(),
}) as Arc<dyn HistoryCell>];
let mut expected_overlay = transcript_overlay(cells.clone());
let mut expected = Buffer::empty(area);
expected_overlay.render(area, &mut expected);
let mut overlay = transcript_overlay(cells);
overlay.handle_event(&mut tui, TuiEvent::Key(key))?;
let mut actual = Buffer::empty(area);
overlay.render(area, &mut actual);
assert_eq!(actual, expected);
}
Ok(())
}
#[test]
@@ -146,24 +273,35 @@ fn transcript_overlay_snapshot_basic() {
}
#[test]
fn transcript_overlay_preserves_semantic_web_links() {
fn transcript_overlay_preserves_complete_wrapped_links_in_history_and_live_output() {
let destination = "https://example.com/a/very/long/path";
let mut overlay = transcript_overlay(vec![Arc::new(history_cell::AgentMarkdownCell::new(
let cell: Arc<dyn HistoryCell> = Arc::new(history_cell::AgentMarkdownCell::new(
destination.to_string(),
std::path::Path::new("/tmp"),
))]);
));
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 24, /*height*/ 10,
);
let mut buf = Buffer::empty(area);
overlay.render(area, &mut buf);
assert!(area.positions().any(|position| {
buf[position]
.symbol()
.contains(&format!("\x1b]8;;{destination}\x07"))
}));
for live in [false, true] {
let mut overlay = transcript_overlay(if live { Vec::new() } else { vec![cell.clone()] });
if live {
overlay.sync_live_tail(area.width, /*key*/ None, |width| {
Some(cell.transcript_hyperlink_lines(width))
});
}
let mut buf = Buffer::empty(area);
overlay.render(area, &mut buf);
let linked_text = area
.positions()
.filter_map(|position| {
let symbol = buf[position].symbol();
symbol
.contains(&format!("\x1b]8;;{destination}\x07"))
.then(|| crate::terminal_hyperlinks::strip_osc8(symbol))
})
.collect::<String>();
assert_eq!(linked_text, destination);
}
}
#[test]
@@ -186,69 +324,67 @@ fn transcript_overlay_renders_live_tail() {
term.draw(|f| overlay.render(f.area(), f.buffer_mut()))
.expect("draw");
assert_snapshot!(term.backend());
overlay.navigate(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE));
term.draw(|frame| overlay.render(frame.area(), frame.buffer_mut()))
.expect("draw");
assert!(overlay.live_tail_visible());
}
#[test]
fn transcript_overlay_preserves_live_tail_when_prepending_history() {
let mut overlay = transcript_overlay(vec![Arc::new(TestCell {
lines: vec![Line::from("recent")],
})]);
overlay.sync_live_tail(
/*width*/ 40,
Some(ActiveCellTranscriptKey {
cacheable: true,
revision: 1,
is_stream_continuation: false,
animation_tick: None,
}),
|_| Some(vec![HyperlinkLine::from("live tail")]),
);
overlay.prepend(
vec![Arc::new(TestCell {
lines: vec![Line::from("older")],
})],
/*width*/ 40,
);
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 40, /*height*/ 10,
);
let mut buffer = Buffer::empty(area);
overlay.render(area, &mut buffer);
let rendered = buffer_to_text(&buffer, area);
assert!(rendered.contains("older"));
assert!(rendered.contains("recent"));
assert!(rendered.contains("live tail"));
}
#[test]
fn transcript_overlay_live_tail_preserves_semantic_web_links() {
let destination = "https://example.com/a/streamed/path";
let cell =
history_cell::AgentMarkdownCell::new(destination.to_string(), std::path::Path::new("/tmp"));
let mut overlay = transcript_overlay(Vec::new());
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 24, /*height*/ 10,
);
let mut buf = Buffer::empty(area);
overlay.sync_live_tail(
area.width,
Some(ActiveCellTranscriptKey {
cacheable: true,
revision: 1,
is_stream_continuation: false,
animation_tick: None,
}),
|width| Some(cell.transcript_hyperlink_lines(width)),
);
overlay.render(area, &mut buf);
assert!(area.positions().any(|position| {
buf[position]
.symbol()
.contains(&format!("\x1b]8;;{destination}\x07"))
}));
fn transcript_overlay_prepend_preserves_reading_highlight_and_live_tail() {
for follow in [false, true] {
let mut overlay = transcript_overlay(vec![
Arc::new(TestCell {
lines: vec!["newer one".into(), "newer two".into()],
}),
Arc::new(TestCell {
lines: (0..20)
.map(|i| Line::from(format!("reading {i}")))
.collect(),
}),
]);
overlay.highlight_cell = Some(1);
overlay.sync_live_tail(
/*width*/ 40,
Some(ActiveCellTranscriptKey {
cacheable: true,
revision: 1,
is_stream_continuation: false,
animation_tick: None,
}),
|_| Some(vec![HyperlinkLine::from("live tail")]),
);
let mut term = Terminal::new(TestBackend::new(/*width*/ 40, /*height*/ 8)).expect("term");
term.draw(|frame| overlay.render(frame.area(), frame.buffer_mut()))
.expect("draw");
if !follow {
overlay.view.jump_to_entry(&overlay.cells, /*index*/ 1);
}
term.draw(|frame| overlay.render(frame.area(), frame.buffer_mut()))
.expect("draw");
let content_area = overlay.content_area;
let before = buffer_to_text(term.backend().buffer(), content_area);
overlay.reveal_highlight = true;
overlay.prepend(vec![Arc::new(TestCell {
lines: vec!["older one".into(), "older two".into(), "older three".into()],
})]);
assert_eq!(
(overlay.highlight_cell, overlay.reveal_highlight),
(Some(2), true)
);
overlay.reveal_highlight = false;
term.draw(|frame| overlay.render(frame.area(), frame.buffer_mut()))
.expect("draw prepended");
assert_eq!(
buffer_to_text(term.backend().buffer(), content_area),
before
);
assert_eq!(overlay.view.is_following(), follow);
overlay.view.jump_to_latest();
term.draw(|frame| overlay.render(frame.area(), frame.buffer_mut()))
.expect("draw tail");
assert!(buffer_to_text(term.backend().buffer(), content_area).contains("live tail"));
}
}
#[test]
@@ -351,7 +487,7 @@ fn transcript_overlay_apply_patch_scroll_vt100_clears_previous_page() {
let mut buf = Buffer::empty(area);
overlay.render(area, &mut buf);
overlay.view.scroll_offset = 0;
overlay.view.jump_to_entry(&overlay.cells, /*index*/ 0);
overlay.render(area, &mut buf);
let snapshot = buffer_to_text(&buf, area);
@@ -374,7 +510,7 @@ fn transcript_overlay_keeps_scroll_pinned_at_bottom() {
.expect("draw");
assert!(
overlay.view.is_scrolled_to_bottom(),
overlay.view.is_following(),
"expected initial render to leave view at bottom"
);
@@ -382,318 +518,227 @@ fn transcript_overlay_keeps_scroll_pinned_at_bottom() {
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());
assert!(overlay.view.is_following());
}
overlay.insert_cell(Arc::new(TestCell {
lines: vec!["tail".into()],
}));
assert_eq!(overlay.view.scroll_offset, usize::MAX);
assert!(overlay.view.is_following());
term.draw(|f| overlay.render(f.area(), f.buffer_mut()))
.expect("draw committed tail");
assert_snapshot!("transcript_overlay_follows_resized_tail", term.backend());
}
#[test]
fn transcript_overlay_preserves_manual_scroll_position() {
let mut overlay = transcript_overlay(
(0..20)
.map(|i| {
Arc::new(TestCell {
lines: vec![Line::from(format!("line{i}"))],
}) as Arc<dyn HistoryCell>
})
.collect(),
);
let mut term = Terminal::new(TestBackend::new(40, 12)).expect("term");
term.draw(|f| overlay.render(f.area(), f.buffer_mut()))
.expect("draw");
overlay.view.scroll_offset = 0;
overlay.insert_cell(Arc::new(TestCell {
lines: vec!["tail".into()],
}));
assert_eq!(overlay.view.scroll_offset, 0);
overlay.view.scroll_offset = 3;
term.draw(|frame| overlay.render(frame.area(), frame.buffer_mut()))
.expect("draw");
let content_area = Rect::new(
/*x*/ 0, /*y*/ 1, /*width*/ 40, /*height*/ 4,
);
let visible_before = buffer_to_text(term.backend().buffer(), content_area);
overlay.prepend(
vec![Arc::new(TestCell {
lines: (0..40).map(|i| Line::from(format!("older {i}"))).collect(),
})],
/*width*/ 40,
);
term.draw(|frame| overlay.render(frame.area(), frame.buffer_mut()))
.expect("draw");
assert_eq!(
buffer_to_text(term.backend().buffer(), content_area),
visible_before
);
assert_snapshot!(
"transcript_overlay_prepended_history",
visible_before.trim()
);
}
#[test]
fn transcript_overlay_insert_preserves_cached_cell_heights() {
let height_calls = Arc::new(AtomicUsize::new(0));
let mut overlay = transcript_overlay(vec![Arc::new(HeightCountingCell {
height_calls: height_calls.clone(),
})]);
let area = Rect::new(0, 0, 40, 12);
let mut buf = Buffer::empty(area);
overlay.render(area, &mut buf);
assert_eq!(height_calls.load(Ordering::Relaxed), 1);
overlay.insert_cell(Arc::new(TestCell {
lines: vec![Line::from("inserted")],
}));
overlay.render(area, &mut buf);
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 {
cacheable: true,
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)
);
}
}
fn transcript_overlay_consolidation_remaps_highlight() {
for (selected, expected) in [(3, 2), (6, 4)] {
let mut overlay = transcript_overlay(
(0..7)
.map(|i| {
Arc::new(TestCell {
lines: vec![Line::from(format!("line{i}"))],
}) as Arc<dyn HistoryCell>
})
.collect(),
);
overlay.set_highlight_cell(Some(selected));
overlay.consolidate_cells(
2..5,
Arc::new(TestCell {
lines: vec![Line::from("consolidated")],
}),
);
assert_eq!(overlay.highlight_cell, Some(expected));
}
}
#[test]
fn transcript_overlay_consolidation_remaps_highlight_inside_range() {
let mut overlay = transcript_overlay(
(0..6)
.map(|i| {
Arc::new(TestCell {
lines: vec![Line::from(format!("line{i}"))],
}) as Arc<dyn HistoryCell>
})
.collect(),
);
overlay.set_highlight_cell(Some(3));
overlay.consolidate_cells(
2..5,
Arc::new(TestCell {
lines: vec![Line::from("consolidated")],
}),
);
assert_eq!(
overlay.highlight_cell,
Some(2),
"highlight inside consolidated range should point to replacement cell",
);
}
#[test]
fn transcript_overlay_consolidation_remaps_highlight_after_range() {
let mut overlay = transcript_overlay(
(0..7)
.map(|i| {
Arc::new(TestCell {
lines: vec![Line::from(format!("line{i}"))],
}) as Arc<dyn HistoryCell>
})
.collect(),
);
overlay.set_highlight_cell(Some(6));
overlay.consolidate_cells(
2..5,
Arc::new(TestCell {
lines: vec![Line::from("consolidated")],
}),
);
assert_eq!(
overlay.highlight_cell,
Some(4),
"highlight after consolidated range should shift left by removed cells",
);
}
fn transcript_line_numbers(overlay: &mut TranscriptOverlay, area: Rect) -> Vec<usize> {
let mut buf = Buffer::empty(area);
overlay.render(area, &mut buf);
let top_h = area.height.saturating_sub(3);
let top = Rect::new(area.x, area.y, area.width, top_h);
let content_area = overlay.view.content_area(top);
let mut nums = Vec::new();
for y in content_area.y..content_area.bottom() {
let mut line = String::new();
for x in content_area.x..content_area.right() {
line.push(buf[(x, y)].symbol().chars().next().unwrap_or(' '));
}
if let Some(n) = line
.split_whitespace()
.find_map(|w| w.strip_prefix("line-"))
.and_then(|s| s.parse().ok())
{
nums.push(n);
}
}
nums
}
#[test]
fn transcript_overlay_paging_is_continuous_and_round_trips() {
let mut overlay = transcript_overlay(
(0..50)
.map(|i| {
Arc::new(TestCell {
lines: vec![Line::from(format!("line-{i:02}"))],
}) as Arc<dyn HistoryCell>
})
let mut overlay = transcript_overlay(vec![Arc::new(TestCell {
lines: (0..50)
.map(|i| Line::from(format!("line-{i:02}")))
.collect(),
})]);
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 40, /*height*/ 15,
);
let area = Rect::new(0, 0, 40, 15);
// Prime layout so last_content_height is populated and paging uses the real content height.
let mut buf = Buffer::empty(area);
overlay.view.scroll_offset = 0;
overlay.render(area, &mut buf);
let page_height = overlay.view.page_height(area);
// Scenario 1: starting from the top, PageDown should show the next page of content.
overlay.view.scroll_offset = 0;
let page1 = transcript_line_numbers(&mut overlay, area);
let page1_len = page1.len();
let expected_page1: Vec<usize> = (0..page1_len).collect();
assert_eq!(
page1, expected_page1,
"first page should start at line-00 and show a full page of content"
);
overlay.view.scroll_offset = overlay.view.scroll_offset.saturating_add(page_height);
let page2 = transcript_line_numbers(&mut overlay, area);
assert_eq!(
page2.len(),
page1_len,
"second page should have the same number of visible lines as the first page"
);
let expected_page2_first = *page1.last().unwrap() + 1;
assert_eq!(
page2[0], expected_page2_first,
"second page after PageDown should immediately follow the first page"
);
// Scenario 2: from an interior offset (start=3), PageDown then PageUp should round-trip.
let interior_offset = 3usize;
overlay.view.scroll_offset = interior_offset;
let before = transcript_line_numbers(&mut overlay, area);
overlay.view.scroll_offset = overlay.view.scroll_offset.saturating_add(page_height);
let _ = transcript_line_numbers(&mut overlay, area);
overlay.view.scroll_offset = overlay.view.scroll_offset.saturating_sub(page_height);
let after = transcript_line_numbers(&mut overlay, area);
assert_eq!(
before, after,
"PageDown+PageUp from interior offset ({interior_offset}) should round-trip"
);
// Scenario 3: from the top of the second page, PageUp then PageDown should round-trip.
overlay.view.scroll_offset = page_height;
let before2 = transcript_line_numbers(&mut overlay, area);
overlay.view.scroll_offset = overlay.view.scroll_offset.saturating_sub(page_height);
let _ = transcript_line_numbers(&mut overlay, area);
overlay.view.scroll_offset = overlay.view.scroll_offset.saturating_add(page_height);
let after2 = transcript_line_numbers(&mut overlay, area);
assert_eq!(
before2, after2,
"PageUp+PageDown from the top of the second page should round-trip"
);
let mut buffer = Buffer::empty(area);
overlay.render(area, &mut buffer);
overlay.view.jump_to_entry(&overlay.cells, /*index*/ 0);
for start in [0, 10] {
overlay.render(area, &mut buffer);
assert_eq!(
buffer_to_text(&buffer, overlay.content_area),
(start..start + 10)
.map(|i| format!("line-{i:02}\n"))
.collect::<String>()
);
overlay.navigate(KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE));
}
overlay.scroll(/*rows*/ -17);
overlay.render(area, &mut buffer);
let before = buffer.clone();
for key in [KeyCode::PageDown, KeyCode::PageUp] {
overlay.navigate(KeyEvent::new(key, KeyModifiers::NONE));
overlay.render(area, &mut buffer);
}
assert_eq!(buffer, before);
}
#[tokio::test]
async fn half_page_uses_the_last_rendered_content_height() -> Result<()> {
let mut overlay = transcript_overlay(
(0..50)
.map(|i| {
Arc::new(TestCell {
lines: vec![Line::from(format!("line-{i:02}"))],
}) as Arc<dyn HistoryCell>
})
let mut overlay = transcript_overlay(vec![Arc::new(TestCell {
lines: (0..50)
.map(|i| Line::from(format!("line-{i:02}")))
.collect(),
);
})]);
let transcript_area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 40, /*height*/ 10,
);
let mut buf = Buffer::empty(transcript_area);
overlay.render(transcript_area, &mut buf);
let page_height = overlay.view.page_height(transcript_area);
let page_height = usize::from(overlay.content_area.height);
let mut tui = crate::tui::test_support::make_test_tui()?;
tui.terminal.set_viewport_area(Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 40, /*height*/ 24,
));
overlay.view.scroll_offset = 10;
overlay.view.jump_to_entry(&overlay.cells, /*index*/ 0);
overlay.scroll(/*rows*/ 10);
overlay.view.handle_key_event(
overlay.handle_event(
&mut tui,
KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL),
TuiEvent::Key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL)),
)?;
assert_eq!(
overlay.view.scroll_offset,
10 + page_height.saturating_add(1) / 2
);
overlay.render(transcript_area, &mut buf);
let first = 10 + page_height.div_ceil(/*rhs*/ 2);
assert!(buffer_to_text(&buf, overlay.content_area).starts_with(&format!("line-{first:02}\n")));
Ok(())
}
#[test]
fn transcript_highlight_scrolls_offscreen_entries_in_both_directions() {
let mut overlay = transcript_overlay(
(0..20)
.map(|index| {
Arc::new(TestCell {
lines: vec![Line::from(format!("entry {index}"))],
}) as Arc<dyn HistoryCell>
})
.collect(),
);
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 40, /*height*/ 12,
);
let mut buffer = Buffer::empty(area);
overlay.render(area, &mut buffer);
for index in [0, 19, 0] {
overlay.set_highlight_cell(Some(index));
overlay.render(area, &mut buffer);
assert!(buffer_to_text(&buffer, overlay.content_area).contains(&format!("entry {index}")));
}
}
#[test]
fn transcript_footer_does_not_format_offscreen_history_for_a_percentage() {
let counts = (0..200)
.map(|_| Arc::new(AtomicUsize::new(/*v*/ 0)))
.collect::<Vec<_>>();
let mut overlay = transcript_overlay(
counts
.iter()
.map(|count| {
Arc::new(LayoutCountingCell {
layout_calls: Arc::clone(count),
}) as Arc<dyn HistoryCell>
})
.collect(),
);
let area = Rect::new(
/*x*/ 2, /*y*/ 1, /*width*/ 72, /*height*/ 12,
);
let mut buffer = Buffer::empty(area);
overlay.set_history_state(TranscriptHistoryState::Partial);
overlay.render(area, &mut buffer);
assert_eq!(
overlay.content_area,
Rect::new(
/*x*/ 2, /*y*/ 2, /*width*/ 72, /*height*/ 7
)
);
assert!(!buffer_to_text(&buffer, area).contains('%'));
let before = counts
.iter()
.map(|count| count.load(Ordering::Relaxed))
.collect::<Vec<_>>();
assert!(before[..190].iter().all(|count| *count == 0));
assert!((1..10).contains(&before.iter().sum::<usize>()));
overlay.set_history_state(TranscriptHistoryState::Complete);
overlay.render(area, &mut buffer);
assert!(buffer_to_text(&buffer, area).contains("100%"));
assert_eq!(
counts
.iter()
.map(|count| count.load(Ordering::Relaxed))
.collect::<Vec<_>>(),
before
);
for selection in [Some(199), Some(198), None] {
overlay.set_highlight_cell(selection);
overlay.render(area, &mut buffer);
assert_eq!(
counts
.iter()
.map(|count| count.load(Ordering::Relaxed))
.collect::<Vec<_>>(),
before
);
if selection.is_some() {
assert!(buffer_to_text(&buffer, area).contains("edit next"));
}
}
overlay.view.jump_to_latest();
overlay.insert_cell(Arc::new(TestCell {
lines: vec!["new output".into()],
}));
overlay.render(area, &mut buffer);
assert_eq!(
counts
.iter()
.map(|count| count.load(Ordering::Relaxed))
.collect::<Vec<_>>(),
before
);
overlay.scroll(/*rows*/ -1);
overlay.render(area, &mut buffer);
assert!(!buffer_to_text(&buffer, area).contains('%'));
}
#[test]
fn transcript_overlay_clips_zero_and_short_viewports_to_their_area() {
let canvas = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 48, /*height*/ 16,
);
for width in [0, 1, 40] {
for height in 0..=6 {
let area = Rect::new(/*x*/ 2, /*y*/ 3, width, height);
let mut overlay = transcript_overlay(vec![Arc::new(TestCell {
lines: vec!["content".into()],
})]);
let mut buffer = Buffer::filled(canvas, Cell::from('.'));
overlay.render(area, &mut buffer);
for position in canvas
.positions()
.filter(|position| !area.contains(*position))
{
assert_eq!(
buffer[position],
Cell::from('.'),
"area={area:?}, position={position:?}"
);
}
}
}
}
+407 -3
View File
@@ -1,7 +1,411 @@
//! Retained display rows and bounded caches for transcript rendering.
//! A conversation viewport over the app's retained cells and its current live tail.
//!
//! The app owns history and pagination. This view owns reading state and bounded
//! layout caches. Reading positions track entry identity and a display-row offset.
//! Resize and replacement clamp that offset; live content always uses its current revision.
mod layout;
mod text;
pub(crate) use layout::LayoutCache;
pub(crate) use text::TextLayout;
use std::sync::Arc;
use crate::chatwidget::ActiveCellTranscriptKey;
use crate::history_cell::HistoryCell;
use crate::history_cell::UserHistoryCell;
use crate::pager_overlay::TranscriptHistoryState;
use crate::terminal_hyperlinks::HyperlinkLine;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::widgets::Clear;
use ratatui::widgets::Widget;
use layout::LayoutCache;
use text::TextLayout;
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
enum EntryKey {
Cell(usize),
Live,
}
impl EntryKey {
fn cell(cell: &Arc<dyn HistoryCell>) -> Self {
Self::Cell(Arc::as_ptr(cell).cast::<()>() as usize)
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct Anchor {
key: EntryKey,
index: usize,
row: usize,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
enum Position {
#[default]
Latest,
Reading(Anchor),
}
struct VisibleRow {
index: usize,
row: usize,
layout: Arc<TextLayout>,
key: EntryKey,
}
/// Entry-based scrolling for the detailed transcript overlay.
#[derive(Default)]
pub(crate) struct TranscriptView {
position: Position,
cache: LayoutCache,
live: Option<Arc<TextLayout>>,
live_separated: Option<Arc<TextLayout>>,
live_key: Option<(u16, ActiveCellTranscriptKey)>,
live_continuation: bool,
area: Rect,
visible: Vec<VisibleRow>,
pub(crate) history: TranscriptHistoryState,
}
impl TranscriptView {
pub(crate) fn render(
&mut self,
area: Rect,
buf: &mut Buffer,
cells: &[Arc<dyn HistoryCell>],
highlight: Option<usize>,
) -> u16 {
self.cache.begin_frame();
Clear.render(area, buf);
self.area = area;
self.visible.clear();
if area.is_empty() {
return 0;
}
let mut rendered = 0;
let (mut index, mut row) = self.start(cells);
for y in area.top()..area.bottom() {
let Some(layout) = self.layout(cells, index) else {
break;
};
if row >= layout.row_count() {
index += 1;
row = 0;
let Some(next) = self.next_nonempty(cells, index) else {
break;
};
index = next;
}
let Some(layout) = self.layout(cells, index) else {
break;
};
let key = self.entry_key(cells, index);
let row_area = Rect::new(area.x, y, area.width, /*height*/ 1);
if (index == 0 || row > 0)
&& cells
.get(index)
.is_some_and(|cell| cell.as_any().is::<UserHistoryCell>())
{
buf.set_style(row_area, crate::style::history_prompt_style());
}
self.visible.push(VisibleRow {
index,
row,
layout,
key,
});
row += 1;
rendered += 1;
}
let mut y = area.y;
for rows in self
.visible
.chunk_by(|a, b| Arc::ptr_eq(&a.layout, &b.layout))
{
let first = &rows[0];
let height = rows.len() as u16;
first
.layout
.render(Rect::new(area.x, y, area.width, height), buf, first.row);
if highlight == Some(first.index) {
for (offset, row) in rows.iter().enumerate() {
row.layout.highlight(
Rect::new(area.x, y + offset as u16, area.width, /*height*/ 1),
buf,
row.row,
);
}
}
y += height;
}
rendered
}
pub(crate) fn sync_live_tail(
&mut self,
width: u16,
key: Option<ActiveCellTranscriptKey>,
lines: impl FnOnce(u16) -> Option<Vec<HyperlinkLine>>,
) {
let next = key.map(|key| (width, key));
if key.is_some_and(|key| key.cacheable) && self.live_key == next {
return;
}
self.live_key = next;
self.live_separated = None;
self.live_continuation = key.is_some_and(|key| key.is_stream_continuation);
self.live = lines(width).map(|lines| Arc::new(TextLayout::new(lines, width)));
}
pub(crate) fn is_following(&self) -> bool {
self.position == Position::Latest
}
pub(crate) fn live_tail_visible(&self) -> bool {
self.visible.iter().any(|row| row.key == EntryKey::Live)
}
/// Hold the current reading position until every older page has arrived.
pub(crate) fn jump_to_beginning(&mut self, cells: &[Arc<dyn HistoryCell>]) {
if self.history == TranscriptHistoryState::LoadingBeginning {
return;
}
if self.history.has_unloaded_history() {
self.scroll(cells, /*rows*/ 0);
self.history = TranscriptHistoryState::LoadingBeginning;
} else {
self.jump_to_entry(cells, /*index*/ 0);
}
}
pub(crate) fn jump_to_latest(&mut self) {
self.position = Position::Latest;
}
pub(crate) fn scroll(&mut self, cells: &[Arc<dyn HistoryCell>], rows: isize) {
let start = self.start(cells);
let (index, row) = self.move_rows(cells, start.0, start.1, rows);
if rows < 0
&& (index, row) == start
&& self.is_following()
&& !self.history.has_unloaded_history()
{
return;
}
// Anchor visible content, not a hidden header that stays ahead of every older page.
let index = self.next_nonempty(cells, index).unwrap_or(index);
let bottom = self.bottom_start(cells);
if rows > 0 && (index, row) >= bottom {
self.jump_to_latest();
return;
}
if self.layout(cells, index).is_some() {
self.position = Position::Reading(Anchor {
key: self.entry_key(cells, index),
index,
row,
});
}
}
pub(crate) fn jump_to_entry(&mut self, cells: &[Arc<dyn HistoryCell>], index: usize) {
self.position = Position::Reading(Anchor {
key: self.entry_key(cells, index),
index,
row: 0,
});
}
/// Reveal an entire entry when prompt backtracking changes the highlight.
pub(crate) fn ensure_entry_visible(&mut self, cells: &[Arc<dyn HistoryCell>], index: usize) {
let key = cells.get(index).map_or(EntryKey::Live, EntryKey::cell);
let mut visible = self
.visible
.iter()
.filter(|row| row.index == index && row.key == key);
let fully_visible = visible
.next()
.is_some_and(|first| first.row == 0 && visible.count() + 1 == first.layout.row_count());
if !fully_visible {
self.jump_to_entry(cells, index);
}
}
/// Keep readers on the replacement entry without translating source offsets.
pub(crate) fn replace_range(
&mut self,
cells: &[Arc<dyn HistoryCell>],
range: std::ops::Range<usize>,
replacement: &Arc<dyn HistoryCell>,
) {
if let Position::Reading(anchor) = self.position
&& range.contains(&self.resolve(cells, anchor))
{
self.position = Position::Reading(Anchor {
key: EntryKey::cell(replacement),
index: range.start,
..anchor
});
}
}
/// Preserve display rows when retained entries gain or lose a leading separator.
pub(crate) fn history_changed(&mut self, cells: &[Arc<dyn HistoryCell>]) {
if let Position::Reading(anchor) = self.position {
let index = self.resolve(cells, anchor);
let separated = cells.get(index).is_some_and(|cell| {
EntryKey::cell(cell) == anchor.key && !cell.is_stream_continuation()
});
let row = if separated && anchor.index == 0 && index > 0 {
anchor.row.saturating_add(/*rhs*/ 1)
} else if separated && anchor.index > 0 && index == 0 {
anchor.row.saturating_sub(/*rhs*/ 1)
} else {
anchor.row
};
self.position = Position::Reading(Anchor {
key: self.entry_key(cells, index),
index,
row,
});
}
}
pub(crate) fn near_start(&mut self, cells: &[Arc<dyn HistoryCell>]) -> bool {
if self.is_following() && self.area.is_empty() {
return false;
}
let (index, row) = self.start(cells);
self.move_rows(cells, index, row, -(self.area.height as isize)) == (0, 0)
}
fn start(&mut self, cells: &[Arc<dyn HistoryCell>]) -> (usize, usize) {
match self.position {
Position::Latest => self.bottom_start(cells),
Position::Reading(anchor) => {
let index = self.resolve(cells, anchor);
let row = self.layout(cells, index).map_or(/*default*/ 0, |layout| {
anchor.row.min(layout.row_count().saturating_sub(/*rhs*/ 1))
});
self.position = Position::Reading(Anchor {
key: self.entry_key(cells, index),
index,
row,
});
(index, row)
}
}
}
fn bottom_start(&mut self, cells: &[Arc<dyn HistoryCell>]) -> (usize, usize) {
let count = cells.len() + usize::from(self.live.is_some());
let Some(last) = count.checked_sub(/*rhs*/ 1) else {
return (0, 0);
};
let height = self
.layout(cells, last)
.map_or(/*default*/ 0, |l| l.row_count());
self.move_rows(cells, last, height, -(self.area.height as isize))
}
fn move_rows(
&mut self,
cells: &[Arc<dyn HistoryCell>],
mut index: usize,
mut row: usize,
rows: isize,
) -> (usize, usize) {
if rows < 0 {
let mut remaining = rows.unsigned_abs();
while remaining > row && index > 0 {
remaining -= row;
index -= 1;
row = self
.layout(cells, index)
.map_or(/*default*/ 0, |l| l.row_count());
}
return (index, row.saturating_sub(remaining));
}
let mut remaining = row.saturating_add(rows as usize);
while let Some(layout) = self.layout(cells, index) {
if remaining < layout.row_count() {
break;
}
remaining -= layout.row_count();
index += 1;
}
(index, remaining)
}
fn next_nonempty(&mut self, cells: &[Arc<dyn HistoryCell>], mut index: usize) -> Option<usize> {
while let Some(layout) = self.layout(cells, index) {
if layout.row_count() > 0 {
return Some(index);
}
index += 1;
}
None
}
fn resolve(&self, cells: &[Arc<dyn HistoryCell>], anchor: Anchor) -> usize {
if anchor.key == EntryKey::Live {
return if self.live.is_some() {
cells.len()
} else {
cells.len().saturating_sub(/*rhs*/ 1)
};
}
if cells
.get(anchor.index)
.is_some_and(|cell| EntryKey::cell(cell) == anchor.key)
{
return anchor.index;
}
cells
.iter()
.position(|cell| EntryKey::cell(cell) == anchor.key)
.unwrap_or_else(|| anchor.index.min(cells.len().saturating_sub(/*rhs*/ 1)))
}
fn entry_key(&self, cells: &[Arc<dyn HistoryCell>], index: usize) -> EntryKey {
cells.get(index).map_or(EntryKey::Live, EntryKey::cell)
}
}
impl TranscriptView {
fn layout(&mut self, cells: &[Arc<dyn HistoryCell>], index: usize) -> Option<Arc<TextLayout>> {
let Some(cell) = cells.get(index) else {
if index != cells.len() {
return None;
}
let live = self.live.as_ref()?;
if cells.is_empty() || self.live_continuation {
return Some(Arc::clone(live));
}
return Some(Arc::clone(self.live_separated.get_or_insert_with(|| {
Arc::new(live.as_ref().clone().with_leading_separator())
})));
};
let width = self.area.width.max(/*other*/ 1);
if cell.as_any().is::<crate::history_cell::SessionInfoCell>()
&& let Some(placeholder) = self.history.session_header_placeholder()
{
return Some(Arc::new(TextLayout::new(
vec![HyperlinkLine::from(Line::from(placeholder).dim())],
width,
)));
}
Some(
self.cache
.get(cell, width, index > 0 && !cell.is_stream_continuation()),
)
}
}
#[cfg(test)]
#[path = "transcript_view_tests.rs"]
mod tests;
+3 -3
View File
@@ -13,7 +13,7 @@ const MAX_CACHED_ENTRIES: usize = 64;
const MAX_CACHED_TEXT_BYTES: usize = 8 * 1024 * 1024;
#[derive(Default)]
pub(crate) struct LayoutCache {
pub(super) struct LayoutCache {
entries: Vec<CachedLayout>,
frame: u64,
render_state: Option<RenderState>,
@@ -37,7 +37,7 @@ struct CachedLayout {
}
impl LayoutCache {
pub(crate) fn begin_frame(&mut self) {
pub(super) fn begin_frame(&mut self) {
self.frame = self.frame.wrapping_add(/*rhs*/ 1);
let state = RenderState {
theme: crate::render::highlight::syntax_theme_revision(),
@@ -51,7 +51,7 @@ impl LayoutCache {
}
}
pub(crate) fn get(
pub(super) fn get(
&mut self,
cell: &Arc<dyn HistoryCell>,
width: u16,
+57 -10
View File
@@ -2,26 +2,30 @@
use crate::terminal_hyperlinks::HyperlinkLine;
use crate::terminal_hyperlinks::HyperlinkParagraph;
use crate::width::display_width;
use ratatui::buffer::Buffer;
use ratatui::buffer::CellWidth;
use ratatui::layout::Alignment;
use ratatui::layout::Rect;
use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::widgets::Widget;
use std::sync::Arc;
#[derive(Clone)]
pub(crate) struct TextLayout {
pub(crate) rows: Vec<TextRow>,
pub(crate) byte_len: usize,
pub(super) struct TextLayout {
pub(super) rows: Vec<TextRow>,
pub(super) byte_len: usize,
}
#[derive(Clone)]
pub(crate) struct TextRow {
pub(crate) line: Arc<HyperlinkLine>,
pub(crate) offset: u16,
pub(super) struct TextRow {
pub(super) line: Arc<HyperlinkLine>,
pub(super) offset: u16,
}
impl TextLayout {
pub(crate) fn new(lines: Vec<HyperlinkLine>, width: u16) -> Self {
pub(super) fn new(lines: Vec<HyperlinkLine>, width: u16) -> Self {
let byte_len = lines
.iter()
.flat_map(|line| &line.line.spans)
@@ -51,7 +55,7 @@ impl TextLayout {
Self { rows, byte_len }
}
pub(crate) fn with_leading_separator(mut self) -> Self {
pub(super) fn with_leading_separator(mut self) -> Self {
if !self.rows.is_empty() {
self.rows.insert(
/*index*/ 0,
@@ -64,11 +68,11 @@ impl TextLayout {
self
}
pub(crate) fn row_count(&self) -> usize {
pub(super) fn row_count(&self) -> usize {
self.rows.len()
}
pub(crate) fn render(&self, area: Rect, buf: &mut Buffer, row: usize) {
pub(super) fn render(&self, area: Rect, buf: &mut Buffer, row: usize) {
let start = row.min(self.rows.len());
let end = row
.saturating_add(usize::from(area.height))
@@ -83,4 +87,47 @@ impl TextLayout {
y += height;
}
}
/// Highlight the entry's text while leaving synthetic gutters and padding untouched.
pub(super) fn highlight(&self, area: Rect, buf: &mut Buffer, row: usize) {
let row = &self.rows[row];
let line = &row.line;
let text = line.line.to_string();
let prefix = if row.offset == 0 {
line.source.as_ref().map_or(
/*default*/ 0,
|source| display_width(&text[..source.prefix_bytes]),
)
} else {
0
};
let width = display_width(&text);
let columns = usize::from(area.width);
let alignment = match line.line.alignment.unwrap_or(Alignment::Left) {
Alignment::Left => 0,
Alignment::Center => (columns / 2).saturating_sub(width / 2),
Alignment::Right => columns.saturating_sub(width),
};
let (alignment, end) = if width > columns {
let end = (0..area.width)
.rfind(|x| !buf[(area.x + x, area.y)].symbol().trim().is_empty())
.map_or(/*default*/ 0, |x| {
usize::from(x + buf[(area.x + x, area.y)].cell_width())
});
let start = if line.line.alignment.unwrap_or(Alignment::Left) == Alignment::Left {
0
} else {
(0..area.width)
.find(|x| !buf[(area.x + x, area.y)].symbol().trim().is_empty())
.map_or(/*default*/ 0, usize::from)
};
(start, end)
} else {
(alignment, alignment + width)
};
for column in alignment + prefix..end.min(columns) {
buf[(area.x + column as u16, area.y)]
.set_style(Style::default().add_modifier(Modifier::REVERSED));
}
}
}
+197
View File
@@ -0,0 +1,197 @@
//! Viewport behavior and cache lifetime across scrolling, pagination, and redraws.
use super::*;
use pretty_assertions::assert_eq;
use std::sync::atomic::AtomicUsize;
use std::sync::atomic::Ordering;
fn cell(text: impl Into<String>) -> Arc<dyn HistoryCell> {
Arc::new(crate::history_cell::PlainHistoryCell::new(
text.into()
.lines()
.map(|line| line.to_owned().into())
.collect(),
))
}
fn render(
view: &mut TranscriptView,
cells: &[Arc<dyn HistoryCell>],
width: u16,
height: u16,
) -> Buffer {
let area = Rect::new(/*x*/ 0, /*y*/ 0, width, height);
let mut buffer = Buffer::empty(area);
view.render(area, &mut buffer, cells, /*highlight*/ None);
buffer
}
fn text(buffer: &Buffer) -> String {
(buffer.area.top()..buffer.area.bottom())
.map(|y| {
(buffer.area.left()..buffer.area.right())
.map(|x| buffer[(x, y)].symbol())
.collect::<String>()
.trim_end()
.to_owned()
})
.collect::<Vec<_>>()
.join("\n")
}
#[derive(Debug)]
struct MutableHistoryCell {
version: Arc<AtomicUsize>,
renders: Arc<AtomicUsize>,
}
impl HistoryCell for MutableHistoryCell {
fn display_lines(&self, _width: u16) -> Vec<Line<'static>> {
self.renders.fetch_add(/*val*/ 1, Ordering::Relaxed);
let version = self.version.load(Ordering::Relaxed);
(0..8)
.map(|row| Line::from(format!("version {version} row {row}")))
.collect()
}
fn raw_lines(&self) -> Vec<Line<'static>> {
self.display_lines(/*width*/ 80)
}
fn has_stable_transcript_height(&self) -> bool {
false
}
}
#[test]
fn reading_survives_prepend_and_new_output_then_returns_to_latest() {
let mut cells = vec![cell("older\nline two"), cell("current\nlast line")];
let mut view = TranscriptView::default();
render(&mut view, &cells, /*width*/ 20, /*height*/ 3);
view.scroll(&cells, /*rows*/ -2);
let before = text(&render(
&mut view, &cells, /*width*/ 20, /*height*/ 3,
));
cells.insert(/*index*/ 0, cell("loaded from history"));
view.history_changed(&cells);
cells.push(cell("new output"));
assert_eq!(
text(&render(
&mut view, &cells, /*width*/ 20, /*height*/ 3
)),
before
);
cells.remove(/*index*/ 0);
view.history_changed(&cells);
assert_eq!(
text(&render(
&mut view, &cells, /*width*/ 20, /*height*/ 3
)),
before
);
view.jump_to_latest();
insta::assert_snapshot!(text(&render(&mut view, &cells, /*width*/ 20, /*height*/ 3)), @"
last line
new output
");
}
#[test]
fn prepending_history_updates_pagination_position_before_the_next_frame() {
let mut cells = vec![cell("current first"), cell("current last")];
let mut view = TranscriptView::default();
view.jump_to_entry(&cells, /*index*/ 0);
assert!(view.near_start(&cells));
render(&mut view, &cells, /*width*/ 20, /*height*/ 3);
cells.remove(/*index*/ 0);
view.history_changed(&cells);
cells.insert(/*index*/ 0, cell("earlier\n".repeat(100)));
view.history_changed(&cells);
assert!(!view.near_start(&cells));
cells.remove(/*index*/ 0);
view.history_changed(&cells);
let earlier = (0..100)
.map(|index| cell(format!("earlier {index}")))
.collect::<Vec<_>>();
cells.splice(0..0, earlier);
view.history_changed(&cells);
assert!(!view.near_start(&cells));
let Position::Reading(anchor) = view.position else {
panic!("reading anchor");
};
assert_eq!(
(anchor.key, anchor.index),
(EntryKey::cell(&cells[100]), 100)
);
}
#[test]
fn single_row_scrolling_crosses_an_entry_separator_in_both_directions() {
let cells = vec![cell("first"), cell("second")];
let mut view = TranscriptView::default();
render(&mut view, &cells, /*width*/ 20, /*height*/ 1);
let mut frames = Vec::new();
for rows in [-1, -1, 1, 1] {
view.scroll(&cells, rows);
frames.push(text(&render(
&mut view, &cells, /*width*/ 20, /*height*/ 1,
)));
}
assert_eq!(frames, ["", "first", "", "second"]);
}
#[test]
fn resize_and_consolidation_clamp_the_row_within_the_same_entry() {
let mut cells = vec![
cell("older"),
cell("alpha beta gamma delta epsilon zeta eta theta"),
];
let mut view = TranscriptView::default();
render(&mut view, &cells, /*width*/ 10, /*height*/ 2);
view.scroll(&cells, /*rows*/ -1);
let Position::Reading(anchor) = view.position else {
panic!("reading anchor");
};
render(&mut view, &cells, /*width*/ 8, /*height*/ 2);
assert_eq!(view.position, Position::Reading(anchor));
let wide = render(&mut view, &cells, /*width*/ 60, /*height*/ 2);
assert_eq!(
text(&wide),
"alpha beta gamma delta epsilon zeta eta theta\n"
);
let replacement = cell("final answer");
view.replace_range(&cells, 0..2, &replacement);
cells.splice(0..2, [replacement]);
let completed = render(&mut view, &cells, /*width*/ 60, /*height*/ 2);
assert!(!view.is_following());
insta::assert_snapshot!(text(&completed), @"
final answer
");
}
#[test]
fn mutable_history_formats_once_per_frame_and_refreshes_the_next_frame() {
let version = Arc::new(AtomicUsize::new(/*v*/ 1));
let renders = Arc::new(AtomicUsize::new(/*v*/ 0));
let cells: Vec<Arc<dyn HistoryCell>> = vec![Arc::new(MutableHistoryCell {
version: Arc::clone(&version),
renders: Arc::clone(&renders),
})];
let mut view = TranscriptView::default();
let first = render(&mut view, &cells, /*width*/ 24, /*height*/ 5);
assert_eq!(renders.load(Ordering::Relaxed), 1);
version.store(/*val*/ 2, Ordering::Relaxed);
let second = render(&mut view, &cells, /*width*/ 24, /*height*/ 5);
assert_eq!(renders.load(Ordering::Relaxed), 2);
assert!(text(&first).contains("version 1 row 3"));
insta::assert_snapshot!(text(&second), @"
version 2 row 3
version 2 row 4
version 2 row 5
version 2 row 6
version 2 row 7
");
}