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