mirror of
https://github.com/openai/codex.git
synced 2026-11-13 14:46:14 +00: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:
1 parent
ee9d441777
commit
90f37e8549
7 files changed
+1359
-124
No files matched your search
@@ -1,5 +1,13 @@
|
|||||||
use crate::color::perceptual_distance;
|
use crate::color::perceptual_distance;
|
||||||
use ratatui::style::Color;
|
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.
|
/// Returns the closest color to the target color that the terminal can display.
|
||||||
pub fn best_color(target: (u8, u8, u8)) -> Color {
|
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() {
|
pub fn requery_default_colors() {
|
||||||
imp::requery_default_colors();
|
imp::requery_default_colors();
|
||||||
|
bump_palette_version();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
@@ -47,6 +56,14 @@ pub fn default_bg() -> Option<(u8, u8, u8)> {
|
|||||||
default_colors().map(|c| c.bg)
|
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)))]
|
#[cfg(all(unix, not(test)))]
|
||||||
mod imp {
|
mod imp {
|
||||||
use super::DefaultColors;
|
use super::DefaultColors;
|
||||||
|
|||||||
+30
-35
@@ -22,6 +22,7 @@ use crate::transcript_multi_click::TranscriptMultiClick;
|
|||||||
use crate::transcript_selection::TRANSCRIPT_GUTTER_COLS;
|
use crate::transcript_selection::TRANSCRIPT_GUTTER_COLS;
|
||||||
use crate::transcript_selection::TranscriptSelection;
|
use crate::transcript_selection::TranscriptSelection;
|
||||||
use crate::transcript_selection::TranscriptSelectionPoint;
|
use crate::transcript_selection::TranscriptSelectionPoint;
|
||||||
|
use crate::transcript_view_cache::TranscriptViewCache;
|
||||||
use crate::tui;
|
use crate::tui;
|
||||||
use crate::tui::TuiEvent;
|
use crate::tui::TuiEvent;
|
||||||
use crate::tui::scrolling::MouseScrollState;
|
use crate::tui::scrolling::MouseScrollState;
|
||||||
@@ -29,7 +30,6 @@ use crate::tui::scrolling::ScrollConfig;
|
|||||||
use crate::tui::scrolling::ScrollConfigOverrides;
|
use crate::tui::scrolling::ScrollConfigOverrides;
|
||||||
use crate::tui::scrolling::ScrollDirection;
|
use crate::tui::scrolling::ScrollDirection;
|
||||||
use crate::tui::scrolling::ScrollUpdate;
|
use crate::tui::scrolling::ScrollUpdate;
|
||||||
use crate::tui::scrolling::TranscriptLineMeta;
|
|
||||||
use crate::tui::scrolling::TranscriptScroll;
|
use crate::tui::scrolling::TranscriptScroll;
|
||||||
use crate::update_action::UpdateAction;
|
use crate::update_action::UpdateAction;
|
||||||
use codex_ansi_escape::ansi_escape_line;
|
use codex_ansi_escape::ansi_escape_line;
|
||||||
@@ -326,6 +326,7 @@ pub(crate) struct App {
|
|||||||
pub(crate) file_search: FileSearchManager,
|
pub(crate) file_search: FileSearchManager,
|
||||||
|
|
||||||
pub(crate) transcript_cells: Vec<Arc<dyn HistoryCell>>,
|
pub(crate) transcript_cells: Vec<Arc<dyn HistoryCell>>,
|
||||||
|
transcript_view_cache: TranscriptViewCache,
|
||||||
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
transcript_scroll: TranscriptScroll,
|
transcript_scroll: TranscriptScroll,
|
||||||
@@ -492,6 +493,7 @@ impl App {
|
|||||||
file_search,
|
file_search,
|
||||||
enhanced_keys_supported,
|
enhanced_keys_supported,
|
||||||
transcript_cells: Vec::new(),
|
transcript_cells: Vec::new(),
|
||||||
|
transcript_view_cache: TranscriptViewCache::new(),
|
||||||
transcript_scroll: TranscriptScroll::default(),
|
transcript_scroll: TranscriptScroll::default(),
|
||||||
transcript_selection: TranscriptSelection::default(),
|
transcript_selection: TranscriptSelection::default(),
|
||||||
transcript_multi_click: TranscriptMultiClick::default(),
|
transcript_multi_click: TranscriptMultiClick::default(),
|
||||||
@@ -707,10 +709,10 @@ impl App {
|
|||||||
height: max_transcript_height,
|
height: max_transcript_height,
|
||||||
};
|
};
|
||||||
|
|
||||||
let transcript =
|
self.transcript_view_cache
|
||||||
crate::transcript_render::build_wrapped_transcript_lines(cells, transcript_area.width);
|
.ensure_wrapped(cells, transcript_area.width);
|
||||||
let (lines, line_meta) = (transcript.lines, transcript.meta);
|
let total_lines = self.transcript_view_cache.lines().len();
|
||||||
if lines.is_empty() {
|
if total_lines == 0 {
|
||||||
Clear.render_ref(transcript_area, frame.buffer);
|
Clear.render_ref(transcript_area, frame.buffer);
|
||||||
self.transcript_scroll = TranscriptScroll::default();
|
self.transcript_scroll = TranscriptScroll::default();
|
||||||
self.transcript_view_top = 0;
|
self.transcript_view_top = 0;
|
||||||
@@ -718,17 +720,14 @@ impl App {
|
|||||||
return area.y;
|
return area.y;
|
||||||
}
|
}
|
||||||
|
|
||||||
let is_user_cell: Vec<bool> = cells
|
|
||||||
.iter()
|
|
||||||
.map(|c| c.as_any().is::<UserHistoryCell>())
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let total_lines = lines.len();
|
|
||||||
self.transcript_total_lines = total_lines;
|
self.transcript_total_lines = total_lines;
|
||||||
let max_visible = std::cmp::min(max_transcript_height as usize, 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 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_scroll = scroll_state;
|
||||||
self.transcript_view_top = top_offset;
|
self.transcript_view_top = top_offset;
|
||||||
|
|
||||||
@@ -762,6 +761,11 @@ impl App {
|
|||||||
height: transcript_visible_height,
|
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() {
|
for (row_index, line_index) in (top_offset..total_lines).enumerate() {
|
||||||
if row_index >= max_visible {
|
if row_index >= max_visible {
|
||||||
break;
|
break;
|
||||||
@@ -775,21 +779,8 @@ impl App {
|
|||||||
height: 1,
|
height: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
let is_user_row = line_meta
|
self.transcript_view_cache
|
||||||
.get(line_index)
|
.render_row_index_into(line_index, row_area, frame.buffer);
|
||||||
.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.apply_transcript_selection(transcript_area, frame.buffer);
|
self.apply_transcript_selection(transcript_area, frame.buffer);
|
||||||
@@ -1102,12 +1093,12 @@ impl App {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let transcript =
|
self.transcript_view_cache
|
||||||
crate::transcript_render::build_wrapped_transcript_lines(&self.transcript_cells, width);
|
.ensure_wrapped(&self.transcript_cells, width);
|
||||||
let line_meta = transcript.meta;
|
let line_meta = self.transcript_view_cache.line_meta();
|
||||||
self.transcript_scroll =
|
self.transcript_scroll =
|
||||||
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 {
|
if schedule_frame {
|
||||||
// Request a redraw; the frame scheduler coalesces bursts and clamps to 60fps.
|
// Request a redraw; the frame scheduler coalesces bursts and clamps to 60fps.
|
||||||
@@ -1127,9 +1118,10 @@ impl App {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let transcript =
|
self.transcript_view_cache
|
||||||
crate::transcript_render::build_wrapped_transcript_lines(&self.transcript_cells, width);
|
.ensure_wrapped(&self.transcript_cells, width);
|
||||||
let (lines, line_meta) = (transcript.lines, transcript.meta);
|
let lines = self.transcript_view_cache.lines();
|
||||||
|
let line_meta = self.transcript_view_cache.line_meta();
|
||||||
if lines.is_empty() || line_meta.is_empty() {
|
if lines.is_empty() || line_meta.is_empty() {
|
||||||
return;
|
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;
|
self.transcript_scroll = scroll_state;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2053,6 +2045,7 @@ mod tests {
|
|||||||
use crate::history_cell::UserHistoryCell;
|
use crate::history_cell::UserHistoryCell;
|
||||||
use crate::history_cell::new_session_info;
|
use crate::history_cell::new_session_info;
|
||||||
use crate::transcript_copy_ui::CopySelectionShortcut;
|
use crate::transcript_copy_ui::CopySelectionShortcut;
|
||||||
|
use crate::tui::scrolling::TranscriptLineMeta;
|
||||||
use codex_core::AuthManager;
|
use codex_core::AuthManager;
|
||||||
use codex_core::CodexAuth;
|
use codex_core::CodexAuth;
|
||||||
use codex_core::ConversationManager;
|
use codex_core::ConversationManager;
|
||||||
@@ -2090,6 +2083,7 @@ mod tests {
|
|||||||
active_profile: None,
|
active_profile: None,
|
||||||
file_search,
|
file_search,
|
||||||
transcript_cells: Vec::new(),
|
transcript_cells: Vec::new(),
|
||||||
|
transcript_view_cache: TranscriptViewCache::new(),
|
||||||
transcript_scroll: TranscriptScroll::default(),
|
transcript_scroll: TranscriptScroll::default(),
|
||||||
transcript_selection: TranscriptSelection::default(),
|
transcript_selection: TranscriptSelection::default(),
|
||||||
transcript_multi_click: TranscriptMultiClick::default(),
|
transcript_multi_click: TranscriptMultiClick::default(),
|
||||||
@@ -2140,6 +2134,7 @@ mod tests {
|
|||||||
active_profile: None,
|
active_profile: None,
|
||||||
file_search,
|
file_search,
|
||||||
transcript_cells: Vec::new(),
|
transcript_cells: Vec::new(),
|
||||||
|
transcript_view_cache: TranscriptViewCache::new(),
|
||||||
transcript_scroll: TranscriptScroll::default(),
|
transcript_scroll: TranscriptScroll::default(),
|
||||||
transcript_selection: TranscriptSelection::default(),
|
transcript_selection: TranscriptSelection::default(),
|
||||||
transcript_multi_click: TranscriptMultiClick::default(),
|
transcript_multi_click: TranscriptMultiClick::default(),
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ mod transcript_copy_ui;
|
|||||||
mod transcript_multi_click;
|
mod transcript_multi_click;
|
||||||
mod transcript_render;
|
mod transcript_render;
|
||||||
mod transcript_selection;
|
mod transcript_selection;
|
||||||
|
mod transcript_view_cache;
|
||||||
mod tui;
|
mod tui;
|
||||||
mod ui_consts;
|
mod ui_consts;
|
||||||
pub mod update_action;
|
pub mod update_action;
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
use crate::color::perceptual_distance;
|
use crate::color::perceptual_distance;
|
||||||
use ratatui::style::Color;
|
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.
|
/// Returns the closest color to the target color that the terminal can display.
|
||||||
pub fn best_color(target: (u8, u8, u8)) -> Color {
|
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() {
|
pub fn requery_default_colors() {
|
||||||
imp::requery_default_colors();
|
imp::requery_default_colors();
|
||||||
|
bump_palette_version();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
@@ -47,6 +56,10 @@ pub fn default_bg() -> Option<(u8, u8, u8)> {
|
|||||||
default_colors().map(|c| c.bg)
|
default_colors().map(|c| c.bg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn palette_version() -> u64 {
|
||||||
|
DEFAULT_PALETTE_VERSION.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(all(unix, not(test)))]
|
#[cfg(all(unix, not(test)))]
|
||||||
mod imp {
|
mod imp {
|
||||||
use super::DefaultColors;
|
use super::DefaultColors;
|
||||||
|
|||||||
@@ -113,9 +113,6 @@ pub(crate) fn build_wrapped_transcript_lines(
|
|||||||
cells: &[Arc<dyn HistoryCell>],
|
cells: &[Arc<dyn HistoryCell>],
|
||||||
width: u16,
|
width: u16,
|
||||||
) -> TranscriptLines {
|
) -> TranscriptLines {
|
||||||
use crate::render::line_utils::line_to_static;
|
|
||||||
use ratatui::style::Color;
|
|
||||||
|
|
||||||
if width == 0 {
|
if width == 0 {
|
||||||
return TranscriptLines {
|
return TranscriptLines {
|
||||||
lines: Vec::new(),
|
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<'_> =
|
let base_opts: crate::wrapping::RtOptions<'_> =
|
||||||
crate::wrapping::RtOptions::new(width.max(1) as usize);
|
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() {
|
for (cell_index, cell) in cells.iter().enumerate() {
|
||||||
// Start from each cell's transcript view (prefixes/indents already applied), then apply
|
append_wrapped_transcript_cell(
|
||||||
// viewport wrapping to prose while keeping preformatted content intact.
|
&mut transcript,
|
||||||
let rendered = cell.transcript_lines_with_joiners(width);
|
&mut has_emitted_lines,
|
||||||
if rendered.lines.is_empty() {
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if !cell.is_stream_continuation() {
|
let opts = if first {
|
||||||
if has_emitted_lines {
|
base_opts.clone()
|
||||||
lines.push(Line::from(""));
|
} else {
|
||||||
meta.push(TranscriptLineMeta::Spacer);
|
// For subsequent input lines within a cell, treat the "initial" indent as the cell's
|
||||||
joiner_before.push(None);
|
// subsequent indent (matches textarea wrapping expectations).
|
||||||
} else {
|
base_opts
|
||||||
has_emitted_lines = true;
|
.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
|
for (seg_idx, (wrapped_line, seg_joiner)) in
|
||||||
// viewport wrapping. This is distinct from `base_idx` (the index into the cell's input
|
wrapped.into_iter().zip(wrapped_joiners).enumerate()
|
||||||
// lines), since a single input line may wrap into multiple visual lines.
|
{
|
||||||
let mut visual_line_in_cell: usize = 0;
|
out.lines.push(line_to_static(&wrapped_line));
|
||||||
let mut first = true;
|
out.meta.push(TranscriptLineMeta::CellLine {
|
||||||
for (base_idx, base_line) in rendered.lines.iter().enumerate() {
|
cell_index,
|
||||||
// Preserve code blocks (and other preformatted text) by not applying
|
line_in_cell: visual_line_in_cell,
|
||||||
// viewport wrapping, so indentation remains meaningful for copy/paste.
|
});
|
||||||
if base_line.style.fg == Some(Color::Cyan) {
|
visual_line_in_cell = visual_line_in_cell.saturating_add(1);
|
||||||
lines.push(base_line.clone());
|
|
||||||
meta.push(TranscriptLineMeta::CellLine {
|
if seg_idx == 0 {
|
||||||
cell_index,
|
// The first wrapped segment corresponds to the original input line, so we use the
|
||||||
line_in_cell: visual_line_in_cell,
|
// cell-provided joiner (hard break vs soft break *between input lines*).
|
||||||
});
|
out.joiner_before.push(
|
||||||
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(
|
|
||||||
rendered
|
rendered
|
||||||
.joiner_before
|
.joiner_before
|
||||||
.get(base_idx)
|
.get(base_idx)
|
||||||
.cloned()
|
.cloned()
|
||||||
.unwrap_or(None),
|
.unwrap_or(None),
|
||||||
);
|
);
|
||||||
first = false;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let opts = if first {
|
|
||||||
base_opts.clone()
|
|
||||||
} else {
|
} else {
|
||||||
// For subsequent input lines within a cell, treat the "initial" indent as the
|
// Subsequent wrapped segments are soft-wrap continuations produced by viewport
|
||||||
// cell's subsequent indent (matches textarea wrapping expectations).
|
// wrapping, so we use the wrap-derived joiner.
|
||||||
base_opts
|
out.joiner_before.push(seg_joiner);
|
||||||
.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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
first = false;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
TranscriptLines {
|
first = false;
|
||||||
lines,
|
|
||||||
meta,
|
|
||||||
joiner_before,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -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).
|
||||||
Reference in new issue
Block a user