mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
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:
committed by
GitHub
Unverified
parent
ee9d441777
commit
90f37e8549
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user