perf(tui2): cache transcript view rendering (#8693)

The transcript viewport draws every frame. Ratatui's Line::render_ref
does grapheme segmentation and span layout, so repeated redraws can burn
CPU during streaming even when the visible transcript hasn't changed.

Introduce TranscriptViewCache to reduce per-frame work:
- WrappedTranscriptCache memoizes flattened+wrapped transcript lines per
width, appends incrementally as new cells arrive, and rebuilds on width
change, truncation (backtrack), or transcript replacement.
- TranscriptRasterCache caches rasterized rows (Vec<Cell>) per line
index and user-row styling; redraws copy cells instead of rerendering
spans.

The caches are width-scoped and store base transcript content only;
selection highlighting and copy affordances are applied after drawing.
User rows include the row-wide base style in the cached raster.

Refactor transcript_render to expose append_wrapped_transcript_cell for
incremental building and add a test that incremental append matches the
full build.

Add docs/tui2/performance-testing.md as a playbook for macOS sample
profiles and hotspot greps.

Expand transcript_view_cache tests to cover rebuild conditions, raster
equivalence vs direct rendering, user-row caching, and eviction.

Test: cargo test -p codex-tui2
This commit is contained in:
Josh McKinney
2026-01-03 11:44:27 -08:00
committed by GitHub
Unverified
parent ee9d441777
commit 90f37e8549
7 changed files with 1359 additions and 124 deletions
+168 -89
View File
@@ -113,9 +113,6 @@ pub(crate) fn build_wrapped_transcript_lines(
cells: &[Arc<dyn HistoryCell>],
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<Line<'static>> = Vec::new();
let mut meta: Vec<TranscriptLineMeta> = Vec::new();
let mut joiner_before: Vec<Option<String>> = 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<dyn HistoryCell>,
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<Arc<dyn HistoryCell>> = 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);
}
}