From 1ac4b6973bca17804de30690cbe15f3e6324d472 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Sat, 19 Sep 2026 21:28:26 +0000 Subject: [PATCH] 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 --- codex-rs/tui/src/pager_overlay.rs | 1190 +---------------- .../tui/src/pager_overlay/scrolling_tests.rs | 63 - ...ipt_overlay_apply_patch_scroll_vt100.snap} | 2 +- ..._transcript_overlay_completed_stream.snap} | 2 +- ...nscript_overlay_follows_resized_tail.snap} | 2 +- ...ipt_overlay_paginated_history_states.snap} | 2 +- ...transcript_overlay_prepended_history.snap} | 2 +- ...transcript_overlay_renders_live_tail.snap} | 2 +- ...s__transcript_overlay_snapshot_basic.snap} | 2 +- codex-rs/tui/src/pager_overlay/transcript.rs | 504 +++++++ .../tui/src/pager_overlay/transcript_tests.rs | 697 ++++++++++ 11 files changed, 1212 insertions(+), 1256 deletions(-) rename codex-rs/tui/src/{snapshots/codex_tui__pager_overlay__tests__transcript_overlay_apply_patch_scroll_vt100.snap => pager_overlay/snapshots/codex_tui__pager_overlay__transcript__tests__transcript_overlay_apply_patch_scroll_vt100.snap} (91%) rename codex-rs/tui/src/{snapshots/codex_tui__pager_overlay__tests__transcript_overlay_completed_stream.snap => pager_overlay/snapshots/codex_tui__pager_overlay__transcript__tests__transcript_overlay_completed_stream.snap} (85%) rename codex-rs/tui/src/{snapshots/codex_tui__pager_overlay__tests__transcript_overlay_follows_resized_tail.snap => pager_overlay/snapshots/codex_tui__pager_overlay__transcript__tests__transcript_overlay_follows_resized_tail.snap} (93%) rename codex-rs/tui/src/{snapshots/codex_tui__pager_overlay__tests__transcript_overlay_paginated_history_states.snap => pager_overlay/snapshots/codex_tui__pager_overlay__transcript__tests__transcript_overlay_paginated_history_states.snap} (97%) rename codex-rs/tui/src/{snapshots/codex_tui__pager_overlay__tests__transcript_overlay_prepended_history.snap => pager_overlay/snapshots/codex_tui__pager_overlay__transcript__tests__transcript_overlay_prepended_history.snap} (52%) rename codex-rs/tui/src/{snapshots/codex_tui__pager_overlay__tests__transcript_overlay_renders_live_tail.snap => pager_overlay/snapshots/codex_tui__pager_overlay__transcript__tests__transcript_overlay_renders_live_tail.snap} (91%) rename codex-rs/tui/src/{snapshots/codex_tui__pager_overlay__tests__transcript_overlay_snapshot_basic.snap => pager_overlay/snapshots/codex_tui__pager_overlay__transcript__tests__transcript_overlay_snapshot_basic.snap} (91%) create mode 100644 codex-rs/tui/src/pager_overlay/transcript.rs create mode 100644 codex-rs/tui/src/pager_overlay/transcript_tests.rs diff --git a/codex-rs/tui/src/pager_overlay.rs b/codex-rs/tui/src/pager_overlay.rs index 4cb407363b..d3f25aa649 100644 --- a/codex-rs/tui/src/pager_overlay.rs +++ b/codex-rs/tui/src/pager_overlay.rs @@ -16,13 +16,7 @@ //! mutates in place or when its transcript output is time-dependent. mod scrolling; - -#[cfg(test)] -#[path = "pager_overlay/highlight_tests.rs"] -mod highlight_tests; - -use std::io::Result; -use std::sync::Arc; +mod transcript; use crate::chatwidget::ActiveCellTranscriptKey; use crate::history_cell::HistoryCell; @@ -54,6 +48,9 @@ use ratatui::widgets::Wrap; use scrolling::CellRenderable; use scrolling::HyperlinkLinesRenderable; use scrolling::render_offset_content; +use std::io::Result; +use std::sync::Arc; +pub(crate) use transcript::TranscriptOverlay; pub(crate) enum Overlay { Transcript(TranscriptOverlay), @@ -472,499 +469,6 @@ impl TranscriptHistoryState { } } -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>, - highlight_cell: Option, - /// Cache key for the render-only live tail appended after committed cells. - live_tail_key: Option, - 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, -} - -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>, 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::()) - { - let live_tail = self.take_live_tail_renderable(); - self.rebuild_renderables(live_tail); - } - previous - } - - fn render_cells( - cells: &[Arc], - highlight_cell: Option, - history_state: TranscriptHistoryState, - ) -> Vec> { - 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, - index: usize, - highlight_cell: Option, - history_state: TranscriptHistoryState, - ) -> Box { - if cell.as_any().is::() - && 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 = 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) { - 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 - } 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>, 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::()) - .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>) { - 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, - consolidated: Arc, - ) { - 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, - compute_lines: impl FnOnce(u16) -> Option>, - ) { - 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) { - 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>) { - 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> { - (self.view.renderables.len() > self.cells.len()).then(|| self.view.renderables.pop())? - } - - fn live_tail_renderable( - lines: Vec, - has_prior_cells: bool, - is_stream_continuation: bool, - ) -> Box { - let mut renderable: Box = - 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, &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 - } -} - pub(crate) struct StaticOverlay { view: PagerView, is_done: bool, @@ -1042,67 +546,12 @@ impl StaticOverlay { #[cfg(test)] mod tests { use super::*; - use crate::history_cell::ReviewDecision; - use codex_app_server_protocol::CommandExecutionSource as ExecCommandSource; use insta::assert_snapshot; use pretty_assertions::assert_eq; - 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; - - use crate::diff_model::FileChange; - use crate::exec_cell::CommandOutput; - use crate::history_cell; - use crate::history_cell::HistoryCell; - use crate::history_cell::new_patch_event; - use codex_protocol::parse_command::ParsedCommand; - use crossterm::event::KeyModifiers; use ratatui::Terminal; use ratatui::backend::TestBackend; use ratatui::text::Text; - #[derive(Debug)] - struct TestCell { - lines: Vec>, - } - - impl crate::history_cell::HistoryCell for TestCell { - fn display_lines(&self, _width: u16) -> Vec> { - self.lines.clone() - } - - fn raw_lines(&self) -> Vec> { - self.lines.clone() - } - - fn transcript_lines(&self, _width: u16) -> Vec> { - self.lines.clone() - } - } - - #[derive(Debug)] - struct HeightCountingCell { - height_calls: Arc, - } - - impl crate::history_cell::HistoryCell for HeightCountingCell { - fn display_lines(&self, _width: u16) -> Vec> { - vec![Line::from("counted")] - } - - fn raw_lines(&self) -> Vec> { - vec![Line::from("counted")] - } - - fn desired_transcript_height(&self, _width: u16) -> u16 { - self.height_calls.fetch_add(1, Ordering::Relaxed); - 1 - } - } - fn paragraph_block(label: &str, lines: usize) -> Box { let text = Text::from( (0..lines) @@ -1116,10 +565,6 @@ mod tests { crate::keymap::RuntimeKeymap::defaults().pager } - fn transcript_overlay(cells: Vec>) -> TranscriptOverlay { - TranscriptOverlay::new(cells, default_pager_keymap()) - } - fn static_overlay(lines: Vec>, title: &str) -> StaticOverlay { StaticOverlay::with_title(lines, title.to_string(), default_pager_keymap()) } @@ -1158,234 +603,6 @@ mod tests { ); } - #[test] - fn edit_prev_hint_is_visible() { - let mut overlay = transcript_overlay(vec![Arc::new(TestCell { - lines: vec![Line::from("hello")], - })]); - - // 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 prev"), - "expected 'edit prev' hint in overlay footer, got: {s:?}" - ); - } - - #[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() { @@ -1406,278 +623,6 @@ mod tests { out } - #[test] - fn transcript_overlay_apply_patch_scroll_vt100_clears_previous_page() { - let cwd = PathBuf::from("/repo"); - let mut cells: Vec> = 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 = 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 = Arc::new(new_patch_event(apply_changes, &cwd)); - cells.push(apply_begin_cell); - - let apply_end_cell: Arc = 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 = 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 - }) - .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 - }) - .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 - }) - .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 - }) - .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 - }) - .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", - ); - } - #[test] fn static_overlay_snapshot_basic() { // Prepare a static overlay with a few lines and a title @@ -1692,133 +637,6 @@ mod tests { } /// Render transcript overlay and return visible line numbers (`line-NN`) in order. - fn transcript_line_numbers(overlay: &mut TranscriptOverlay, area: Rect) -> Vec { - 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 - }) - .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 = (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 - }) - .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(()) - } #[test] fn static_overlay_wraps_long_lines() { diff --git a/codex-rs/tui/src/pager_overlay/scrolling_tests.rs b/codex-rs/tui/src/pager_overlay/scrolling_tests.rs index 2e15be86e1..75712b2bdd 100644 --- a/codex-rs/tui/src/pager_overlay/scrolling_tests.rs +++ b/codex-rs/tui/src/pager_overlay/scrolling_tests.rs @@ -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 = Arc::new(HyperlinkTestCell { - lines: lines.clone(), - }); - - for width in [7, 13, 28] { - let cells: Vec> = 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) - .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; diff --git a/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_apply_patch_scroll_vt100.snap b/codex-rs/tui/src/pager_overlay/snapshots/codex_tui__pager_overlay__transcript__tests__transcript_overlay_apply_patch_scroll_vt100.snap similarity index 91% rename from codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_apply_patch_scroll_vt100.snap rename to codex-rs/tui/src/pager_overlay/snapshots/codex_tui__pager_overlay__transcript__tests__transcript_overlay_apply_patch_scroll_vt100.snap index 24ce94e912..22c3f0fc80 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_apply_patch_scroll_vt100.snap +++ b/codex-rs/tui/src/pager_overlay/snapshots/codex_tui__pager_overlay__transcript__tests__transcript_overlay_apply_patch_scroll_vt100.snap @@ -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 / / / / / / / / / / / / / / / / / / / / / / / / / / / / / diff --git a/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_completed_stream.snap b/codex-rs/tui/src/pager_overlay/snapshots/codex_tui__pager_overlay__transcript__tests__transcript_overlay_completed_stream.snap similarity index 85% rename from codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_completed_stream.snap rename to codex-rs/tui/src/pager_overlay/snapshots/codex_tui__pager_overlay__transcript__tests__transcript_overlay_completed_stream.snap index 9ed198f554..16049ad05a 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_completed_stream.snap +++ b/codex-rs/tui/src/pager_overlay/snapshots/codex_tui__pager_overlay__transcript__tests__transcript_overlay_completed_stream.snap @@ -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 / / / / / / / / / diff --git a/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_follows_resized_tail.snap b/codex-rs/tui/src/pager_overlay/snapshots/codex_tui__pager_overlay__transcript__tests__transcript_overlay_follows_resized_tail.snap similarity index 93% rename from codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_follows_resized_tail.snap rename to codex-rs/tui/src/pager_overlay/snapshots/codex_tui__pager_overlay__transcript__tests__transcript_overlay_follows_resized_tail.snap index 84cb5dbcec..2b5a1776f9 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_follows_resized_tail.snap +++ b/codex-rs/tui/src/pager_overlay/snapshots/codex_tui__pager_overlay__transcript__tests__transcript_overlay_follows_resized_tail.snap @@ -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 / / / / / / / / / " diff --git a/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_paginated_history_states.snap b/codex-rs/tui/src/pager_overlay/snapshots/codex_tui__pager_overlay__transcript__tests__transcript_overlay_paginated_history_states.snap similarity index 97% rename from codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_paginated_history_states.snap rename to codex-rs/tui/src/pager_overlay/snapshots/codex_tui__pager_overlay__transcript__tests__transcript_overlay_paginated_history_states.snap index b8106e6cae..ec975f94de 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_paginated_history_states.snap +++ b/codex-rs/tui/src/pager_overlay/snapshots/codex_tui__pager_overlay__transcript__tests__transcript_overlay_paginated_history_states.snap @@ -1,5 +1,5 @@ --- -source: tui/src/pager_overlay.rs +source: tui/src/pager_overlay/transcript_tests.rs expression: snapshots --- --- loading --- diff --git a/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_prepended_history.snap b/codex-rs/tui/src/pager_overlay/snapshots/codex_tui__pager_overlay__transcript__tests__transcript_overlay_prepended_history.snap similarity index 52% rename from codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_prepended_history.snap rename to codex-rs/tui/src/pager_overlay/snapshots/codex_tui__pager_overlay__transcript__tests__transcript_overlay_prepended_history.snap index e7602f1517..bbec8348a2 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_prepended_history.snap +++ b/codex-rs/tui/src/pager_overlay/snapshots/codex_tui__pager_overlay__transcript__tests__transcript_overlay_prepended_history.snap @@ -1,5 +1,5 @@ --- -source: tui/src/pager_overlay.rs +source: tui/src/pager_overlay/transcript_tests.rs expression: visible_before.trim() --- line2 diff --git a/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_renders_live_tail.snap b/codex-rs/tui/src/pager_overlay/snapshots/codex_tui__pager_overlay__transcript__tests__transcript_overlay_renders_live_tail.snap similarity index 91% rename from codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_renders_live_tail.snap rename to codex-rs/tui/src/pager_overlay/snapshots/codex_tui__pager_overlay__transcript__tests__transcript_overlay_renders_live_tail.snap index b823027c57..f269e26e0e 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_renders_live_tail.snap +++ b/codex-rs/tui/src/pager_overlay/snapshots/codex_tui__pager_overlay__transcript__tests__transcript_overlay_renders_live_tail.snap @@ -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 / / / / / / / / / " diff --git a/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_snapshot_basic.snap b/codex-rs/tui/src/pager_overlay/snapshots/codex_tui__pager_overlay__transcript__tests__transcript_overlay_snapshot_basic.snap similarity index 91% rename from codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_snapshot_basic.snap rename to codex-rs/tui/src/pager_overlay/snapshots/codex_tui__pager_overlay__transcript__tests__transcript_overlay_snapshot_basic.snap index 024964cea0..3fdd854ed1 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_snapshot_basic.snap +++ b/codex-rs/tui/src/pager_overlay/snapshots/codex_tui__pager_overlay__transcript__tests__transcript_overlay_snapshot_basic.snap @@ -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 / / / / / / / / / " diff --git a/codex-rs/tui/src/pager_overlay/transcript.rs b/codex-rs/tui/src/pager_overlay/transcript.rs new file mode 100644 index 0000000000..ff9bb7e1a5 --- /dev/null +++ b/codex-rs/tui/src/pager_overlay/transcript.rs @@ -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>, + highlight_cell: Option, + /// Cache key for the render-only live tail appended after committed cells. + live_tail_key: Option, + 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, +} + +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>, 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::()) + { + let live_tail = self.take_live_tail_renderable(); + self.rebuild_renderables(live_tail); + } + previous + } + + fn render_cells( + cells: &[Arc], + highlight_cell: Option, + history_state: TranscriptHistoryState, + ) -> Vec> { + 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, + index: usize, + highlight_cell: Option, + history_state: TranscriptHistoryState, + ) -> Box { + if cell.as_any().is::() + && 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 = 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) { + 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 + } 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>, 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::()) + .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>) { + 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, + consolidated: Arc, + ) { + 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, + compute_lines: impl FnOnce(u16) -> Option>, + ) { + 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) { + 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>) { + 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> { + (self.view.renderables.len() > self.cells.len()).then(|| self.view.renderables.pop())? + } + + fn live_tail_renderable( + lines: Vec, + has_prior_cells: bool, + is_stream_continuation: bool, + ) -> Box { + let mut renderable: Box = + 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, &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; diff --git a/codex-rs/tui/src/pager_overlay/transcript_tests.rs b/codex-rs/tui/src/pager_overlay/transcript_tests.rs new file mode 100644 index 0000000000..6af64f9c0c --- /dev/null +++ b/codex-rs/tui/src/pager_overlay/transcript_tests.rs @@ -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>, +} + +impl crate::history_cell::HistoryCell for TestCell { + fn display_lines(&self, _width: u16) -> Vec> { + self.lines.clone() + } + + fn raw_lines(&self) -> Vec> { + self.lines.clone() + } + + fn transcript_lines(&self, _width: u16) -> Vec> { + self.lines.clone() + } +} + +#[derive(Debug)] +struct HeightCountingCell { + height_calls: Arc, +} + +impl crate::history_cell::HistoryCell for HeightCountingCell { + fn display_lines(&self, _width: u16) -> Vec> { + vec![Line::from("counted")] + } + + fn raw_lines(&self) -> Vec> { + 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>) -> 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> = 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 = 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 = Arc::new(new_patch_event(apply_changes, &cwd)); + cells.push(apply_begin_cell); + + let apply_end_cell: Arc = 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 = 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 + }) + .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 + }) + .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 + }) + .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 + }) + .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 + }) + .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 { + 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 + }) + .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 = (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 + }) + .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(()) +}