From 30ab0ee65b5dc871df14498087132c59aad3d8bb Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Sat, 19 Sep 2026 21:28:27 +0000 Subject: [PATCH] Cache transcript layouts across measurement and rendering (#46720) ## Why The transcript overlay separately generated cell content for height measurement and rendering. Active cells backed by external state could also leave the live tail stale when their revision key did not change. ## What changed - Share retained text layouts between measurement and painting, and render only visible rows while preserving wrapping, styles, and hyperlinks. - Reuse stable layouts across frames, invalidating them when width, animation, syntax theme, or terminal colors change. Bound retention by entry count and text size, allowing a single oversized entry. - Refresh externally mutable cells each frame and bypass live-tail reuse when any active source is not cacheable. ## Testing Add coverage for layout reuse, eviction, invalidation, and live-tail refresh without a revision change. Expand the transcript snapshot to cover styled text, wide glyphs, wrapping, and hyperlinks. GitOrigin-RevId: 7dfb737e762f7f056a38774d956f618090809a62 --- codex-rs/tui/src/chatwidget.rs | 19 ++- .../tui/src/chatwidget/rendering_tests.rs | 29 +++++ codex-rs/tui/src/history_cell/mod.rs | 9 -- codex-rs/tui/src/lib.rs | 1 + codex-rs/tui/src/pager_overlay.rs | 8 +- .../tui/src/pager_overlay/highlight_tests.rs | 9 +- codex-rs/tui/src/pager_overlay/scrolling.rs | 21 +++- .../tui/src/pager_overlay/scrolling_tests.rs | 3 + ...ts__transcript_overlay_snapshot_basic.snap | 65 ++++++++-- codex-rs/tui/src/pager_overlay/transcript.rs | 25 +++- .../tui/src/pager_overlay/transcript_tests.rs | 32 ++--- codex-rs/tui/src/transcript_view.rs | 7 ++ codex-rs/tui/src/transcript_view/layout.rs | 113 ++++++++++++++++++ .../tui/src/transcript_view/layout_tests.rs | 103 ++++++++++++++++ codex-rs/tui/src/transcript_view/text.rs | 86 +++++++++++++ 15 files changed, 475 insertions(+), 55 deletions(-) create mode 100644 codex-rs/tui/src/transcript_view.rs create mode 100644 codex-rs/tui/src/transcript_view/layout.rs create mode 100644 codex-rs/tui/src/transcript_view/layout_tests.rs create mode 100644 codex-rs/tui/src/transcript_view/text.rs diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 825da453e8..b4a263ea88 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -832,6 +832,8 @@ enum CodexOpTarget { /// it cheaply decide when to recompute that tail as the active cell evolves. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) struct ActiveCellTranscriptKey { + /// External mutable sources must refresh even when the explicit revision is unchanged. + pub(crate) cacheable: bool, /// Cache-busting revision for in-place updates. /// /// Many active cells are updated incrementally while streaming (for example when exec groups @@ -1994,11 +1996,26 @@ impl ChatWidget { { return None; } + let live_sources: [Option<&dyn HistoryCell>; 2] = [ + cell.map(AsRef::as_ref), + rate_limit_reset_hint.map(|cell| cell as &dyn HistoryCell), + ]; Some(ActiveCellTranscriptKey { + cacheable: live_sources + .into_iter() + .flatten() + .chain( + self.realtime_conversation + .pending_history_cells + .iter() + .chain(self.realtime_conversation.live_transcript_cells()) + .map(AsRef::as_ref), + ) + .all(HistoryCell::has_stable_transcript_height), revision: self.transcript.active_cell_revision, is_stream_continuation: cell .map(|cell| cell.is_stream_continuation()) - .unwrap_or(false), + .unwrap_or(/*default*/ false), animation_tick: cell .and_then(|cell| cell.transcript_animation_tick()) .or_else(|| realtime_cells.find_map(|cell| cell.transcript_animation_tick())), diff --git a/codex-rs/tui/src/chatwidget/rendering_tests.rs b/codex-rs/tui/src/chatwidget/rendering_tests.rs index 589f08b13a..e0c844396b 100644 --- a/codex-rs/tui/src/chatwidget/rendering_tests.rs +++ b/codex-rs/tui/src/chatwidget/rendering_tests.rs @@ -391,3 +391,32 @@ async fn external_writer_notice_offers_command_center_on_shared_servers() { } } } + +#[tokio::test] +async fn externally_mutable_active_cells_refresh_the_transcript_without_a_revision_change() { + let (widget, _height_calls, display_calls) = widget_with_counting_cell( + /*desired_height*/ 2, /*line_count*/ 2, /*stable_height*/ false, + ) + .await; + let mut overlay = crate::pager_overlay::TranscriptOverlay::new( + Vec::new(), + crate::keymap::RuntimeKeymap::defaults().pager, + ); + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 48, /*height*/ 10, + ); + let key = widget.active_cell_transcript_key(); + let mut frames = Vec::new(); + for _ in 0..2 { + assert_eq!(widget.active_cell_transcript_key(), key); + overlay.sync_live_tail(area.width, key, |width| { + widget.active_cell_transcript_hyperlink_lines(width) + }); + let mut buffer = Buffer::empty(area); + overlay.render(area, &mut buffer); + frames.push(buffer); + } + assert_eq!(display_calls.load(Ordering::Relaxed), 2); + assert!(contains_text(&frames[0], "frame 1 row 0")); + assert!(contains_text(&frames[1], "frame 2 row 0")); +} diff --git a/codex-rs/tui/src/history_cell/mod.rs b/codex-rs/tui/src/history_cell/mod.rs index c4419cad6a..2ff7163df5 100644 --- a/codex-rs/tui/src/history_cell/mod.rs +++ b/codex-rs/tui/src/history_cell/mod.rs @@ -321,15 +321,6 @@ pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync + Any { plain_hyperlink_lines(self.transcript_lines(width)) } - fn desired_transcript_height(&self, width: u16) -> u16 { - let lines = visible_lines(self.transcript_hyperlink_lines(width)); - Paragraph::new(Text::from(lines)) - .wrap(Wrap { trim: false }) - .line_count(width) - .try_into() - .unwrap_or(0) - } - /// Whether the cached transcript layout remains valid across later frames. /// /// Cells backed by external state should return `false` so the shared viewport refreshes diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 1c49b3f6af..469f7773b4 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -220,6 +220,7 @@ mod token_usage; mod tool_output; mod tooltips; mod transcript_reflow; +mod transcript_view; mod tui; mod ui_consts; mod unarchive_prompt; diff --git a/codex-rs/tui/src/pager_overlay.rs b/codex-rs/tui/src/pager_overlay.rs index d3f25aa649..705bf4b1e1 100644 --- a/codex-rs/tui/src/pager_overlay.rs +++ b/codex-rs/tui/src/pager_overlay.rs @@ -18,6 +18,11 @@ mod scrolling; mod transcript; +pub(crate) use transcript::TranscriptOverlay; + +use std::io::Result; +use std::sync::Arc; + use crate::chatwidget::ActiveCellTranscriptKey; use crate::history_cell::HistoryCell; use crate::history_cell::SessionInfoCell; @@ -48,9 +53,6 @@ use ratatui::widgets::Wrap; use scrolling::CellRenderable; use scrolling::HyperlinkLinesRenderable; use scrolling::render_offset_content; -use std::io::Result; -use std::sync::Arc; -pub(crate) use transcript::TranscriptOverlay; pub(crate) enum Overlay { Transcript(TranscriptOverlay), diff --git a/codex-rs/tui/src/pager_overlay/highlight_tests.rs b/codex-rs/tui/src/pager_overlay/highlight_tests.rs index 15663f686f..3e7d3b3855 100644 --- a/codex-rs/tui/src/pager_overlay/highlight_tests.rs +++ b/codex-rs/tui/src/pager_overlay/highlight_tests.rs @@ -14,17 +14,13 @@ struct MeasuredCell { 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()] } - - fn desired_transcript_height(&self, _width: u16) -> u16 { - self.measurements.fetch_add(1, Ordering::Relaxed); - 1 - } } #[test] @@ -60,8 +56,6 @@ fn moving_highlight_preserves_unaffected_height_caches() { .collect::>() }; let mut expected = vec![1; cells.len()]; - expected[28] = 3; - expected[30] = 3; assert_eq!(measurements(), expected); // Width changes still invalidate every cached height. @@ -93,6 +87,7 @@ fn moving_highlight_matches_full_rebuild_with_live_tail() { overlay.sync_live_tail( /*width*/ 40, Some(ActiveCellTranscriptKey { + cacheable: true, revision: 1, is_stream_continuation: false, animation_tick: None, diff --git a/codex-rs/tui/src/pager_overlay/scrolling.rs b/codex-rs/tui/src/pager_overlay/scrolling.rs index 9cfdd11e7c..a6b66a8c4e 100644 --- a/codex-rs/tui/src/pager_overlay/scrolling.rs +++ b/codex-rs/tui/src/pager_overlay/scrolling.rs @@ -1,5 +1,8 @@ //! Viewport-aware transcript rendering and the fallback for generic pager content. +use crate::transcript_view::LayoutCache; +use std::cell::RefCell; +use std::rc::Rc; use std::sync::Arc; use crate::history_cell::HistoryCell; @@ -17,6 +20,7 @@ use ratatui::widgets::Widget; pub(super) struct CellRenderable { pub(super) cell: Arc, pub(super) highlighted: bool, + pub(super) cache: Rc>, } impl Renderable for CellRenderable { @@ -26,7 +30,10 @@ impl Renderable for CellRenderable { /// 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 hyperlink_lines = self.cell.transcript_hyperlink_lines(area.width); + 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() @@ -36,14 +43,18 @@ impl Renderable for CellRenderable { } else { Style::default() }; - HyperlinkParagraph::new(&hyperlink_lines, style) - .scroll(scroll_offset) - .render(area, buf); + buf.set_style(area, style); + layout.render(area, buf, usize::from(scroll_offset)); true } fn desired_height(&self, width: u16) -> u16 { - self.cell.desired_transcript_height(width) + self.cache + .borrow_mut() + .get(&self.cell, width, /*separated*/ false) + .row_count() + .try_into() + .unwrap_or(u16::MAX) } } diff --git a/codex-rs/tui/src/pager_overlay/scrolling_tests.rs b/codex-rs/tui/src/pager_overlay/scrolling_tests.rs index 75712b2bdd..26d5a22321 100644 --- a/codex-rs/tui/src/pager_overlay/scrolling_tests.rs +++ b/codex-rs/tui/src/pager_overlay/scrolling_tests.rs @@ -96,6 +96,7 @@ fn scrolled_test_renderables(lines: &[HyperlinkLine]) -> Vec<(&'static str, Box< ( "uncached history cell", Box::new(CellRenderable { + cache: Default::default(), cell: cell.clone(), highlighted: false, }), @@ -103,6 +104,7 @@ fn scrolled_test_renderables(lines: &[HyperlinkLine]) -> Vec<(&'static str, Box< ( "highlighted cached user history cell", Box::new(CachedRenderable::new(CellRenderable { + cache: Default::default(), cell: user, highlighted: true, })), @@ -111,6 +113,7 @@ fn scrolled_test_renderables(lines: &[HyperlinkLine]) -> Vec<(&'static str, Box< "inset cached history cell", Box::new(InsetRenderable::new( Box::new(CachedRenderable::new(CellRenderable { + cache: Default::default(), cell, highlighted: false, })) as Box, diff --git a/codex-rs/tui/src/pager_overlay/snapshots/codex_tui__pager_overlay__transcript__tests__transcript_overlay_snapshot_basic.snap b/codex-rs/tui/src/pager_overlay/snapshots/codex_tui__pager_overlay__transcript__tests__transcript_overlay_snapshot_basic.snap index 3fdd854ed1..b55c9f66ed 100644 --- a/codex-rs/tui/src/pager_overlay/snapshots/codex_tui__pager_overlay__transcript__tests__transcript_overlay_snapshot_basic.snap +++ b/codex-rs/tui/src/pager_overlay/snapshots/codex_tui__pager_overlay__transcript__tests__transcript_overlay_snapshot_basic.snap @@ -1,14 +1,57 @@ --- source: tui/src/pager_overlay/transcript_tests.rs -expression: term.backend() +expression: "format!(\"{:?}\", term.backend().buffer())" --- -"/ T R A N S C R I P T / / / / / / / / / " -"alpha " -" " -"beta " -" " -"gamma " -"───────────────────────────────── 100% ─" -" ↑/↓ to scroll pgup/pgdn to page hom" -" q close esc to edit prev " -" " +Buffer { + area: Rect { x: 0, y: 0, width: 24, height: 16 }, + content: [ + "/ T R A N S C R I P T / ", + " indented styled 漢字", // hidden by multi-width symbols: [(21, " "), (23, " ")] + "ガ text ", // hidden by multi-width symbols: [(1, " ")] + "abc-def ghi ", + "continuation spaces and ", + "a-long-hyphenated-token ", + "+ added text ", + " ", + "• A styled ]8;;https://example.com/transcriptl]8;;]8;;https://example.com/transcripti]8;;]8;;https://example.com/transcriptn]8;;]8;;https://example.com/transcriptk]8;; ", + " (]8;;https://example.com/transcripth]8;;]8;;https://example.com/transcriptt]8;;]8;;https://example.com/transcriptt]8;;]8;;https://example.com/transcriptp]8;;]8;;https://example.com/transcripts]8;;]8;;https://example.com/transcript:]8;;]8;;https://example.com/transcript/]8;;]8;;https://example.com/transcript/]8;;]8;;https://example.com/transcripte]8;;]8;;https://example.com/transcriptx]8;;]8;;https://example.com/transcripta]8;;]8;;https://example.com/transcriptm]8;;]8;;https://example.com/transcriptp]8;;]8;;https://example.com/transcriptl]8;;]8;;https://example.com/transcripte]8;;]8;;https://example.com/transcript.]8;;]8;;https://example.com/transcriptc]8;;]8;;https://example.com/transcripto]8;;]8;;https://example.com/transcriptm]8;;]8;;https://example.com/transcript/]8;;]8;;https://example.com/transcriptt]8;;", + "]8;;https://example.com/transcriptr]8;;]8;;https://example.com/transcripta]8;;]8;;https://example.com/transcriptn]8;;]8;;https://example.com/transcripts]8;;]8;;https://example.com/transcriptc]8;;]8;;https://example.com/transcriptr]8;;]8;;https://example.com/transcripti]8;;]8;;https://example.com/transcriptp]8;;]8;;https://example.com/transcriptt]8;;) ", + "~ ", + "───────────────── 100% ─", + " ↑/↓ to scroll pgup/pg", + " q close esc to edit p", + " ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 0, y: 1, fg: Green, bg: Reset, underline: Reset, modifier: NONE, + x: 13, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, + x: 21, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 22, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, + x: 23, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, + x: 1, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, + x: 7, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 6, fg: Reset, bg: Green, underline: Reset, modifier: NONE, + x: 12, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 2, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 4, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, + x: 10, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 11, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: UNDERLINED, + x: 15, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 3, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: UNDERLINED, + x: 9, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 0, y: 12, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 1, y: 13, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, + x: 4, y: 13, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 17, y: 13, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, + x: 0, y: 14, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 1, y: 14, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, + x: 2, y: 14, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 11, y: 14, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, + x: 14, y: 14, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 0, y: 15, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} diff --git a/codex-rs/tui/src/pager_overlay/transcript.rs b/codex-rs/tui/src/pager_overlay/transcript.rs index ff9bb7e1a5..68fa46a2cb 100644 --- a/codex-rs/tui/src/pager_overlay/transcript.rs +++ b/codex-rs/tui/src/pager_overlay/transcript.rs @@ -1,6 +1,9 @@ //! Detailed transcript overlay over committed history and the current live tail. use super::*; +use crate::transcript_view::LayoutCache; +use std::cell::RefCell; +use std::rc::Rc; pub(crate) struct TranscriptOverlay { /// Pager UI state and the renderables currently displayed. @@ -8,6 +11,7 @@ pub(crate) struct TranscriptOverlay { /// 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). cells: Vec>, highlight_cell: Option, @@ -38,9 +42,11 @@ impl TranscriptOverlay { /// 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())); Self { view: PagerView::new( Self::render_cells( + &cache, &transcript_cells, /*highlight_cell*/ None, TranscriptHistoryState::Idle, @@ -49,6 +55,7 @@ impl TranscriptOverlay { usize::MAX, keymap, ), + cache, cells: transcript_cells, highlight_cell: None, live_tail_key: None, @@ -84,6 +91,7 @@ impl TranscriptOverlay { } fn render_cells( + cache: &Rc>, cells: &[Arc], highlight_cell: Option, history_state: TranscriptHistoryState, @@ -91,12 +99,13 @@ impl TranscriptOverlay { cells .iter() .enumerate() - .map(|(i, cell)| Self::render_cell(cell, i, highlight_cell, history_state)) + .map(|(i, cell)| Self::render_cell(cache, cell, i, highlight_cell, history_state)) .collect() } /// Build the renderable for a committed cell, caching its height when the cell is stable. fn render_cell( + cache: &Rc>, cell: &Arc, index: usize, highlight_cell: Option, @@ -108,6 +117,7 @@ impl TranscriptOverlay { return Box::new(Line::from(placeholder).dim()); } let cell_renderable = CellRenderable { + cache: Rc::clone(cache), cell: cell.clone(), highlighted: highlight_cell == Some(index), }; @@ -142,6 +152,7 @@ impl TranscriptOverlay { 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, @@ -315,7 +326,7 @@ impl TranscriptOverlay { animation_tick: key.animation_tick, }); - if self.live_tail_key == next_key { + 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(); @@ -347,6 +358,7 @@ impl TranscriptOverlay { 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, @@ -370,8 +382,12 @@ impl TranscriptOverlay { // Detach the live tail before changing cells: their old count identifies the tail renderable. fn rebuild_renderables(&mut self, tail_renderable: Option>) { - self.view.renderables = - Self::render_cells(&self.cells, self.highlight_cell, self.history_state); + 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); } @@ -433,6 +449,7 @@ impl TranscriptOverlay { } 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; diff --git a/codex-rs/tui/src/pager_overlay/transcript_tests.rs b/codex-rs/tui/src/pager_overlay/transcript_tests.rs index 6af64f9c0c..ff2454abaa 100644 --- a/codex-rs/tui/src/pager_overlay/transcript_tests.rs +++ b/codex-rs/tui/src/pager_overlay/transcript_tests.rs @@ -47,17 +47,13 @@ struct HeightCountingCell { impl crate::history_cell::HistoryCell for HeightCountingCell { fn display_lines(&self, _width: u16) -> Vec> { + self.height_calls.fetch_add(1, Ordering::Relaxed); vec![Line::from("counted")] } fn raw_lines(&self) -> Vec> { vec![Line::from("counted")] } - - fn desired_transcript_height(&self, _width: u16) -> u16 { - self.height_calls.fetch_add(1, Ordering::Relaxed); - 1 - } } fn default_pager_keymap() -> crate::keymap::PagerKeymap { @@ -130,22 +126,23 @@ fn transcript_overlay_snapshots_paginated_history_states() { #[test] fn transcript_overlay_snapshot_basic() { - // Prepare a transcript overlay with a few lines let mut overlay = transcript_overlay(vec![ Arc::new(TestCell { - lines: vec![Line::from("alpha")], - }), - Arc::new(TestCell { - lines: vec![Line::from("beta")], - }), - Arc::new(TestCell { - lines: vec![Line::from("gamma")], + lines: vec![ + Line::from(vec![" indented ".green(), "styled 漢字 ガ text".bold()]), + Line::from("abc-def ghi continuation spaces and a-long-hyphenated-token"), + Line::from("+ added text").on_green(), + ], }), + Arc::new(history_cell::AgentMarkdownCell::new( + "A **styled** [link](https://example.com/transcript)".to_string(), + std::path::Path::new("/tmp"), + )), ]); - let mut term = Terminal::new(TestBackend::new(40, 10)).expect("term"); + let mut term = Terminal::new(TestBackend::new(24, 16)).expect("term"); term.draw(|f| overlay.render(f.area(), f.buffer_mut())) .expect("draw"); - assert_snapshot!(term.backend()); + assert_snapshot!(format!("{:?}", term.backend().buffer())); } #[test] @@ -177,6 +174,7 @@ fn transcript_overlay_renders_live_tail() { overlay.sync_live_tail( /*width*/ 40, Some(ActiveCellTranscriptKey { + cacheable: true, revision: 1, is_stream_continuation: false, animation_tick: None, @@ -198,6 +196,7 @@ fn transcript_overlay_preserves_live_tail_when_prepending_history() { overlay.sync_live_tail( /*width*/ 40, Some(ActiveCellTranscriptKey { + cacheable: true, revision: 1, is_stream_continuation: false, animation_tick: None, @@ -236,6 +235,7 @@ fn transcript_overlay_live_tail_preserves_semantic_web_links() { overlay.sync_live_tail( area.width, Some(ActiveCellTranscriptKey { + cacheable: true, revision: 1, is_stream_continuation: false, animation_tick: None, @@ -259,6 +259,7 @@ fn transcript_overlay_sync_live_tail_is_noop_for_identical_key() { let calls = std::cell::Cell::new(0usize); let key = ActiveCellTranscriptKey { + cacheable: true, revision: 1, is_stream_continuation: false, animation_tick: None, @@ -479,6 +480,7 @@ fn transcript_overlay_history_rebuild_preserves_only_the_live_tail() { .to_vec(), ); let key = tail.as_ref().map(|_| ActiveCellTranscriptKey { + cacheable: true, revision: 1, is_stream_continuation: false, animation_tick: None, diff --git a/codex-rs/tui/src/transcript_view.rs b/codex-rs/tui/src/transcript_view.rs new file mode 100644 index 0000000000..140db18012 --- /dev/null +++ b/codex-rs/tui/src/transcript_view.rs @@ -0,0 +1,7 @@ +//! Retained display rows and bounded caches for transcript rendering. + +mod layout; +mod text; + +pub(crate) use layout::LayoutCache; +pub(crate) use text::TextLayout; diff --git a/codex-rs/tui/src/transcript_view/layout.rs b/codex-rs/tui/src/transcript_view/layout.rs new file mode 100644 index 0000000000..26890bfe73 --- /dev/null +++ b/codex-rs/tui/src/transcript_view/layout.rs @@ -0,0 +1,113 @@ +//! Width-specific layouts retained only for recently displayed conversation entries. +//! +//! Mutable cells refresh each frame, then share that frame's layout across measurement and paint. +//! Stable cells also invalidate when animation ticks, syntax themes, or terminal colors change. + +use std::sync::Weak; + +use super::TextLayout; +use crate::history_cell::HistoryCell; +use std::sync::Arc; + +const MAX_CACHED_ENTRIES: usize = 64; +const MAX_CACHED_TEXT_BYTES: usize = 8 * 1024 * 1024; + +#[derive(Default)] +pub(crate) struct LayoutCache { + entries: Vec, + frame: u64, + render_state: Option, +} + +#[derive(PartialEq, Eq)] +struct RenderState { + theme: u64, + foreground: Option<(u8, u8, u8)>, + background: Option<(u8, u8, u8)>, + color_level: crate::terminal_palette::StdoutColorLevel, +} + +struct CachedLayout { + source: Weak, + width: u16, + separated: bool, + layout: Arc, + rendered_frame: u64, + animation_tick: Option, +} + +impl LayoutCache { + pub(crate) fn begin_frame(&mut self) { + self.frame = self.frame.wrapping_add(/*rhs*/ 1); + let state = RenderState { + theme: crate::render::highlight::syntax_theme_revision(), + foreground: crate::terminal_palette::default_fg(), + background: crate::terminal_palette::default_bg(), + color_level: crate::terminal_palette::stdout_color_level(), + }; + if self.render_state.as_ref() != Some(&state) { + self.entries.clear(); + self.render_state = Some(state); + } + } + + pub(crate) fn get( + &mut self, + cell: &Arc, + width: u16, + separated: bool, + ) -> Arc { + let source = Arc::downgrade(cell); + let animation_tick = cell.transcript_animation_tick(); + if let Some(index) = self.entries.iter().position(|entry| { + entry.width == width + && entry.separated == separated + && entry.source.ptr_eq(&source) + && (entry.rendered_frame == self.frame + || (cell.has_stable_transcript_height() + && entry.animation_tick == animation_tick)) + }) { + let layout = Arc::clone(&self.entries[index].layout); + if index + 1 != self.entries.len() { + let entry = self.entries.remove(index); + self.entries.push(entry); + } + return layout; + } + let layout = TextLayout::new(cell.transcript_hyperlink_lines(width), width); + let layout = Arc::new(if separated { + layout.with_leading_separator() + } else { + layout + }); + self.entries.retain(|entry| !entry.source.ptr_eq(&source)); + self.entries.push(CachedLayout { + source, + width, + separated, + layout: Arc::clone(&layout), + rendered_frame: self.frame, + animation_tick, + }); + self.evict(); + layout + } + + fn evict(&mut self) { + let mut bytes = self + .entries + .iter() + .map(|entry| entry.layout.byte_len) + .sum::(); + // Retain a single oversized entry rather than repeatedly laying it out while visible. + while self.entries.len() > 1 + && (self.entries.len() > MAX_CACHED_ENTRIES || bytes > MAX_CACHED_TEXT_BYTES) + { + bytes -= self.entries.remove(/*index*/ 0).layout.byte_len; + } + } +} + +#[cfg(test)] +#[path = "layout_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/transcript_view/layout_tests.rs b/codex-rs/tui/src/transcript_view/layout_tests.rs new file mode 100644 index 0000000000..614bb76f0d --- /dev/null +++ b/codex-rs/tui/src/transcript_view/layout_tests.rs @@ -0,0 +1,103 @@ +//! Cache reuse, bounded lifetime, and invalidation across frames. + +use super::*; +use pretty_assertions::assert_eq; +use ratatui::text::Line; +use std::sync::atomic::AtomicUsize; +use std::sync::atomic::Ordering; + +#[derive(Debug, Default)] +struct Cell { + renders: AtomicUsize, + tick: AtomicUsize, + mutable: bool, +} + +impl HistoryCell for Cell { + fn display_lines(&self, _width: u16) -> Vec> { + self.renders.fetch_add(/*val*/ 1, Ordering::Relaxed); + vec![format!("tick {}", self.tick.load(Ordering::Relaxed)).into()] + } + + fn raw_lines(&self) -> Vec> { + vec![] + } + + fn has_stable_transcript_height(&self) -> bool { + !self.mutable + } + + fn transcript_animation_tick(&self) -> Option { + Some(self.tick.load(Ordering::Relaxed) as u64) + } +} + +#[test] +fn layouts_refresh_for_width_animation_and_mutable_frames() { + for mutable in [false, true] { + let cell = Arc::new(Cell { + mutable, + ..Cell::default() + }); + let history = cell.clone() as Arc; + let mut cache = LayoutCache::default(); + cache.begin_frame(); + cache.get(&history, /*width*/ 20, /*separated*/ false); + cache.get(&history, /*width*/ 20, /*separated*/ false); + assert_eq!(cell.renders.load(Ordering::Relaxed), 1); + cache.begin_frame(); + cache.get(&history, /*width*/ 20, /*separated*/ false); + assert_eq!( + cell.renders.load(Ordering::Relaxed), + 1 + usize::from(mutable) + ); + cell.tick.store(/*val*/ 2, Ordering::Relaxed); + cache.begin_frame(); + let next = cache.get(&history, /*width*/ 20, /*separated*/ false); + assert_eq!(next.rows[0].line.line.to_string(), "tick 2"); + cache.get(&history, /*width*/ 10, /*separated*/ false); + assert_eq!( + cell.renders.load(Ordering::Relaxed), + 3 + usize::from(mutable) + ); + crate::terminal_palette::with_test_default_colors( + crate::terminal_probe::DefaultColors { + fg: (12, 34, 56), + bg: (65, 43, 21), + }, + || { + cache.begin_frame(); + cache.get(&history, /*width*/ 10, /*separated*/ false); + assert_eq!( + cell.renders.load(Ordering::Relaxed), + 4 + usize::from(mutable) + ); + }, + ); + } +} + +#[test] +fn recent_entries_are_reused_and_old_entries_are_evicted() { + let cells: Vec<_> = (0..100).map(|_| Arc::new(Cell::default())).collect(); + let mut cache = LayoutCache::default(); + cache.begin_frame(); + for cell in &cells { + cache.get( + &(cell.clone() as Arc), + /*width*/ 20, + /*separated*/ false, + ); + } + for index in [99, 98, 0] { + cache.get( + &(cells[index].clone() as Arc), + /*width*/ 20, + /*separated*/ false, + ); + } + assert_eq!( + [99, 98, 0].map(|index| cells[index].renders.load(Ordering::Relaxed)), + [1, 1, 2] + ); +} diff --git a/codex-rs/tui/src/transcript_view/text.rs b/codex-rs/tui/src/transcript_view/text.rs new file mode 100644 index 0000000000..874e0aa628 --- /dev/null +++ b/codex-rs/tui/src/transcript_view/text.rs @@ -0,0 +1,86 @@ +//! Retain renderer output and row offsets, preserving Ratatui wrapping for overflow lines. + +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; +use std::sync::Arc; + +#[derive(Clone)] +pub(crate) struct TextLayout { + pub(crate) rows: Vec, + pub(crate) byte_len: usize, +} + +#[derive(Clone)] +pub(crate) struct TextRow { + pub(crate) line: Arc, + pub(crate) offset: u16, +} + +impl TextLayout { + pub(crate) fn new(lines: Vec, width: u16) -> Self { + let byte_len = lines + .iter() + .flat_map(|line| &line.line.spans) + .map(|span| span.content.len()) + .sum(); + let rows = lines + .into_iter() + .flat_map(|line| { + // Most history renderers already wrap to width. Use the original paragraph for + // overflow so indentation, hyphens, and wide glyphs follow the same rules as paint. + let count = if crate::line_truncation::line_width(&line.line) + <= usize::from(width.max(/*other*/ 1)) + { + 1 + } else { + HyperlinkParagraph::new(std::slice::from_ref(&line), Style::default()) + .line_count(width.max(/*other*/ 1)) + }; + let line = Arc::new(line); + // Ratatui's paragraph scroll offset is u16, as in the existing pager. + (0..u16::try_from(count).unwrap_or(u16::MAX)).map(move |offset| TextRow { + line: Arc::clone(&line), + offset, + }) + }) + .collect(); + Self { rows, byte_len } + } + + pub(crate) fn with_leading_separator(mut self) -> Self { + if !self.rows.is_empty() { + self.rows.insert( + /*index*/ 0, + TextRow { + line: Arc::new(HyperlinkLine::from("")), + offset: 0, + }, + ); + } + self + } + + pub(crate) fn row_count(&self) -> usize { + self.rows.len() + } + + pub(crate) 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)) + .min(self.rows.len()); + let mut y = area.y; + for rows in self.rows[start..end].chunk_by(|a, b| Arc::ptr_eq(&a.line, &b.line)) { + let first = &rows[0]; + let height = rows.len() as u16; + HyperlinkParagraph::new(std::slice::from_ref(first.line.as_ref()), Style::default()) + .scroll(first.offset) + .render(Rect::new(area.x, y, area.width, height), buf); + y += height; + } + } +}