mirror of
https://github.com/openai/codex.git
synced 2026-09-20 12:47:38 +00:00
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:
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
@@ -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 / / / / / / / / / / / / / / / / / / / / / / / / / / / / /
|
||||
@@ -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 / / / / / / / / /
|
||||
@@ -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 / / / / / / / / / "
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
source: tui/src/pager_overlay.rs
|
||||
source: tui/src/pager_overlay/transcript_tests.rs
|
||||
expression: snapshots
|
||||
---
|
||||
--- loading ---
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
source: tui/src/pager_overlay.rs
|
||||
source: tui/src/pager_overlay/transcript_tests.rs
|
||||
expression: visible_before.trim()
|
||||
---
|
||||
line2
|
||||
@@ -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 / / / / / / / / / "
|
||||
@@ -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 / / / / / / / / / "
|
||||
504
codex-rs/tui/src/pager_overlay/transcript.rs
Normal file
504
codex-rs/tui/src/pager_overlay/transcript.rs
Normal 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;
|
||||
697
codex-rs/tui/src/pager_overlay/transcript_tests.rs
Normal file
697
codex-rs/tui/src/pager_overlay/transcript_tests.rs
Normal 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(())
|
||||
}
|
||||
Reference in New Issue
Block a user