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; + } + } +}