diff --git a/codex-rs/tui/src/terminal_palette.rs b/codex-rs/tui/src/terminal_palette.rs index 5c6f32cd9..6349c007e 100644 --- a/codex-rs/tui/src/terminal_palette.rs +++ b/codex-rs/tui/src/terminal_palette.rs @@ -1,5 +1,13 @@ use crate::color::perceptual_distance; use ratatui::style::Color; +use std::sync::atomic::AtomicU64; +use std::sync::atomic::Ordering; + +static DEFAULT_PALETTE_VERSION: AtomicU64 = AtomicU64::new(0); + +fn bump_palette_version() { + DEFAULT_PALETTE_VERSION.fetch_add(1, Ordering::Relaxed); +} /// Returns the closest color to the target color that the terminal can display. pub fn best_color(target: (u8, u8, u8)) -> Color { @@ -27,6 +35,7 @@ pub fn best_color(target: (u8, u8, u8)) -> Color { pub fn requery_default_colors() { imp::requery_default_colors(); + bump_palette_version(); } #[derive(Clone, Copy)] @@ -47,6 +56,14 @@ pub fn default_bg() -> Option<(u8, u8, u8)> { default_colors().map(|c| c.bg) } +/// Returns a monotonic counter that increments whenever `requery_default_colors()` runs +/// successfully so cached renderers can know when their styling assumptions (e.g. +/// background colors baked into cached transcript rows) are stale and need invalidation. +#[allow(dead_code)] +pub fn palette_version() -> u64 { + DEFAULT_PALETTE_VERSION.load(Ordering::Relaxed) +} + #[cfg(all(unix, not(test)))] mod imp { use super::DefaultColors; diff --git a/codex-rs/tui2/src/app.rs b/codex-rs/tui2/src/app.rs index 71ba06542..534e4fd2a 100644 --- a/codex-rs/tui2/src/app.rs +++ b/codex-rs/tui2/src/app.rs @@ -22,6 +22,7 @@ use crate::transcript_multi_click::TranscriptMultiClick; use crate::transcript_selection::TRANSCRIPT_GUTTER_COLS; use crate::transcript_selection::TranscriptSelection; use crate::transcript_selection::TranscriptSelectionPoint; +use crate::transcript_view_cache::TranscriptViewCache; use crate::tui; use crate::tui::TuiEvent; use crate::tui::scrolling::MouseScrollState; @@ -29,7 +30,6 @@ use crate::tui::scrolling::ScrollConfig; use crate::tui::scrolling::ScrollConfigOverrides; use crate::tui::scrolling::ScrollDirection; use crate::tui::scrolling::ScrollUpdate; -use crate::tui::scrolling::TranscriptLineMeta; use crate::tui::scrolling::TranscriptScroll; use crate::update_action::UpdateAction; use codex_ansi_escape::ansi_escape_line; @@ -326,6 +326,7 @@ pub(crate) struct App { pub(crate) file_search: FileSearchManager, pub(crate) transcript_cells: Vec>, + transcript_view_cache: TranscriptViewCache, #[allow(dead_code)] transcript_scroll: TranscriptScroll, @@ -492,6 +493,7 @@ impl App { file_search, enhanced_keys_supported, transcript_cells: Vec::new(), + transcript_view_cache: TranscriptViewCache::new(), transcript_scroll: TranscriptScroll::default(), transcript_selection: TranscriptSelection::default(), transcript_multi_click: TranscriptMultiClick::default(), @@ -707,10 +709,10 @@ impl App { height: max_transcript_height, }; - let transcript = - crate::transcript_render::build_wrapped_transcript_lines(cells, transcript_area.width); - let (lines, line_meta) = (transcript.lines, transcript.meta); - if lines.is_empty() { + self.transcript_view_cache + .ensure_wrapped(cells, transcript_area.width); + let total_lines = self.transcript_view_cache.lines().len(); + if total_lines == 0 { Clear.render_ref(transcript_area, frame.buffer); self.transcript_scroll = TranscriptScroll::default(); self.transcript_view_top = 0; @@ -718,17 +720,14 @@ impl App { return area.y; } - let is_user_cell: Vec = cells - .iter() - .map(|c| c.as_any().is::()) - .collect(); - - let total_lines = lines.len(); self.transcript_total_lines = total_lines; let max_visible = std::cmp::min(max_transcript_height as usize, total_lines); let max_start = total_lines.saturating_sub(max_visible); - let (scroll_state, top_offset) = self.transcript_scroll.resolve_top(&line_meta, max_start); + let (scroll_state, top_offset) = { + let line_meta = self.transcript_view_cache.line_meta(); + self.transcript_scroll.resolve_top(line_meta, max_start) + }; self.transcript_scroll = scroll_state; self.transcript_view_top = top_offset; @@ -762,6 +761,11 @@ impl App { height: transcript_visible_height, }; + // Cache a few viewports worth of rasterized rows so redraws during streaming can cheaply + // copy already-rendered `Cell`s instead of re-running grapheme segmentation. + self.transcript_view_cache + .set_raster_capacity(max_visible.saturating_mul(4).max(256)); + for (row_index, line_index) in (top_offset..total_lines).enumerate() { if row_index >= max_visible { break; @@ -775,21 +779,8 @@ impl App { height: 1, }; - let is_user_row = line_meta - .get(line_index) - .and_then(TranscriptLineMeta::cell_index) - .map(|cell_index| is_user_cell.get(cell_index).copied().unwrap_or(false)) - .unwrap_or(false); - if is_user_row { - let base_style = crate::style::user_message_style(); - for x in row_area.x..row_area.right() { - let cell = &mut frame.buffer[(x, y)]; - let style = cell.style().patch(base_style); - cell.set_style(style); - } - } - - lines[line_index].render_ref(row_area, frame.buffer); + self.transcript_view_cache + .render_row_index_into(line_index, row_area, frame.buffer); } self.apply_transcript_selection(transcript_area, frame.buffer); @@ -1102,12 +1093,12 @@ impl App { return; } - let transcript = - crate::transcript_render::build_wrapped_transcript_lines(&self.transcript_cells, width); - let line_meta = transcript.meta; + self.transcript_view_cache + .ensure_wrapped(&self.transcript_cells, width); + let line_meta = self.transcript_view_cache.line_meta(); self.transcript_scroll = self.transcript_scroll - .scrolled_by(delta_lines, &line_meta, visible_lines); + .scrolled_by(delta_lines, line_meta, visible_lines); if schedule_frame { // Request a redraw; the frame scheduler coalesces bursts and clamps to 60fps. @@ -1127,9 +1118,10 @@ impl App { return; } - let transcript = - crate::transcript_render::build_wrapped_transcript_lines(&self.transcript_cells, width); - let (lines, line_meta) = (transcript.lines, transcript.meta); + self.transcript_view_cache + .ensure_wrapped(&self.transcript_cells, width); + let lines = self.transcript_view_cache.lines(); + let line_meta = self.transcript_view_cache.line_meta(); if lines.is_empty() || line_meta.is_empty() { return; } @@ -1149,7 +1141,7 @@ impl App { } }; - if let Some(scroll_state) = TranscriptScroll::anchor_for(&line_meta, top_offset) { + if let Some(scroll_state) = TranscriptScroll::anchor_for(line_meta, top_offset) { self.transcript_scroll = scroll_state; } } @@ -2053,6 +2045,7 @@ mod tests { use crate::history_cell::UserHistoryCell; use crate::history_cell::new_session_info; use crate::transcript_copy_ui::CopySelectionShortcut; + use crate::tui::scrolling::TranscriptLineMeta; use codex_core::AuthManager; use codex_core::CodexAuth; use codex_core::ConversationManager; @@ -2090,6 +2083,7 @@ mod tests { active_profile: None, file_search, transcript_cells: Vec::new(), + transcript_view_cache: TranscriptViewCache::new(), transcript_scroll: TranscriptScroll::default(), transcript_selection: TranscriptSelection::default(), transcript_multi_click: TranscriptMultiClick::default(), @@ -2140,6 +2134,7 @@ mod tests { active_profile: None, file_search, transcript_cells: Vec::new(), + transcript_view_cache: TranscriptViewCache::new(), transcript_scroll: TranscriptScroll::default(), transcript_selection: TranscriptSelection::default(), transcript_multi_click: TranscriptMultiClick::default(), diff --git a/codex-rs/tui2/src/lib.rs b/codex-rs/tui2/src/lib.rs index 015c48b44..f549ab783 100644 --- a/codex-rs/tui2/src/lib.rs +++ b/codex-rs/tui2/src/lib.rs @@ -81,6 +81,7 @@ mod transcript_copy_ui; mod transcript_multi_click; mod transcript_render; mod transcript_selection; +mod transcript_view_cache; mod tui; mod ui_consts; pub mod update_action; diff --git a/codex-rs/tui2/src/terminal_palette.rs b/codex-rs/tui2/src/terminal_palette.rs index 5c6f32cd9..941cf78a2 100644 --- a/codex-rs/tui2/src/terminal_palette.rs +++ b/codex-rs/tui2/src/terminal_palette.rs @@ -1,5 +1,13 @@ use crate::color::perceptual_distance; use ratatui::style::Color; +use std::sync::atomic::AtomicU64; +use std::sync::atomic::Ordering; + +static DEFAULT_PALETTE_VERSION: AtomicU64 = AtomicU64::new(0); + +fn bump_palette_version() { + DEFAULT_PALETTE_VERSION.fetch_add(1, Ordering::Relaxed); +} /// Returns the closest color to the target color that the terminal can display. pub fn best_color(target: (u8, u8, u8)) -> Color { @@ -27,6 +35,7 @@ pub fn best_color(target: (u8, u8, u8)) -> Color { pub fn requery_default_colors() { imp::requery_default_colors(); + bump_palette_version(); } #[derive(Clone, Copy)] @@ -47,6 +56,10 @@ pub fn default_bg() -> Option<(u8, u8, u8)> { default_colors().map(|c| c.bg) } +pub fn palette_version() -> u64 { + DEFAULT_PALETTE_VERSION.load(Ordering::Relaxed) +} + #[cfg(all(unix, not(test)))] mod imp { use super::DefaultColors; diff --git a/codex-rs/tui2/src/transcript_render.rs b/codex-rs/tui2/src/transcript_render.rs index ceee03d89..729ef54bc 100644 --- a/codex-rs/tui2/src/transcript_render.rs +++ b/codex-rs/tui2/src/transcript_render.rs @@ -113,9 +113,6 @@ pub(crate) fn build_wrapped_transcript_lines( cells: &[Arc], width: u16, ) -> TranscriptLines { - use crate::render::line_utils::line_to_static; - use ratatui::style::Color; - if width == 0 { return TranscriptLines { lines: Vec::new(), @@ -124,110 +121,140 @@ pub(crate) fn build_wrapped_transcript_lines( }; } + let mut transcript = TranscriptLines { + lines: Vec::new(), + meta: Vec::new(), + joiner_before: Vec::new(), + }; + let mut has_emitted_lines = false; let base_opts: crate::wrapping::RtOptions<'_> = crate::wrapping::RtOptions::new(width.max(1) as usize); - let mut lines: Vec> = Vec::new(); - let mut meta: Vec = Vec::new(); - let mut joiner_before: Vec> = Vec::new(); - let mut has_emitted_lines = false; - for (cell_index, cell) in cells.iter().enumerate() { - // Start from each cell's transcript view (prefixes/indents already applied), then apply - // viewport wrapping to prose while keeping preformatted content intact. - let rendered = cell.transcript_lines_with_joiners(width); - if rendered.lines.is_empty() { + append_wrapped_transcript_cell( + &mut transcript, + &mut has_emitted_lines, + cell_index, + cell, + width, + &base_opts, + ); + } + + transcript +} + +/// Append a single history cell to an existing wrapped transcript. +/// +/// This is the incremental building block used by transcript caching: it applies the same +/// flattening and viewport-wrapping rules as [`build_wrapped_transcript_lines`], but for one cell +/// at a time. +/// +/// `has_emitted_lines` tracks whether the output already contains any non-spacer lines and is used +/// to decide when to insert an inter-cell spacer row. +pub(crate) fn append_wrapped_transcript_cell( + out: &mut TranscriptLines, + has_emitted_lines: &mut bool, + cell_index: usize, + cell: &Arc, + width: u16, + base_opts: &crate::wrapping::RtOptions<'_>, +) { + use crate::render::line_utils::line_to_static; + use ratatui::style::Color; + + if width == 0 { + return; + } + + // Start from each cell's transcript view (prefixes/indents already applied), then apply + // viewport wrapping to prose while keeping preformatted content intact. + let rendered = cell.transcript_lines_with_joiners(width); + if rendered.lines.is_empty() { + return; + } + + if !cell.is_stream_continuation() { + if *has_emitted_lines { + out.lines.push(Line::from("")); + out.meta.push(TranscriptLineMeta::Spacer); + out.joiner_before.push(None); + } else { + *has_emitted_lines = true; + } + } + + // `visual_line_in_cell` counts the output visual lines produced from this cell *after* any + // viewport wrapping. This is distinct from `base_idx` (the index into the cell's input + // lines), since a single input line may wrap into multiple visual lines. + let mut visual_line_in_cell: usize = 0; + let mut first = true; + for (base_idx, base_line) in rendered.lines.iter().enumerate() { + // Preserve code blocks (and other preformatted text) by not applying + // viewport wrapping, so indentation remains meaningful for copy/paste. + if base_line.style.fg == Some(Color::Cyan) { + out.lines.push(base_line.clone()); + out.meta.push(TranscriptLineMeta::CellLine { + cell_index, + line_in_cell: visual_line_in_cell, + }); + visual_line_in_cell = visual_line_in_cell.saturating_add(1); + // Preformatted lines are treated as hard breaks; we keep the cell-provided joiner + // (which is typically `None`). + out.joiner_before.push( + rendered + .joiner_before + .get(base_idx) + .cloned() + .unwrap_or(None), + ); + first = false; continue; } - if !cell.is_stream_continuation() { - if has_emitted_lines { - lines.push(Line::from("")); - meta.push(TranscriptLineMeta::Spacer); - joiner_before.push(None); - } else { - has_emitted_lines = true; - } - } + let opts = if first { + base_opts.clone() + } else { + // For subsequent input lines within a cell, treat the "initial" indent as the cell's + // subsequent indent (matches textarea wrapping expectations). + base_opts + .clone() + .initial_indent(base_opts.subsequent_indent.clone()) + }; + // `word_wrap_line_with_joiners` returns both the wrapped visual lines and, for each + // continuation segment, the exact joiner substring that should be inserted instead of a + // newline when copying as a logical line. + let (wrapped, wrapped_joiners) = + crate::wrapping::word_wrap_line_with_joiners(base_line, opts); - // `visual_line_in_cell` counts the output visual lines produced from this cell *after* any - // viewport wrapping. This is distinct from `base_idx` (the index into the cell's input - // lines), since a single input line may wrap into multiple visual lines. - let mut visual_line_in_cell: usize = 0; - let mut first = true; - for (base_idx, base_line) in rendered.lines.iter().enumerate() { - // Preserve code blocks (and other preformatted text) by not applying - // viewport wrapping, so indentation remains meaningful for copy/paste. - if base_line.style.fg == Some(Color::Cyan) { - lines.push(base_line.clone()); - meta.push(TranscriptLineMeta::CellLine { - cell_index, - line_in_cell: visual_line_in_cell, - }); - visual_line_in_cell = visual_line_in_cell.saturating_add(1); - // Preformatted lines are treated as hard breaks; we keep the cell-provided joiner - // (which is typically `None`). - joiner_before.push( + for (seg_idx, (wrapped_line, seg_joiner)) in + wrapped.into_iter().zip(wrapped_joiners).enumerate() + { + out.lines.push(line_to_static(&wrapped_line)); + out.meta.push(TranscriptLineMeta::CellLine { + cell_index, + line_in_cell: visual_line_in_cell, + }); + visual_line_in_cell = visual_line_in_cell.saturating_add(1); + + if seg_idx == 0 { + // The first wrapped segment corresponds to the original input line, so we use the + // cell-provided joiner (hard break vs soft break *between input lines*). + out.joiner_before.push( rendered .joiner_before .get(base_idx) .cloned() .unwrap_or(None), ); - first = false; - continue; - } - - let opts = if first { - base_opts.clone() } else { - // For subsequent input lines within a cell, treat the "initial" indent as the - // cell's subsequent indent (matches textarea wrapping expectations). - base_opts - .clone() - .initial_indent(base_opts.subsequent_indent.clone()) - }; - // `word_wrap_line_with_joiners` returns both the wrapped visual lines and, for each - // continuation segment, the exact joiner substring that should be inserted instead of a - // newline when copying as a logical line. - let (wrapped, wrapped_joiners) = - crate::wrapping::word_wrap_line_with_joiners(base_line, opts); - - for (seg_idx, (wrapped_line, seg_joiner)) in - wrapped.into_iter().zip(wrapped_joiners).enumerate() - { - lines.push(line_to_static(&wrapped_line)); - meta.push(TranscriptLineMeta::CellLine { - cell_index, - line_in_cell: visual_line_in_cell, - }); - visual_line_in_cell = visual_line_in_cell.saturating_add(1); - - if seg_idx == 0 { - // The first wrapped segment corresponds to the original input line, so we use - // the cell-provided joiner (hard break vs soft break *between input lines*). - joiner_before.push( - rendered - .joiner_before - .get(base_idx) - .cloned() - .unwrap_or(None), - ); - } else { - // Subsequent wrapped segments are soft-wrap continuations produced by viewport - // wrapping, so we use the wrap-derived joiner. - joiner_before.push(seg_joiner); - } + // Subsequent wrapped segments are soft-wrap continuations produced by viewport + // wrapping, so we use the wrap-derived joiner. + out.joiner_before.push(seg_joiner); } - - first = false; } - } - TranscriptLines { - lines, - meta, - joiner_before, + first = false; } } @@ -396,4 +423,56 @@ mod tests { ] ); } + + #[test] + fn append_wrapped_transcript_cell_matches_full_build() { + use ratatui::style::Color; + use ratatui::style::Style; + + let cells: Vec> = vec![ + Arc::new(FakeCell { + lines: vec![Line::from("• hello world")], + joiner_before: vec![None], + is_stream_continuation: false, + }), + // A preformatted line should not be viewport-wrapped. + Arc::new(FakeCell { + lines: vec![Line::from("• 1234567890").style(Style::default().fg(Color::Cyan))], + joiner_before: vec![None], + is_stream_continuation: false, + }), + // A stream continuation should not get an inter-cell spacer row. + Arc::new(FakeCell { + lines: vec![Line::from("• wrap me please")], + joiner_before: vec![None], + is_stream_continuation: true, + }), + ]; + + let width = 7; + let full = build_wrapped_transcript_lines(&cells, width); + + let mut out = TranscriptLines { + lines: Vec::new(), + meta: Vec::new(), + joiner_before: Vec::new(), + }; + let mut has_emitted_lines = false; + let base_opts: crate::wrapping::RtOptions<'_> = + crate::wrapping::RtOptions::new(width.max(1) as usize); + for (cell_index, cell) in cells.iter().enumerate() { + append_wrapped_transcript_cell( + &mut out, + &mut has_emitted_lines, + cell_index, + cell, + width, + &base_opts, + ); + } + + assert_eq!(out.lines, full.lines); + assert_eq!(out.meta, full.meta); + assert_eq!(out.joiner_before, full.joiner_before); + } } diff --git a/codex-rs/tui2/src/transcript_view_cache.rs b/codex-rs/tui2/src/transcript_view_cache.rs new file mode 100644 index 000000000..a32094b11 --- /dev/null +++ b/codex-rs/tui2/src/transcript_view_cache.rs @@ -0,0 +1,1033 @@ +//! Caches for transcript rendering in `codex-tui2`. +//! +//! The inline transcript view is drawn every frame. Two parts of that draw can +//! be expensive in steady state: +//! +//! - Building the *wrapped transcript* (`HistoryCell` → flattened `Line`s + +//! per-line metadata). This work is needed for rendering and for scroll math. +//! - Rendering each visible `Line` into the frame buffer. Ratatui's rendering +//! path performs grapheme segmentation and width/layout work; repeatedly +//! rerendering the same visible lines can dominate CPU during streaming. +//! +//! This module provides a pair of caches: +//! +//! - [`WrappedTranscriptCache`] memoizes the wrapped transcript for a given +//! terminal width and supports incremental append when new history cells are +//! added. +//! - [`TranscriptRasterCache`] memoizes the *rasterized* representation of +//! individual wrapped lines (a single terminal row of `Cell`s) so redraws can +//! cheaply copy already-rendered cells instead of re-running grapheme +//! segmentation for every frame. +//! +//! Notes: +//! - All caches are invalidated on width changes because wrapping and layout +//! depend on the viewport width. +//! - Rasterization is cached for base transcript content only; selection +//! highlight and copy affordances are applied after the rows are drawn, so +//! they do not pollute the cache. +//! +//! ## Algorithm overview +//! +//! At a high level, transcript rendering is a two-stage pipeline: +//! +//! 1. **Build wrapped transcript lines**: flatten the logical `HistoryCell` list into a single +//! vector of visual [`Line`]s and a parallel `meta` vector (`TranscriptLineMeta`) that maps each +//! visual line back to `(cell_index, line_in_cell)` or `Spacer`. +//! 2. **Render visible lines into the frame buffer**: draw the subset of wrapped lines that are +//! currently visible in the viewport. +//! +//! The cache mirrors that pipeline: +//! +//! - [`WrappedTranscriptCache`] memoizes stage (1) for the current `width` and supports incremental +//! append when new cells are pushed during streaming. +//! - [`TranscriptRasterCache`] memoizes stage (2) per line by caching the final rendered row +//! (`Vec`) for a given `(line_index, is_user_row)` at the current `width`. +//! +//! ### Per draw tick +//! +//! Callers typically do the following during a draw tick: +//! +//! 1. Call [`TranscriptViewCache::ensure_wrapped`] with the current `cells` and viewport `width`. +//! This may append new cells or rebuild from scratch (on width change/truncation/replacement). +//! 2. Use [`TranscriptViewCache::lines`] and [`TranscriptViewCache::line_meta`] for scroll math and +//! to resolve the visible `line_index` range. +//! 3. Configure row caching via [`TranscriptViewCache::set_raster_capacity`] (usually a few +//! viewports worth). +//! 4. For each visible `line_index`, call [`TranscriptViewCache::render_row_index_into`] to draw a +//! single terminal row. +//! +//! ### Rasterization details +//! +//! `render_row_index_into` delegates to `TranscriptRasterCache::render_row_into`: +//! +//! - On a **cache hit**, it copies cached cells into the destination buffer (no grapheme +//! segmentation, no span layout). +//! - On a **cache miss**, it renders the wrapped [`Line`] into a scratch `Buffer` with height 1, +//! copies out the resulting cells, inserts them into the cache, and then copies them into the +//! destination buffer. +//! +//! Cached rows are invalidated when: +//! - the wrapped transcript is rebuilt (line indices shift) +//! - the width changes (layout changes) +//! +//! The raster cache is bounded by `capacity` using an approximate LRU so it does not grow without +//! bound during long sessions. + +use crate::history_cell::HistoryCell; +use crate::history_cell::UserHistoryCell; +use crate::transcript_render::TranscriptLines; +use crate::tui::scrolling::TranscriptLineMeta; +use ratatui::buffer::Buffer; +use ratatui::prelude::Rect; +use ratatui::text::Line; +use ratatui::widgets::WidgetRef; +use std::collections::HashMap; +use std::collections::VecDeque; +use std::sync::Arc; + +/// Top-level cache for the inline transcript viewport. +/// +/// This combines two caches that are used together during a draw tick: +/// +/// - [`WrappedTranscriptCache`] produces the flattened wrapped transcript lines and metadata used +/// for rendering, scrolling, and selection/copy mapping. +/// - [`TranscriptRasterCache`] caches the expensive conversion from a wrapped [`Line`] into a row +/// of terminal [`ratatui::buffer::Cell`]s so repeated redraws can copy cells instead of redoing +/// grapheme segmentation. +/// +/// The caches are intentionally coupled: +/// - width changes invalidate both layers +/// - wrapped transcript rebuilds invalidate the raster cache because line indices shift +pub(crate) struct TranscriptViewCache { + /// Memoized flattened wrapped transcript content for the current width. + wrapped: WrappedTranscriptCache, + /// Per-line row rasterization cache for the current width. + raster: TranscriptRasterCache, +} + +impl TranscriptViewCache { + /// Create an empty transcript view cache. + pub(crate) fn new() -> Self { + Self { + wrapped: WrappedTranscriptCache::new(), + raster: TranscriptRasterCache::new(), + } + } + + /// Ensure the wrapped transcript cache is up to date for `cells` at `width`. + /// + /// This is the shared entrypoint for the transcript renderer and scroll math. It ensures the + /// cache reflects the current transcript and viewport width while preserving scroll/copy + /// invariants (`lines`, `meta`, and `joiner_before` remain aligned). + /// + /// Rebuild conditions: + /// - `width` changes (wrapping/layout is width-dependent) + /// - the transcript is truncated (fewer `cells` than last time), which means the previously + /// cached suffix may refer to cells that no longer exist and the cached `(cell_index, + /// line_in_cell)` mapping is no longer valid. In `tui2` today, this happens when the user + /// backtracks/forks a conversation: `app_backtrack` trims `App::transcript_cells` to preserve + /// only content up to the selected user message. + /// - the transcript is replaced (detected by a change in the first cell pointer), which + /// commonly happens when history is rotated/dropped from the front while keeping a similar + /// length (e.g. to cap history size) or when switching to a different transcript. We don't + /// currently replace the transcript list in the main render loop, but we keep this guard so + /// future history-capping or transcript-reload features can't accidentally treat a shifted + /// list as an append. In that case, treating the new list as an append would misattribute + /// line origins and break scroll anchors and selection/copy mapping. + /// + /// The raster cache is invalidated whenever the wrapped transcript is rebuilt or the width no + /// longer matches. + pub(crate) fn ensure_wrapped(&mut self, cells: &[Arc], width: u16) { + let update = self.wrapped.ensure(cells, width); + if update == WrappedTranscriptUpdate::Rebuilt { + self.raster.width = width; + self.raster.clear(); + } else if width != self.raster.width { + // Keep the invariant that raster cache always matches the active wrapped width. + self.raster.clear(); + self.raster.width = width; + } + } + + /// Return the cached flattened wrapped transcript lines. + /// + /// This is primarily used for: + /// - computing `total_lines` for scroll/viewport logic + /// - any code that needs a read-only view of the current flattened transcript + /// + /// Callers should generally avoid iterating these lines to render them in the draw hot path; + /// use [`Self::render_row_index_into`] so redraws can take advantage of the raster cache. + pub(crate) fn lines(&self) -> &[Line<'static>] { + &self.wrapped.transcript.lines + } + + /// Return per-line origin metadata aligned with [`Self::lines`]. + /// + /// This mapping is what makes scroll/selection stable as the transcript grows and reflows: + /// each visible line index can be mapped back to the originating `(cell_index, line_in_cell)` + /// pair (or to a `Spacer` row). + /// + /// Typical uses: + /// - scroll anchoring (`TranscriptScroll` resolves/anchors using this metadata) + /// - determining whether a visible row is a user-authored row (`cell_index → is_user_cell`) + pub(crate) fn line_meta(&self) -> &[TranscriptLineMeta] { + &self.wrapped.transcript.meta + } + + /// Configure the per-line raster cache capacity. + /// + /// When `capacity == 0`, raster caching is disabled and rows are rendered directly into the + /// destination buffer (but wrapped transcript caching still applies). + pub(crate) fn set_raster_capacity(&mut self, capacity: usize) { + self.raster.set_capacity(capacity); + } + + /// Whether a flattened transcript line belongs to a user-authored history cell. + /// + /// User rows apply a row-wide base style (background). This is a property of the originating + /// cell, not of the line content, so it is derived from the cached `line_meta` mapping. + pub(crate) fn is_user_row(&self, line_index: usize) -> bool { + let Some(cell_index) = self + .wrapped + .transcript + .meta + .get(line_index) + .and_then(TranscriptLineMeta::cell_index) + else { + return false; + }; + + self.wrapped + .is_user_cell + .get(cell_index) + .copied() + .unwrap_or(false) + } + + /// Render a single cached line index into the destination `buf`. + /// + /// This is the draw hot-path helper: it looks up the wrapped `Line` for `line_index`, applies + /// user-row styling if needed, and then either rasterizes the line or copies cached cells into + /// place. + /// + /// Callers are expected to have already ensured the cache via [`Self::ensure_wrapped`]. + pub(crate) fn render_row_index_into( + &mut self, + line_index: usize, + row_area: Rect, + buf: &mut Buffer, + ) { + let is_user_row = self.is_user_row(line_index); + let line = &self.wrapped.transcript.lines[line_index]; + self.raster + .render_row_into(line_index, is_user_row, line, row_area, buf); + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum WrappedTranscriptUpdate { + /// The cache already represented the provided `cells` and `width`. + Unchanged, + /// The cache appended additional cells without rebuilding. + Appended, + /// The cache rebuilt from scratch (width change, truncation, or replacement). + Rebuilt, +} + +/// Incremental memoization of wrapped transcript lines for a given width. +/// +/// This cache exists so callers doing tight-loop scroll math (mouse wheel, PgUp/PgDn) and render +/// ticks do not repeatedly rebuild the wrapped transcript (`HistoryCell` → flattened `Line`s). +/// +/// It assumes the transcript is append-mostly: when new cells arrive, they are appended to the end +/// of `cells` and existing cells do not mutate. If the underlying cell list is replaced or +/// truncated, the cache rebuilds from scratch. +struct WrappedTranscriptCache { + /// Width this cache was last built for. + width: u16, + /// Number of leading cells already incorporated into [`Self::transcript`]. + cell_count: usize, + /// Pointer identity of the first cell at the time the cache was built. + /// + /// This is a cheap replacement/truncation detector: if the caller swaps the transcript list + /// (for example, drops old cells from the front to cap history length), the length may remain + /// the same while the content shifts. In that case, we must rebuild because `(cell_index, + /// line_in_cell)` mappings and scroll anchors would otherwise become inconsistent. + first_cell_ptr: Option<*const dyn HistoryCell>, + /// Cached flattened wrapped transcript output. + /// + /// Invariant: `lines.len() == meta.len() == joiner_before.len()`. + transcript: TranscriptLines, + /// Whether the flattened transcript has emitted at least one non-spacer line. + /// + /// This is used to decide whether to insert a spacer line between non-continuation cells. + has_emitted_lines: bool, + /// Per-cell marker indicating whether a logical cell is a [`UserHistoryCell`]. + /// + /// We store this alongside the wrapped transcript so user-row styling can be derived cheaply + /// from `TranscriptLineMeta::cell_index()` without re-inspecting the cell type every frame. + is_user_cell: Vec, +} + +impl WrappedTranscriptCache { + /// Create an empty wrapped transcript cache. + /// + /// The cache is inert until the first [`Self::ensure`] call; until then it contains no + /// rendered transcript state. + fn new() -> Self { + Self { + width: 0, + cell_count: 0, + first_cell_ptr: None, + transcript: TranscriptLines { + lines: Vec::new(), + meta: Vec::new(), + joiner_before: Vec::new(), + }, + has_emitted_lines: false, + is_user_cell: Vec::new(), + } + } + + /// Ensure the wrapped transcript represents `cells` at `width`. + /// + /// This cache is intentionally single-entry and width-scoped: + /// - when `width` is unchanged and `cells` has grown, append only the new cells + /// - when `width` changes or the transcript is replaced/truncated, rebuild from scratch + /// + /// The cache assumes history cells are append-only and immutable once inserted. If existing + /// cell contents can change without changing identity, callers must treat that as a rebuild. + fn ensure(&mut self, cells: &[Arc], width: u16) -> WrappedTranscriptUpdate { + if width == 0 { + self.width = width; + self.cell_count = cells.len(); + self.first_cell_ptr = cells.first().map(Arc::as_ptr); + self.transcript.lines.clear(); + self.transcript.meta.clear(); + self.transcript.joiner_before.clear(); + self.has_emitted_lines = false; + self.is_user_cell.clear(); + return WrappedTranscriptUpdate::Rebuilt; + } + + let current_first_ptr = cells.first().map(Arc::as_ptr); + if self.width != width + || self.cell_count > cells.len() + || (self.cell_count > 0 + && current_first_ptr.is_some() + && self.first_cell_ptr != current_first_ptr) + { + self.rebuild(cells, width); + return WrappedTranscriptUpdate::Rebuilt; + } + + if self.cell_count == cells.len() { + return WrappedTranscriptUpdate::Unchanged; + } + + let old_cell_count = self.cell_count; + self.cell_count = cells.len(); + self.first_cell_ptr = current_first_ptr; + let base_opts: crate::wrapping::RtOptions<'_> = + crate::wrapping::RtOptions::new(width.max(1) as usize); + for (cell_index, cell) in cells.iter().enumerate().skip(old_cell_count) { + self.is_user_cell + .push(cell.as_any().is::()); + crate::transcript_render::append_wrapped_transcript_cell( + &mut self.transcript, + &mut self.has_emitted_lines, + cell_index, + cell, + width, + &base_opts, + ); + } + + WrappedTranscriptUpdate::Appended + } + + /// Rebuild the wrapped transcript cache from scratch. + /// + /// This is used when width changes, the transcript is truncated, or the caller provides a new + /// cell list that cannot be treated as an append to the previous one. + fn rebuild(&mut self, cells: &[Arc], width: u16) { + self.width = width; + self.cell_count = cells.len(); + self.first_cell_ptr = cells.first().map(Arc::as_ptr); + self.transcript.lines.clear(); + self.transcript.meta.clear(); + self.transcript.joiner_before.clear(); + self.has_emitted_lines = false; + self.is_user_cell.clear(); + self.is_user_cell.reserve(cells.len()); + + let base_opts: crate::wrapping::RtOptions<'_> = + crate::wrapping::RtOptions::new(width.max(1) as usize); + for (cell_index, cell) in cells.iter().enumerate() { + self.is_user_cell + .push(cell.as_any().is::()); + crate::transcript_render::append_wrapped_transcript_cell( + &mut self.transcript, + &mut self.has_emitted_lines, + cell_index, + cell, + width, + &base_opts, + ); + } + } +} + +/// Bounded cache of rasterized transcript rows. +/// +/// Each cached entry stores the final rendered [`ratatui::buffer::Cell`] values for a single +/// transcript line rendered into a 1-row buffer. +/// +/// Keying: +/// - The cache key includes `(line_index, is_user_row)`. +/// - Width is stored out-of-band and any width change clears the cache. +/// +/// Eviction: +/// - The cache uses an approximate LRU implemented with a monotonic stamp (`clock`) and an +/// `(key, stamp)` queue. +/// - This avoids per-access list manipulation while still keeping memory bounded. +struct TranscriptRasterCache { + /// Width this cache's rasterized rows were rendered at. + width: u16, + /// Maximum number of rasterized rows to retain. + capacity: usize, + /// Monotonic counter used to stamp accesses for eviction. + clock: u64, + /// Version of the terminal palette used for the cached rows. + palette_version: u64, + /// Access log used for approximate LRU eviction. + lru: VecDeque<(u64, u64)>, + /// Cached rasterized rows by key. + rows: HashMap, +} + +/// Cached raster for a single transcript line at a particular width. +#[derive(Clone)] +struct RasterizedRow { + /// The last access stamp recorded for this row. + /// + /// Eviction only removes a row when a popped `(key, stamp)` matches this value. + last_used: u64, + /// The full row of rendered cells (length is `width` at the time of rasterization). + cells: Vec, +} + +impl TranscriptRasterCache { + /// Create an empty raster cache (caching disabled until a non-zero capacity is set). + fn new() -> Self { + Self { + width: 0, + capacity: 0, + clock: 0, + palette_version: crate::terminal_palette::palette_version(), + lru: VecDeque::new(), + rows: HashMap::new(), + } + } + + /// Drop all cached rasterized rows and reset access tracking. + /// + /// This is used on width changes and when disabling caching so we don't retain stale rows or + /// unbounded memory. + fn clear(&mut self) { + self.lru.clear(); + self.rows.clear(); + self.clock = 0; + } + + /// Set the maximum number of cached rasterized rows. + /// + /// When set to 0, caching is disabled and any existing cached rows are dropped. + fn set_capacity(&mut self, capacity: usize) { + self.capacity = capacity; + self.evict_if_needed(); + } + + /// Render a single wrapped transcript line into `buf`, using a cached raster when possible. + /// + /// The cache key includes `is_user_row` because user rows apply a row-wide base style, so the + /// final raster differs even when the text spans are identical. + fn render_row_into( + &mut self, + line_index: usize, + is_user_row: bool, + line: &Line<'static>, + row_area: Rect, + buf: &mut Buffer, + ) { + if row_area.width == 0 || row_area.height == 0 { + return; + } + + let palette_version = crate::terminal_palette::palette_version(); + if palette_version != self.palette_version { + self.palette_version = palette_version; + self.clear(); + } + + if self.width != row_area.width { + self.width = row_area.width; + self.clear(); + } + + if self.capacity == 0 { + let cells = rasterize_line(line, row_area.width, is_user_row); + copy_row(row_area, buf, &cells); + return; + } + + let key = raster_key(line_index, is_user_row); + let stamp = self.bump_clock(); + if let Some(row) = self.rows.get_mut(&key) { + row.last_used = stamp; + self.lru.push_back((key, stamp)); + copy_row(row_area, buf, &row.cells); + return; + } + + let cells = rasterize_line(line, row_area.width, is_user_row); + copy_row(row_area, buf, &cells); + self.rows.insert( + key, + RasterizedRow { + last_used: stamp, + cells, + }, + ); + self.lru.push_back((key, stamp)); + self.evict_if_needed(); + } + + /// Return a new access stamp. + /// + /// The stamp is used only for equality checks ("is this the latest access for this key?") so a + /// wrapping counter is sufficient; `u64` wraparound is effectively unreachable in practice for + /// a UI cache. + fn bump_clock(&mut self) -> u64 { + let stamp = self.clock; + self.clock = self.clock.wrapping_add(1); + stamp + } + + /// Evict old cached rows until `rows.len() <= capacity`. + /// + /// The cache uses an approximate LRU: we push `(key, stamp)` on every access, and only evict a + /// row when the popped entry matches the row's current `last_used` stamp. + fn evict_if_needed(&mut self) { + if self.capacity == 0 { + self.clear(); + return; + } + while self.rows.len() > self.capacity { + let Some((key, stamp)) = self.lru.pop_front() else { + break; + }; + if self + .rows + .get(&key) + .is_some_and(|row| row.last_used == stamp) + { + self.rows.remove(&key); + } + } + } +} + +/// Compute the cache key for a rasterized transcript row. +/// +/// We key by `line_index` (not by hashing line content) because: +/// - it is effectively free in the draw loop +/// - the wrapped transcript cache defines a stable `(index → Line)` mapping until the next rebuild +/// - rebuilds clear the raster cache, so indices cannot alias across different transcripts +/// +/// `is_user_row` is included because user rows apply a row-wide base style that affects every cell. +fn raster_key(line_index: usize, is_user_row: bool) -> u64 { + (line_index as u64) << 1 | u64::from(is_user_row) +} + +/// Rasterize a single wrapped transcript [`Line`] into a 1-row cell vector. +/// +/// This is the expensive step we want to avoid repeating on every redraw: it runs Ratatui's +/// rendering for the line (including grapheme segmentation) into a scratch buffer and then copies +/// out the rendered cells. +/// +/// For user rows, we pre-fill the row with the base user style so the cached raster includes the +/// full-width background, matching the viewport behavior. +fn rasterize_line( + line: &Line<'static>, + width: u16, + is_user_row: bool, +) -> Vec { + let scratch_area = Rect::new(0, 0, width, 1); + let mut scratch = Buffer::empty(scratch_area); + + if is_user_row { + let base_style = crate::style::user_message_style(); + for x in 0..width { + scratch[(x, 0)].set_style(base_style); + } + } + + line.render_ref(scratch_area, &mut scratch); + + let mut out = Vec::with_capacity(width as usize); + for x in 0..width { + out.push(scratch[(x, 0)].clone()); + } + out +} + +/// Copy a cached rasterized row into a destination buffer at `area`. +/// +/// This is the "fast path" for redraws: once a row is cached, a redraw copies the pre-rendered +/// cells into the frame buffer without re-running span layout/grapheme segmentation. +fn copy_row(area: Rect, buf: &mut Buffer, cells: &[ratatui::buffer::Cell]) { + let y = area.y; + for (dx, cell) in cells.iter().enumerate() { + let x = area.x.saturating_add(dx as u16); + if x >= area.right() { + break; + } + buf[(x, y)] = cell.clone(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::history_cell::TranscriptLinesWithJoiners; + use crate::history_cell::UserHistoryCell; + use pretty_assertions::assert_eq; + use ratatui::style::Color; + use ratatui::style::Style; + use ratatui::style::Stylize; + use ratatui::text::Span; + use std::sync::atomic::AtomicUsize; + use std::sync::atomic::Ordering; + + #[derive(Debug)] + struct FakeCell { + lines: Vec>, + joiner_before: Vec>, + is_stream_continuation: bool, + transcript_calls: Arc, + } + + impl FakeCell { + fn new( + lines: Vec>, + joiner_before: Vec>, + is_stream_continuation: bool, + transcript_calls: Arc, + ) -> Self { + Self { + lines, + joiner_before, + is_stream_continuation, + transcript_calls, + } + } + } + + impl HistoryCell for FakeCell { + fn display_lines(&self, _width: u16) -> Vec> { + self.lines.clone() + } + + fn transcript_lines_with_joiners(&self, _width: u16) -> TranscriptLinesWithJoiners { + self.transcript_calls.fetch_add(1, Ordering::Relaxed); + TranscriptLinesWithJoiners { + lines: self.lines.clone(), + joiner_before: self.joiner_before.clone(), + } + } + + fn is_stream_continuation(&self) -> bool { + self.is_stream_continuation + } + } + + #[test] + fn wrapped_cache_matches_build_wrapped_transcript_lines() { + let calls0 = Arc::new(AtomicUsize::new(0)); + let calls1 = Arc::new(AtomicUsize::new(0)); + let calls2 = Arc::new(AtomicUsize::new(0)); + + let cells: Vec> = vec![ + // Wrapping case: expect a soft-wrap joiner for the continuation segment. + Arc::new(FakeCell::new( + vec![Line::from("• hello world")], + vec![None], + false, + calls0, + )), + // Preformatted (cyan) lines are not wrapped by the viewport wrapper. + Arc::new(FakeCell::new( + vec![Line::from(" let x = 12345;").cyan()], + vec![None], + true, + calls1, + )), + // New non-continuation cell inserts a spacer. + Arc::new(FakeCell::new( + vec![Line::from("• foo bar")], + vec![None], + false, + calls2, + )), + ]; + + let width = 8; + let expected = crate::transcript_render::build_wrapped_transcript_lines(&cells, width); + + let mut cache = TranscriptViewCache::new(); + cache.ensure_wrapped(&cells, width); + + assert_eq!(cache.lines(), expected.lines.as_slice()); + assert_eq!(cache.line_meta(), expected.meta.as_slice()); + assert_eq!( + cache.wrapped.transcript.joiner_before, + expected.joiner_before + ); + assert_eq!(cache.lines().len(), cache.line_meta().len()); + assert_eq!( + cache.lines().len(), + cache.wrapped.transcript.joiner_before.len() + ); + } + + #[test] + fn wrapped_cache_ensure_appends_only_new_cells_when_width_is_unchanged() { + let calls0 = Arc::new(AtomicUsize::new(0)); + let calls1 = Arc::new(AtomicUsize::new(0)); + let cells: Vec> = vec![ + Arc::new(FakeCell::new( + vec![Line::from("• hello world")], + vec![None], + false, + calls0.clone(), + )), + Arc::new(FakeCell::new( + vec![Line::from("• foo bar")], + vec![None], + false, + calls1.clone(), + )), + ]; + + let mut cache = TranscriptViewCache::new(); + cache.ensure_wrapped(&cells[..1], 8); + cache.ensure_wrapped(&cells, 8); + + assert_eq!(calls0.load(Ordering::Relaxed), 1); + assert_eq!(calls1.load(Ordering::Relaxed), 1); + + assert_eq!( + cache.lines(), + &[ + Line::from("• hello"), + Line::from("world"), + Line::from(""), + Line::from("• foo"), + Line::from("bar") + ] + ); + assert_eq!( + cache.line_meta(), + &[ + TranscriptLineMeta::CellLine { + cell_index: 0, + line_in_cell: 0 + }, + TranscriptLineMeta::CellLine { + cell_index: 0, + line_in_cell: 1 + }, + TranscriptLineMeta::Spacer, + TranscriptLineMeta::CellLine { + cell_index: 1, + line_in_cell: 0 + }, + TranscriptLineMeta::CellLine { + cell_index: 1, + line_in_cell: 1 + }, + ] + ); + assert_eq!( + cache.wrapped.transcript.joiner_before.as_slice(), + &[ + None, + Some(" ".to_string()), + None, + None, + Some(" ".to_string()), + ] + ); + } + + #[test] + fn wrapped_cache_ensure_rebuilds_on_width_change() { + let calls0 = Arc::new(AtomicUsize::new(0)); + let calls1 = Arc::new(AtomicUsize::new(0)); + let cells: Vec> = vec![ + Arc::new(FakeCell::new( + vec![Line::from("• hello world")], + vec![None], + false, + calls0.clone(), + )), + Arc::new(FakeCell::new( + vec![Line::from("• foo bar")], + vec![None], + false, + calls1.clone(), + )), + ]; + + let mut cache = TranscriptViewCache::new(); + cache.ensure_wrapped(&cells, 8); + cache.ensure_wrapped(&cells, 10); + + assert_eq!(calls0.load(Ordering::Relaxed), 2); + assert_eq!(calls1.load(Ordering::Relaxed), 2); + + let expected = crate::transcript_render::build_wrapped_transcript_lines(&cells, 10); + assert_eq!(cache.lines(), expected.lines.as_slice()); + assert_eq!(cache.line_meta(), expected.meta.as_slice()); + assert_eq!( + cache.wrapped.transcript.joiner_before, + expected.joiner_before + ); + } + + #[test] + fn wrapped_cache_ensure_rebuilds_on_truncation() { + let calls0 = Arc::new(AtomicUsize::new(0)); + let calls1 = Arc::new(AtomicUsize::new(0)); + let cells: Vec> = vec![ + Arc::new(FakeCell::new( + vec![Line::from("• hello world")], + vec![None], + false, + calls0.clone(), + )), + Arc::new(FakeCell::new( + vec![Line::from("• foo bar")], + vec![None], + false, + calls1.clone(), + )), + ]; + + let mut cache = TranscriptViewCache::new(); + cache.ensure_wrapped(&cells, 8); + cache.ensure_wrapped(&cells[..1], 8); + + // The second ensure is a rebuild of the truncated prefix; only the first cell is rendered. + assert_eq!(calls0.load(Ordering::Relaxed), 2); + assert_eq!(calls1.load(Ordering::Relaxed), 1); + + let expected = crate::transcript_render::build_wrapped_transcript_lines(&cells[..1], 8); + assert_eq!(cache.lines(), expected.lines.as_slice()); + assert_eq!(cache.line_meta(), expected.meta.as_slice()); + } + + #[test] + fn wrapped_cache_ensure_with_zero_width_clears_without_calling_cell_render() { + let calls = Arc::new(AtomicUsize::new(0)); + let cells: Vec> = vec![Arc::new(FakeCell::new( + vec![Line::from("• hello world")], + vec![None], + false, + calls.clone(), + ))]; + + let mut cache = TranscriptViewCache::new(); + cache.ensure_wrapped(&cells, 0); + + assert_eq!(calls.load(Ordering::Relaxed), 0); + assert_eq!(cache.lines(), &[]); + assert_eq!(cache.line_meta(), &[]); + assert_eq!( + cache.wrapped.transcript.joiner_before, + Vec::>::new() + ); + } + + #[test] + fn wrapped_cache_ensure_rebuilds_when_first_cell_pointer_changes() { + let calls_a = Arc::new(AtomicUsize::new(0)); + let calls_b = Arc::new(AtomicUsize::new(0)); + + let cell_a0: Arc = Arc::new(FakeCell::new( + vec![Line::from("• a")], + vec![None], + false, + calls_a.clone(), + )); + let cell_a1: Arc = Arc::new(FakeCell::new( + vec![Line::from("• b")], + vec![None], + false, + calls_b.clone(), + )); + + let mut cache = TranscriptViewCache::new(); + cache.ensure_wrapped(&[cell_a0.clone(), cell_a1.clone()], 10); + assert_eq!(calls_a.load(Ordering::Relaxed), 1); + assert_eq!(calls_b.load(Ordering::Relaxed), 1); + + // Replace the transcript with a different first cell but keep the length the same. + let calls_c = Arc::new(AtomicUsize::new(0)); + let cell_b0: Arc = Arc::new(FakeCell::new( + vec![Line::from("• c")], + vec![None], + false, + calls_c.clone(), + )); + + cache.ensure_wrapped(&[cell_b0.clone(), cell_a1.clone()], 10); + + // This should be treated as a replacement and rebuilt from scratch. + assert_eq!(calls_c.load(Ordering::Relaxed), 1); + assert_eq!(calls_b.load(Ordering::Relaxed), 2); + } + + #[test] + fn raster_cache_reuses_rows_and_clears_on_width_change() { + let mut cache = TranscriptViewCache::new(); + let calls = Arc::new(AtomicUsize::new(0)); + let cells: Vec> = vec![Arc::new(FakeCell::new( + vec![Line::from(vec![ + Span::from("• hello").style(Style::default().fg(Color::Magenta)), + ])], + vec![None], + false, + calls, + ))]; + + cache.ensure_wrapped(&cells, 20); + cache.set_raster_capacity(8); + + let area = Rect::new(0, 0, 10, 1); + let mut buf = Buffer::empty(area); + + cache.render_row_index_into(0, area, &mut buf); + assert_eq!(cache.raster.rows.len(), 1); + + cache.render_row_index_into(0, area, &mut buf); + assert_eq!(cache.raster.rows.len(), 1); + + let mut buf_wide = Buffer::empty(Rect::new(0, 0, 12, 1)); + cache.render_row_index_into(0, Rect::new(0, 0, 12, 1), &mut buf_wide); + assert_eq!(cache.raster.width, 12); + assert_eq!(cache.raster.rows.len(), 1); + } + + fn direct_render_cells( + line: &Line<'static>, + width: u16, + is_user_row: bool, + ) -> Vec { + let area = Rect::new(0, 0, width, 1); + let mut scratch = Buffer::empty(area); + if is_user_row { + let base_style = crate::style::user_message_style(); + for x in 0..width { + scratch[(x, 0)].set_style(base_style); + } + } + line.render_ref(area, &mut scratch); + (0..width).map(|x| scratch[(x, 0)].clone()).collect() + } + + #[test] + fn rasterize_line_matches_direct_render_for_user_and_non_user_rows() { + let width = 12; + let line = Line::from(vec!["hello".into(), " ".into(), "world".magenta()]); + + let non_user = rasterize_line(&line, width, false); + assert_eq!(non_user, direct_render_cells(&line, width, false)); + + let user = rasterize_line(&line, width, true); + assert_eq!(user, direct_render_cells(&line, width, true)); + } + + #[test] + fn raster_cache_evicts_old_rows_when_over_capacity() { + let mut cache = TranscriptViewCache::new(); + let calls = Arc::new(AtomicUsize::new(0)); + let cells: Vec> = vec![Arc::new(FakeCell::new( + vec![Line::from("first"), Line::from("second")], + vec![None, None], + false, + calls, + ))]; + + cache.ensure_wrapped(&cells, 10); + cache.set_raster_capacity(1); + + let area = Rect::new(0, 0, 10, 1); + let mut buf = Buffer::empty(area); + + cache.render_row_index_into(0, area, &mut buf); + assert_eq!(cache.raster.rows.len(), 1); + assert!(cache.raster.rows.contains_key(&raster_key(0, false))); + + cache.render_row_index_into(1, area, &mut buf); + assert_eq!(cache.raster.rows.len(), 1); + assert!(cache.raster.rows.contains_key(&raster_key(1, false))); + } + + #[test] + fn raster_cache_resets_when_palette_version_changes() { + let mut cache = TranscriptViewCache::new(); + let calls = Arc::new(AtomicUsize::new(0)); + let cells: Vec> = vec![Arc::new(FakeCell::new( + vec![Line::from("palette")], + vec![None], + false, + calls, + ))]; + + cache.ensure_wrapped(&cells, 20); + cache.set_raster_capacity(1); + + let area = Rect::new(0, 0, 10, 1); + let mut buf = Buffer::empty(area); + + cache.render_row_index_into(0, area, &mut buf); + assert_eq!(cache.raster.clock, 1); + + cache.render_row_index_into(0, area, &mut buf); + assert_eq!(cache.raster.clock, 2); + + crate::terminal_palette::requery_default_colors(); + cache.render_row_index_into(0, area, &mut buf); + assert_eq!(cache.raster.clock, 1); + } + + #[test] + fn render_row_index_into_treats_user_history_cells_as_user_rows() { + let mut cache = TranscriptViewCache::new(); + let cells: Vec> = vec![Arc::new(UserHistoryCell { + message: "hello".to_string(), + })]; + + cache.ensure_wrapped(&cells, 20); + cache.set_raster_capacity(8); + + let area = Rect::new(0, 0, 20, 1); + let mut buf = Buffer::empty(area); + + cache.render_row_index_into(0, area, &mut buf); + assert!(cache.is_user_row(0)); + assert!(cache.raster.rows.contains_key(&raster_key(0, true))); + } +} diff --git a/docs/tui2/performance-testing.md b/docs/tui2/performance-testing.md new file mode 100644 index 000000000..ea5f36288 --- /dev/null +++ b/docs/tui2/performance-testing.md @@ -0,0 +1,97 @@ +# Performance testing (`codex-tui2`) + +This doc captures a repeatable workflow for investigating `codex-tui2` performance issues +(especially high idle CPU and high CPU while streaming) and validating optimizations to the draw +hot path. + +## Scope (this round) + +The current focus is the transcript draw hot path, specifically the cost of repeatedly rendering +the same visible transcript lines via Ratatui’s `Line::render_ref` (notably grapheme segmentation +and span layout). + +The intended mitigation is a **rasterization cache**: render a wrapped transcript `Line` into a +row of `Cell`s once, cache it, and on subsequent redraws copy cached cells into the frame buffer. + +Key invariants: + +- The cache is width-scoped (invalidate on terminal width changes). +- The cache stores **base content** only; selection highlight and copy affordances are applied + after rendering, so they don’t pollute cached rows. + +## Roles + +- Human: runs `codex-tui2` in an interactive terminal (e.g. Ghostty), triggers “idle” and + “streaming” scenarios, and captures profiles. +- Assistant (or a script): reads profile output and extracts hotspots and deltas. + +## Baseline setup + +Build from a clean checkout: + +```sh +cd codex-rs +cargo build -p codex-tui2 +``` + +Run `codex-tui2` in a terminal and get a PID (macOS): + +```sh +pgrep -n codex-tui2 +``` + +Track CPU quickly while reproducing: + +```sh +top -pid "$(pgrep -n codex-tui2)" +``` + +## Capture profiles (macOS) + +Capture both an “idle” and a “streaming” profile so hotspots are not conflated: + +```sh +sample "$(pgrep -n codex-tui2)" 1 -file /tmp/tui2.idle.sample.txt +sample "$(pgrep -n codex-tui2)" 1 -file /tmp/tui2.streaming.sample.txt +``` + +For the streaming sample, trigger a response that emits many deltas (e.g. “Tell me a story”) so +the stream runs long enough to sample. + +## Quick hotspot extraction + +These `rg` patterns keep the investigation grounded in the data: + +```sh +# Buffer diff hot path (idle) +rg -n "custom_terminal::diff_buffers|diff_buffers" /tmp/tui2.*.sample.txt | head -n 80 + +# Transcript rendering hot path (streaming) +rg -n "App::render_transcript_cells|Line::render|render_spans|styled_graphemes|GraphemeCursor::next_boundary" /tmp/tui2.*.sample.txt | head -n 120 +``` + +## Rasterization-cache validation checklist + +After implementing a transcript rasterization cache, re-run the same scenarios and confirm: + +- Streaming sample shifts away from `unicode_segmentation::grapheme::GraphemeCursor::next_boundary` + stacks dominating the main thread. +- CPU during streaming drops materially vs baseline for the same streaming load. +- Idle CPU does not regress (redraw gating changes can mask rendering improvements; always measure + both idle and streaming). + +## Notes to record per run + +- Terminal size: width × height +- Scenario: idle vs streaming (prompt + approximate response length) +- CPU snapshot: `top` (directional) +- Profile excerpt: 20–50 relevant lines for the dominant stacks + +## Code pointers + +- `codex-rs/tui2/src/transcript_view_cache.rs`: wrapped transcript memoization + per-line + rasterization cache (cached `Cell` rows). +- `codex-rs/tui2/src/transcript_render.rs`: incremental helper used by the wrapped-line cache + (`append_wrapped_transcript_cell`). +- `codex-rs/tui2/src/app.rs`: wiring in `App::render_transcript_cells` (uses cached rows instead of + calling `Line::render_ref` every frame).