Extract the TUI transcript overlay into its own module (#46719)

## What changed

Move `TranscriptOverlay` from `pager_overlay.rs` into
`pager_overlay/transcript.rs`, preserving its implementation and re-exporting
it from `pager_overlay`.

Move transcript tests into `pager_overlay/transcript_tests.rs`, attach the
highlight tests to the new module, and update snapshot names and source paths.

GitOrigin-RevId: ff8e78e7a5d7cca86fdd0ab10b41156b04350a82
This commit is contained in:
Eric Traut
2026-09-19 21:28:26 +00:00
committed by copyberry
parent daed4b9755
commit 1ac4b6973b
11 changed files with 1212 additions and 1256 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,9 @@
use super::super::CachedRenderable;
use super::super::TranscriptOverlay;
use super::CellRenderable;
use super::HyperlinkLinesRenderable;
use super::render_offset_content;
use crate::chatwidget::ActiveCellTranscriptKey;
use crate::history_cell::HistoryCell;
use crate::history_cell::PlainHistoryCell;
use crate::history_cell::UserHistoryCell;
use crate::keymap::RuntimeKeymap;
use crate::render::Insets;
use crate::render::renderable::InsetRenderable;
use crate::render::renderable::Renderable;
@@ -199,65 +195,6 @@ fn scrolled_transcript_renderables_match_full_height_fallback() {
}
}
#[test]
fn transcript_overlay_scrolled_cells_and_live_tail_match_full_height_fallback() {
let lines = scrolled_hyperlink_lines();
let cell: Arc<dyn HistoryCell> = Arc::new(HyperlinkTestCell {
lines: lines.clone(),
});
for width in [7, 13, 28] {
let cells: Vec<Arc<dyn HistoryCell>> = vec![
Arc::new(PlainHistoryCell::new(vec![Line::from(
"leading stable history",
)])),
cell.clone(),
];
let mut actual = TranscriptOverlay::new(cells.clone(), RuntimeKeymap::defaults().pager);
let mut expected = TranscriptOverlay::new(cells, RuntimeKeymap::defaults().pager);
for overlay in [&mut actual, &mut expected] {
overlay.sync_live_tail(
width,
Some(ActiveCellTranscriptKey {
revision: 1,
is_stream_continuation: false,
animation_tick: None,
}),
|_| Some(lines.clone()),
);
}
expected.view.renderables = expected
.view
.renderables
.into_iter()
.map(|inner| Box::new(LegacyOnlyRenderable { inner }) as Box<dyn Renderable>)
.collect();
let area = Rect::new(/*x*/ 2, /*y*/ 1, width, /*height*/ 10);
let full_area = Rect::new(
/*x*/ 0,
/*y*/ 0,
area.right().saturating_add(/*rhs*/ 1),
area.bottom().saturating_add(/*rhs*/ 1),
);
let total_height = actual.view.content_height(width);
for offset in [0, 1, 3, total_height.saturating_sub(/*rhs*/ 2), usize::MAX] {
actual.view.scroll_offset = offset;
expected.view.scroll_offset = offset;
let mut actual_buffer = Buffer::empty(full_area);
let mut expected_buffer = Buffer::empty(full_area);
actual.render(area, &mut actual_buffer);
expected.render(area, &mut expected_buffer);
assert_eq!(
actual_buffer, expected_buffer,
"width={width}, offset={offset}",
);
}
}
}
#[test]
fn fallback_handles_offsets_near_maximum_height() {
struct MaximumHeightRenderable;

View File

@@ -1,5 +1,5 @@
---
source: tui/src/pager_overlay.rs
source: tui/src/pager_overlay/transcript_tests.rs
expression: snapshot
---
/ T R A N S C R I P T / / / / / / / / / / / / / / / / / / / / / / / / / / / / /

View File

@@ -1,5 +1,5 @@
---
source: tui/src/pager_overlay.rs
source: tui/src/pager_overlay/transcript_tests.rs
expression: "buffer_to_text(&actual, area)"
---
/ T R A N S C R I P T / / / / / / / / /

View File

@@ -1,5 +1,5 @@
---
source: tui/src/pager_overlay.rs
source: tui/src/pager_overlay/transcript_tests.rs
expression: term.backend()
---
"/ T R A N S C R I P T / / / / / / / / / "

View File

@@ -1,5 +1,5 @@
---
source: tui/src/pager_overlay.rs
source: tui/src/pager_overlay/transcript_tests.rs
expression: visible_before.trim()
---
line2

View File

@@ -1,5 +1,5 @@
---
source: tui/src/pager_overlay.rs
source: tui/src/pager_overlay/transcript_tests.rs
expression: term.backend()
---
"/ T R A N S C R I P T / / / / / / / / / "

View File

@@ -1,5 +1,5 @@
---
source: tui/src/pager_overlay.rs
source: tui/src/pager_overlay/transcript_tests.rs
expression: term.backend()
---
"/ T R A N S C R I P T / / / / / / / / / "

View File

@@ -0,0 +1,504 @@
//! Detailed transcript overlay over committed history and the current live tail.
use super::*;
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,
/// Committed transcript cells (does not include the live tail).
cells: Vec<Arc<dyn HistoryCell>>,
highlight_cell: Option<usize>,
/// Cache key for the render-only live tail appended after committed cells.
live_tail_key: Option<LiveTailKey>,
history_state: TranscriptHistoryState,
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 {
Self {
view: PagerView::new(
Self::render_cells(
&transcript_cells,
/*highlight_cell*/ None,
TranscriptHistoryState::Idle,
),
"T R A N S C R I P T".to_string(),
usize::MAX,
keymap,
),
cells: transcript_cells,
highlight_cell: None,
live_tail_key: None,
history_state: TranscriptHistoryState::Idle,
is_done: false,
}
}
pub(crate) fn set_history_state(
&mut self,
state: TranscriptHistoryState,
) -> TranscriptHistoryState {
let previous = self.history_state;
if previous == state {
return previous;
}
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);
}
previous
}
fn render_cells(
cells: &[Arc<dyn HistoryCell>],
highlight_cell: Option<usize>,
history_state: TranscriptHistoryState,
) -> Vec<Box<dyn Renderable>> {
cells
.iter()
.enumerate()
.map(|(i, cell)| Self::render_cell(cell, i, highlight_cell, history_state))
.collect()
}
/// Build the renderable for a committed cell, caching its height when the cell is stable.
fn render_cell(
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 {
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
}
/// 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(
&cell,
self.cells.len(),
self.highlight_cell,
self.history_state,
);
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 {
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
.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
.highlight_cell
.is_some_and(|idx| idx >= self.cells.len())
{
self.highlight_cell = None;
}
self.rebuild_renderables(live_tail);
if follow_bottom {
self.view.scroll_offset = usize::MAX;
}
}
/// 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;
}
}
/// 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>,
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 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;
}
}
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(
history_cell,
index,
self.highlight_cell,
self.history_state,
);
}
}
}
if let Some(idx) = self.highlight_cell {
self.view.scroll_chunk_into_view(idx);
}
}
/// 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()
}
// Detach the live tail before changing cells: their old count identifies the tail renderable.
fn rebuild_renderables(&mut self, tail_renderable: Option<Box<dyn Renderable>>) {
self.view.renderables =
Self::render_cells(&self.cells, self.highlight_cell, self.history_state);
if let Some(tail) = tail_renderable {
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
}
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 mut pairs: Vec<(Vec<ShortcutHint>, &str)> = vec![(
first_or_empty(&self.view.keymap, "close", &self.view.keymap.close),
"close",
)];
if self.highlight_cell.is_some() {
pairs.push((
vec![
key_hint::plain(KeyCode::Esc).into(),
key_hint::plain(KeyCode::Left).into(),
],
"to edit prev",
));
pairs.push((vec![key_hint::plain(KeyCode::Right).into()], "to edit next"));
pairs.push((
vec![key_hint::plain(KeyCode::Enter).into()],
"to edit message",
));
} else {
pairs.push((vec![key_hint::plain(KeyCode::Esc).into()], "to edit prev"));
}
render_key_hints(line2, buf, &pairs);
}
pub(crate) fn render(&mut self, area: Rect, buf: &mut Buffer) {
// Preserve following the tail before the composer changes the available height.
if self.view.is_scrolled_to_bottom() {
self.view.scroll_offset = usize::MAX;
}
let top_h = area.height.saturating_sub(3);
let top = Rect::new(area.x, area.y, area.width, top_h);
let bottom = Rect::new(area.x, area.y + top_h, area.width, 3);
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 {
TranscriptHistoryState::Idle => return,
TranscriptHistoryState::LoadingOlder | TranscriptHistoryState::LoadingBeginning => {
" loading older history... "
}
TranscriptHistoryState::Partial => " partial history | PgUp for earlier ",
TranscriptHistoryState::Failed => " history unavailable | PgUp to retry ",
TranscriptHistoryState::Complete => " start of history ",
};
let width = (label.chars().count() as u16).min(area.width);
let status_area = Rect::new(
area.right().saturating_sub(width),
area.y,
width,
/*height*/ 1,
);
Span::from(label).dim().render(status_area, buf);
}
}
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;

View File

@@ -0,0 +1,697 @@
//! Transcript overlay navigation, pagination, and rendering tests.
use super::*;
use crate::diff_model::FileChange;
use crate::exec_cell::CommandOutput;
use crate::history_cell;
use crate::history_cell::HistoryCell;
use crate::history_cell::ReviewDecision;
use crate::history_cell::new_patch_event;
use codex_app_server_protocol::CommandExecutionSource as ExecCommandSource;
use codex_protocol::parse_command::ParsedCommand;
use crossterm::event::KeyModifiers;
use insta::assert_snapshot;
use pretty_assertions::assert_eq;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::AtomicUsize;
use std::sync::atomic::Ordering;
use std::time::Duration;
#[derive(Debug)]
struct TestCell {
lines: Vec<Line<'static>>,
}
impl crate::history_cell::HistoryCell for TestCell {
fn display_lines(&self, _width: u16) -> Vec<Line<'static>> {
self.lines.clone()
}
fn raw_lines(&self) -> Vec<Line<'static>> {
self.lines.clone()
}
fn transcript_lines(&self, _width: u16) -> Vec<Line<'static>> {
self.lines.clone()
}
}
#[derive(Debug)]
struct HeightCountingCell {
height_calls: Arc<AtomicUsize>,
}
impl crate::history_cell::HistoryCell for HeightCountingCell {
fn display_lines(&self, _width: u16) -> Vec<Line<'static>> {
vec![Line::from("counted")]
}
fn raw_lines(&self) -> Vec<Line<'static>> {
vec![Line::from("counted")]
}
fn desired_transcript_height(&self, _width: u16) -> u16 {
self.height_calls.fetch_add(1, Ordering::Relaxed);
1
}
}
fn default_pager_keymap() -> crate::keymap::PagerKeymap {
crate::keymap::RuntimeKeymap::defaults().pager
}
fn transcript_overlay(cells: Vec<Arc<dyn HistoryCell>>) -> TranscriptOverlay {
TranscriptOverlay::new(cells, default_pager_keymap())
}
#[test]
fn jump_top_requests_older_history_from_the_bottom() {
let overlay = transcript_overlay(vec![Arc::new(TestCell {
lines: vec![Line::from("recent")],
})]);
let home = KeyEvent::new(KeyCode::Home, crossterm::event::KeyModifiers::NONE);
assert!(overlay.should_load_older(home));
assert!(overlay.should_load_from_start(home));
assert!(!overlay.should_load_from_start(KeyEvent::new(
KeyCode::PageUp,
crossterm::event::KeyModifiers::NONE,
)));
}
#[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:?}"
);
}
#[test]
fn transcript_overlay_snapshots_paginated_history_states() {
let mut overlay = transcript_overlay(vec![Arc::new(TestCell {
lines: vec![Line::from("recent transcript")],
})]);
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 72, /*height*/ 10,
);
let mut snapshots = String::new();
for (name, state) in [
("loading", TranscriptHistoryState::LoadingOlder),
("partial", TranscriptHistoryState::Partial),
("failed", TranscriptHistoryState::Failed),
("complete", TranscriptHistoryState::Complete),
] {
overlay.set_history_state(state);
let mut buf = Buffer::empty(area);
overlay.render(area, &mut buf);
snapshots.push_str(&format!("--- {name} ---\n{}", buffer_to_text(&buf, area)));
}
assert_snapshot!("transcript_overlay_paginated_history_states", snapshots);
}
#[test]
fn transcript_overlay_snapshot_basic() {
// Prepare a transcript overlay with a few lines
let mut overlay = transcript_overlay(vec![
Arc::new(TestCell {
lines: vec![Line::from("alpha")],
}),
Arc::new(TestCell {
lines: vec![Line::from("beta")],
}),
Arc::new(TestCell {
lines: vec![Line::from("gamma")],
}),
]);
let mut term = Terminal::new(TestBackend::new(40, 10)).expect("term");
term.draw(|f| overlay.render(f.area(), f.buffer_mut()))
.expect("draw");
assert_snapshot!(term.backend());
}
#[test]
fn transcript_overlay_preserves_semantic_web_links() {
let destination = "https://example.com/a/very/long/path";
let mut overlay = transcript_overlay(vec![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"))
}));
}
#[test]
fn transcript_overlay_renders_live_tail() {
let mut overlay = transcript_overlay(vec![Arc::new(TestCell {
lines: vec![Line::from("alpha")],
})]);
overlay.sync_live_tail(
/*width*/ 40,
Some(ActiveCellTranscriptKey {
revision: 1,
is_stream_continuation: false,
animation_tick: None,
}),
|_| Some(vec![HyperlinkLine::from("tail")]),
);
let mut term = Terminal::new(TestBackend::new(40, 10)).expect("term");
term.draw(|f| overlay.render(f.area(), f.buffer_mut()))
.expect("draw");
assert_snapshot!(term.backend());
}
#[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 {
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 {
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"))
}));
}
#[test]
fn transcript_overlay_sync_live_tail_is_noop_for_identical_key() {
let mut overlay = transcript_overlay(vec![Arc::new(TestCell {
lines: vec![Line::from("alpha")],
})]);
let calls = std::cell::Cell::new(0usize);
let key = ActiveCellTranscriptKey {
revision: 1,
is_stream_continuation: false,
animation_tick: None,
};
overlay.sync_live_tail(/*width*/ 40, Some(key), |_| {
calls.set(calls.get() + 1);
Some(vec![HyperlinkLine::from("tail")])
});
overlay.sync_live_tail(/*width*/ 40, Some(key), |_| {
calls.set(calls.get() + 1);
Some(vec![HyperlinkLine::from("tail2")])
});
assert_eq!(calls.get(), 1);
}
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 transcript_overlay_apply_patch_scroll_vt100_clears_previous_page() {
let cwd = PathBuf::from("/repo");
let mut cells: Vec<Arc<dyn HistoryCell>> = Vec::new();
let mut approval_changes = HashMap::new();
approval_changes.insert(
PathBuf::from("foo.txt"),
FileChange::Add {
content: "hello\nworld\n".to_string(),
},
);
let approval_cell: Arc<dyn HistoryCell> = Arc::new(new_patch_event(approval_changes, &cwd));
cells.push(approval_cell);
let mut apply_changes = HashMap::new();
apply_changes.insert(
PathBuf::from("foo.txt"),
FileChange::Add {
content: "hello\nworld\n".to_string(),
},
);
let apply_begin_cell: Arc<dyn HistoryCell> = Arc::new(new_patch_event(apply_changes, &cwd));
cells.push(apply_begin_cell);
let apply_end_cell: Arc<dyn HistoryCell> = history_cell::new_approval_decision_cell(
history_cell::ApprovalDecisionSubject::Command(vec!["ls".into()]),
ReviewDecision::Approved,
history_cell::ApprovalDecisionActor::User,
)
.into();
cells.push(apply_end_cell);
let mut exec_cell = crate::exec_cell::new_active_exec_command(
"exec-1".into(),
vec!["bash".into(), "-lc".into(), "ls".into()],
vec![ParsedCommand::Unknown { cmd: "ls".into() }],
ExecCommandSource::Agent,
/*interaction_input*/ None,
/*animations_enabled*/ true,
);
exec_cell.complete_call(
"exec-1",
CommandOutput::new(/*exit_code*/ 0, "src\nREADME.md\n".into()),
Duration::from_millis(420),
);
let exec_cell: Arc<dyn HistoryCell> = Arc::new(exec_cell);
cells.push(exec_cell);
let mut overlay = transcript_overlay(cells);
let area = Rect::new(0, 0, 80, 12);
let mut buf = Buffer::empty(area);
overlay.render(area, &mut buf);
overlay.view.scroll_offset = 0;
overlay.render(area, &mut buf);
let snapshot = buffer_to_text(&buf, area);
assert_snapshot!("transcript_overlay_apply_patch_scroll_vt100", snapshot);
}
#[test]
fn transcript_overlay_keeps_scroll_pinned_at_bottom() {
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");
assert!(
overlay.view.is_scrolled_to_bottom(),
"expected initial render to leave view at bottom"
);
for height in [9, 14] {
term.backend_mut().resize(/*width*/ 40, height);
term.draw(|f| overlay.render(f.area(), f.buffer_mut()))
.expect("draw after composer height change");
assert!(overlay.is_scrolled_to_bottom());
}
overlay.insert_cell(Arc::new(TestCell {
lines: vec!["tail".into()],
}));
assert_eq!(overlay.view.scroll_offset, usize::MAX);
term.draw(|f| overlay.render(f.area(), f.buffer_mut()))
.expect("draw committed tail");
assert_snapshot!("transcript_overlay_follows_resized_tail", term.backend());
}
#[test]
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 {
revision: 1,
is_stream_continuation: false,
animation_tick: None,
});
overlay.sync_live_tail(/*width*/ 40, key, |_| tail.clone());
let consolidated = Arc::new(TestCell {
lines: vec!["first".into(), "last".into()],
});
if replace {
overlay.replace_cells(vec![consolidated]);
} else {
overlay.consolidate_cells(0..2, consolidated);
}
// A draw may arrive after the active tail has already been cleared.
overlay.sync_live_tail(/*width*/ 40, key, |_| tail.clone());
let mut reopened = transcript_overlay(overlay.cells.clone());
reopened.sync_live_tail(/*width*/ 40, key, |_| tail.clone());
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 40, /*height*/ 10,
);
let mut actual = Buffer::empty(area);
let mut expected = Buffer::empty(area);
overlay.render(area, &mut actual);
reopened.render(area, &mut expected);
assert_eq!(actual, expected);
if tail.is_none() {
assert_snapshot!(
"transcript_overlay_completed_stream",
buffer_to_text(&actual, area)
);
}
}
}
}
#[test]
fn transcript_overlay_consolidation_remaps_highlight_inside_range() {
let mut overlay = transcript_overlay(
(0..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>
})
.collect(),
);
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"
);
}
#[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>
})
.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 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.handle_key_event(
&mut tui,
KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL),
)?;
assert_eq!(
overlay.view.scroll_offset,
10 + page_height.saturating_add(1) / 2
);
Ok(())
}