mirror of
https://github.com/openai/codex.git
synced 2026-09-20 12:47:38 +00:00
Cache transcript layouts across measurement and rendering (#46720)
## Why The transcript overlay separately generated cell content for height measurement and rendering. Active cells backed by external state could also leave the live tail stale when their revision key did not change. ## What changed - Share retained text layouts between measurement and painting, and render only visible rows while preserving wrapping, styles, and hyperlinks. - Reuse stable layouts across frames, invalidating them when width, animation, syntax theme, or terminal colors change. Bound retention by entry count and text size, allowing a single oversized entry. - Refresh externally mutable cells each frame and bypass live-tail reuse when any active source is not cacheable. ## Testing Add coverage for layout reuse, eviction, invalidation, and live-tail refresh without a revision change. Expand the transcript snapshot to cover styled text, wide glyphs, wrapping, and hyperlinks. GitOrigin-RevId: 7dfb737e762f7f056a38774d956f618090809a62
This commit is contained in:
@@ -832,6 +832,8 @@ enum CodexOpTarget {
|
||||
/// it cheaply decide when to recompute that tail as the active cell evolves.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct ActiveCellTranscriptKey {
|
||||
/// External mutable sources must refresh even when the explicit revision is unchanged.
|
||||
pub(crate) cacheable: bool,
|
||||
/// Cache-busting revision for in-place updates.
|
||||
///
|
||||
/// Many active cells are updated incrementally while streaming (for example when exec groups
|
||||
@@ -1994,11 +1996,26 @@ impl ChatWidget {
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let live_sources: [Option<&dyn HistoryCell>; 2] = [
|
||||
cell.map(AsRef::as_ref),
|
||||
rate_limit_reset_hint.map(|cell| cell as &dyn HistoryCell),
|
||||
];
|
||||
Some(ActiveCellTranscriptKey {
|
||||
cacheable: live_sources
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.chain(
|
||||
self.realtime_conversation
|
||||
.pending_history_cells
|
||||
.iter()
|
||||
.chain(self.realtime_conversation.live_transcript_cells())
|
||||
.map(AsRef::as_ref),
|
||||
)
|
||||
.all(HistoryCell::has_stable_transcript_height),
|
||||
revision: self.transcript.active_cell_revision,
|
||||
is_stream_continuation: cell
|
||||
.map(|cell| cell.is_stream_continuation())
|
||||
.unwrap_or(false),
|
||||
.unwrap_or(/*default*/ false),
|
||||
animation_tick: cell
|
||||
.and_then(|cell| cell.transcript_animation_tick())
|
||||
.or_else(|| realtime_cells.find_map(|cell| cell.transcript_animation_tick())),
|
||||
|
||||
@@ -391,3 +391,32 @@ async fn external_writer_notice_offers_command_center_on_shared_servers() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn externally_mutable_active_cells_refresh_the_transcript_without_a_revision_change() {
|
||||
let (widget, _height_calls, display_calls) = widget_with_counting_cell(
|
||||
/*desired_height*/ 2, /*line_count*/ 2, /*stable_height*/ false,
|
||||
)
|
||||
.await;
|
||||
let mut overlay = crate::pager_overlay::TranscriptOverlay::new(
|
||||
Vec::new(),
|
||||
crate::keymap::RuntimeKeymap::defaults().pager,
|
||||
);
|
||||
let area = Rect::new(
|
||||
/*x*/ 0, /*y*/ 0, /*width*/ 48, /*height*/ 10,
|
||||
);
|
||||
let key = widget.active_cell_transcript_key();
|
||||
let mut frames = Vec::new();
|
||||
for _ in 0..2 {
|
||||
assert_eq!(widget.active_cell_transcript_key(), key);
|
||||
overlay.sync_live_tail(area.width, key, |width| {
|
||||
widget.active_cell_transcript_hyperlink_lines(width)
|
||||
});
|
||||
let mut buffer = Buffer::empty(area);
|
||||
overlay.render(area, &mut buffer);
|
||||
frames.push(buffer);
|
||||
}
|
||||
assert_eq!(display_calls.load(Ordering::Relaxed), 2);
|
||||
assert!(contains_text(&frames[0], "frame 1 row 0"));
|
||||
assert!(contains_text(&frames[1], "frame 2 row 0"));
|
||||
}
|
||||
|
||||
@@ -321,15 +321,6 @@ pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync + Any {
|
||||
plain_hyperlink_lines(self.transcript_lines(width))
|
||||
}
|
||||
|
||||
fn desired_transcript_height(&self, width: u16) -> u16 {
|
||||
let lines = visible_lines(self.transcript_hyperlink_lines(width));
|
||||
Paragraph::new(Text::from(lines))
|
||||
.wrap(Wrap { trim: false })
|
||||
.line_count(width)
|
||||
.try_into()
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Whether the cached transcript layout remains valid across later frames.
|
||||
///
|
||||
/// Cells backed by external state should return `false` so the shared viewport refreshes
|
||||
|
||||
@@ -220,6 +220,7 @@ mod token_usage;
|
||||
mod tool_output;
|
||||
mod tooltips;
|
||||
mod transcript_reflow;
|
||||
mod transcript_view;
|
||||
mod tui;
|
||||
mod ui_consts;
|
||||
mod unarchive_prompt;
|
||||
|
||||
@@ -18,6 +18,11 @@
|
||||
mod scrolling;
|
||||
mod transcript;
|
||||
|
||||
pub(crate) use transcript::TranscriptOverlay;
|
||||
|
||||
use std::io::Result;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::chatwidget::ActiveCellTranscriptKey;
|
||||
use crate::history_cell::HistoryCell;
|
||||
use crate::history_cell::SessionInfoCell;
|
||||
@@ -48,9 +53,6 @@ use ratatui::widgets::Wrap;
|
||||
use scrolling::CellRenderable;
|
||||
use scrolling::HyperlinkLinesRenderable;
|
||||
use scrolling::render_offset_content;
|
||||
use std::io::Result;
|
||||
use std::sync::Arc;
|
||||
pub(crate) use transcript::TranscriptOverlay;
|
||||
|
||||
pub(crate) enum Overlay {
|
||||
Transcript(TranscriptOverlay),
|
||||
|
||||
@@ -14,17 +14,13 @@ struct MeasuredCell {
|
||||
|
||||
impl HistoryCell for MeasuredCell {
|
||||
fn display_lines(&self, _width: u16) -> Vec<Line<'static>> {
|
||||
self.measurements.fetch_add(1, Ordering::Relaxed);
|
||||
vec!["history".into()]
|
||||
}
|
||||
|
||||
fn raw_lines(&self) -> Vec<Line<'static>> {
|
||||
vec!["history".into()]
|
||||
}
|
||||
|
||||
fn desired_transcript_height(&self, _width: u16) -> u16 {
|
||||
self.measurements.fetch_add(1, Ordering::Relaxed);
|
||||
1
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -60,8 +56,6 @@ fn moving_highlight_preserves_unaffected_height_caches() {
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let mut expected = vec![1; cells.len()];
|
||||
expected[28] = 3;
|
||||
expected[30] = 3;
|
||||
assert_eq!(measurements(), expected);
|
||||
|
||||
// Width changes still invalidate every cached height.
|
||||
@@ -93,6 +87,7 @@ fn moving_highlight_matches_full_rebuild_with_live_tail() {
|
||||
overlay.sync_live_tail(
|
||||
/*width*/ 40,
|
||||
Some(ActiveCellTranscriptKey {
|
||||
cacheable: true,
|
||||
revision: 1,
|
||||
is_stream_continuation: false,
|
||||
animation_tick: None,
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
//! Viewport-aware transcript rendering and the fallback for generic pager content.
|
||||
|
||||
use crate::transcript_view::LayoutCache;
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::history_cell::HistoryCell;
|
||||
@@ -17,6 +20,7 @@ use ratatui::widgets::Widget;
|
||||
pub(super) struct CellRenderable {
|
||||
pub(super) cell: Arc<dyn HistoryCell>,
|
||||
pub(super) highlighted: bool,
|
||||
pub(super) cache: Rc<RefCell<LayoutCache>>,
|
||||
}
|
||||
|
||||
impl Renderable for CellRenderable {
|
||||
@@ -26,7 +30,10 @@ impl Renderable for CellRenderable {
|
||||
|
||||
/// Scroll visible text and hyperlink metadata together without rendering hidden rows.
|
||||
fn render_scrolled(&self, area: Rect, buf: &mut Buffer, scroll_offset: u16) -> bool {
|
||||
let hyperlink_lines = self.cell.transcript_hyperlink_lines(area.width);
|
||||
let layout = self
|
||||
.cache
|
||||
.borrow_mut()
|
||||
.get(&self.cell, area.width, /*separated*/ false);
|
||||
let style = if self.cell.as_any().is::<UserHistoryCell>() {
|
||||
if self.highlighted {
|
||||
user_message_style().reversed()
|
||||
@@ -36,14 +43,18 @@ impl Renderable for CellRenderable {
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
HyperlinkParagraph::new(&hyperlink_lines, style)
|
||||
.scroll(scroll_offset)
|
||||
.render(area, buf);
|
||||
buf.set_style(area, style);
|
||||
layout.render(area, buf, usize::from(scroll_offset));
|
||||
true
|
||||
}
|
||||
|
||||
fn desired_height(&self, width: u16) -> u16 {
|
||||
self.cell.desired_transcript_height(width)
|
||||
self.cache
|
||||
.borrow_mut()
|
||||
.get(&self.cell, width, /*separated*/ false)
|
||||
.row_count()
|
||||
.try_into()
|
||||
.unwrap_or(u16::MAX)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -96,6 +96,7 @@ fn scrolled_test_renderables(lines: &[HyperlinkLine]) -> Vec<(&'static str, Box<
|
||||
(
|
||||
"uncached history cell",
|
||||
Box::new(CellRenderable {
|
||||
cache: Default::default(),
|
||||
cell: cell.clone(),
|
||||
highlighted: false,
|
||||
}),
|
||||
@@ -103,6 +104,7 @@ fn scrolled_test_renderables(lines: &[HyperlinkLine]) -> Vec<(&'static str, Box<
|
||||
(
|
||||
"highlighted cached user history cell",
|
||||
Box::new(CachedRenderable::new(CellRenderable {
|
||||
cache: Default::default(),
|
||||
cell: user,
|
||||
highlighted: true,
|
||||
})),
|
||||
@@ -111,6 +113,7 @@ fn scrolled_test_renderables(lines: &[HyperlinkLine]) -> Vec<(&'static str, Box<
|
||||
"inset cached history cell",
|
||||
Box::new(InsetRenderable::new(
|
||||
Box::new(CachedRenderable::new(CellRenderable {
|
||||
cache: Default::default(),
|
||||
cell,
|
||||
highlighted: false,
|
||||
})) as Box<dyn Renderable>,
|
||||
|
||||
@@ -1,14 +1,57 @@
|
||||
---
|
||||
source: tui/src/pager_overlay/transcript_tests.rs
|
||||
expression: term.backend()
|
||||
expression: "format!(\"{:?}\", term.backend().buffer())"
|
||||
---
|
||||
"/ T R A N S C R I P T / / / / / / / / / "
|
||||
"alpha "
|
||||
" "
|
||||
"beta "
|
||||
" "
|
||||
"gamma "
|
||||
"───────────────────────────────── 100% ─"
|
||||
" ↑/↓ to scroll pgup/pgdn to page hom"
|
||||
" q close esc to edit prev "
|
||||
" "
|
||||
Buffer {
|
||||
area: Rect { x: 0, y: 0, width: 24, height: 16 },
|
||||
content: [
|
||||
"/ T R A N S C R I P T / ",
|
||||
" indented styled 漢字", // hidden by multi-width symbols: [(21, " "), (23, " ")]
|
||||
"ガ text ", // hidden by multi-width symbols: [(1, " ")]
|
||||
"abc-def ghi ",
|
||||
"continuation spaces and ",
|
||||
"a-long-hyphenated-token ",
|
||||
"+ added text ",
|
||||
" ",
|
||||
"• A styled ]8;;https://example.com/transcriptl]8;;]8;;https://example.com/transcripti]8;;]8;;https://example.com/transcriptn]8;;]8;;https://example.com/transcriptk]8;; ",
|
||||
" (]8;;https://example.com/transcripth]8;;]8;;https://example.com/transcriptt]8;;]8;;https://example.com/transcriptt]8;;]8;;https://example.com/transcriptp]8;;]8;;https://example.com/transcripts]8;;]8;;https://example.com/transcript:]8;;]8;;https://example.com/transcript/]8;;]8;;https://example.com/transcript/]8;;]8;;https://example.com/transcripte]8;;]8;;https://example.com/transcriptx]8;;]8;;https://example.com/transcripta]8;;]8;;https://example.com/transcriptm]8;;]8;;https://example.com/transcriptp]8;;]8;;https://example.com/transcriptl]8;;]8;;https://example.com/transcripte]8;;]8;;https://example.com/transcript.]8;;]8;;https://example.com/transcriptc]8;;]8;;https://example.com/transcripto]8;;]8;;https://example.com/transcriptm]8;;]8;;https://example.com/transcript/]8;;]8;;https://example.com/transcriptt]8;;",
|
||||
"]8;;https://example.com/transcriptr]8;;]8;;https://example.com/transcripta]8;;]8;;https://example.com/transcriptn]8;;]8;;https://example.com/transcripts]8;;]8;;https://example.com/transcriptc]8;;]8;;https://example.com/transcriptr]8;;]8;;https://example.com/transcripti]8;;]8;;https://example.com/transcriptp]8;;]8;;https://example.com/transcriptt]8;;) ",
|
||||
"~ ",
|
||||
"───────────────── 100% ─",
|
||||
" ↑/↓ to scroll pgup/pg",
|
||||
" q close esc to edit p",
|
||||
" ",
|
||||
],
|
||||
styles: [
|
||||
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
|
||||
x: 0, y: 1, fg: Green, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 13, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
|
||||
x: 21, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 22, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
|
||||
x: 23, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
|
||||
x: 1, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 2, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
|
||||
x: 7, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 0, y: 6, fg: Reset, bg: Green, underline: Reset, modifier: NONE,
|
||||
x: 12, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 0, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
|
||||
x: 2, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 4, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
|
||||
x: 10, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 11, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: UNDERLINED,
|
||||
x: 15, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 3, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: UNDERLINED,
|
||||
x: 9, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 0, y: 12, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
|
||||
x: 1, y: 13, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
|
||||
x: 4, y: 13, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
|
||||
x: 17, y: 13, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
|
||||
x: 0, y: 14, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
|
||||
x: 1, y: 14, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
|
||||
x: 2, y: 14, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
|
||||
x: 11, y: 14, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
|
||||
x: 14, y: 14, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
|
||||
x: 0, y: 15, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
//! Detailed transcript overlay over committed history and the current live tail.
|
||||
|
||||
use super::*;
|
||||
use crate::transcript_view::LayoutCache;
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
pub(crate) struct TranscriptOverlay {
|
||||
/// Pager UI state and the renderables currently displayed.
|
||||
@@ -8,6 +11,7 @@ pub(crate) struct TranscriptOverlay {
|
||||
/// The invariant is that `view.renderables` is `render_cells(cells)` plus an optional trailing
|
||||
/// live-tail renderable appended after the committed cells.
|
||||
view: PagerView,
|
||||
cache: Rc<RefCell<LayoutCache>>,
|
||||
/// Committed transcript cells (does not include the live tail).
|
||||
cells: Vec<Arc<dyn HistoryCell>>,
|
||||
highlight_cell: Option<usize>,
|
||||
@@ -38,9 +42,11 @@ impl TranscriptOverlay {
|
||||
/// This overlay does not own the "active cell"; callers may optionally append a live tail via
|
||||
/// `sync_live_tail` during draws to reflect in-flight activity.
|
||||
pub(crate) fn new(transcript_cells: Vec<Arc<dyn HistoryCell>>, keymap: PagerKeymap) -> Self {
|
||||
let cache = Rc::new(RefCell::new(LayoutCache::default()));
|
||||
Self {
|
||||
view: PagerView::new(
|
||||
Self::render_cells(
|
||||
&cache,
|
||||
&transcript_cells,
|
||||
/*highlight_cell*/ None,
|
||||
TranscriptHistoryState::Idle,
|
||||
@@ -49,6 +55,7 @@ impl TranscriptOverlay {
|
||||
usize::MAX,
|
||||
keymap,
|
||||
),
|
||||
cache,
|
||||
cells: transcript_cells,
|
||||
highlight_cell: None,
|
||||
live_tail_key: None,
|
||||
@@ -84,6 +91,7 @@ impl TranscriptOverlay {
|
||||
}
|
||||
|
||||
fn render_cells(
|
||||
cache: &Rc<RefCell<LayoutCache>>,
|
||||
cells: &[Arc<dyn HistoryCell>],
|
||||
highlight_cell: Option<usize>,
|
||||
history_state: TranscriptHistoryState,
|
||||
@@ -91,12 +99,13 @@ impl TranscriptOverlay {
|
||||
cells
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, cell)| Self::render_cell(cell, i, highlight_cell, history_state))
|
||||
.map(|(i, cell)| Self::render_cell(cache, cell, i, highlight_cell, history_state))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Build the renderable for a committed cell, caching its height when the cell is stable.
|
||||
fn render_cell(
|
||||
cache: &Rc<RefCell<LayoutCache>>,
|
||||
cell: &Arc<dyn HistoryCell>,
|
||||
index: usize,
|
||||
highlight_cell: Option<usize>,
|
||||
@@ -108,6 +117,7 @@ impl TranscriptOverlay {
|
||||
return Box::new(Line::from(placeholder).dim());
|
||||
}
|
||||
let cell_renderable = CellRenderable {
|
||||
cache: Rc::clone(cache),
|
||||
cell: cell.clone(),
|
||||
highlighted: highlight_cell == Some(index),
|
||||
};
|
||||
@@ -142,6 +152,7 @@ impl TranscriptOverlay {
|
||||
let had_prior_cells = !self.cells.is_empty();
|
||||
let tail_renderable = self.take_live_tail_renderable();
|
||||
let cell_renderable = Self::render_cell(
|
||||
&self.cache,
|
||||
&cell,
|
||||
self.cells.len(),
|
||||
self.highlight_cell,
|
||||
@@ -315,7 +326,7 @@ impl TranscriptOverlay {
|
||||
animation_tick: key.animation_tick,
|
||||
});
|
||||
|
||||
if self.live_tail_key == next_key {
|
||||
if active_key.is_some_and(|key| key.cacheable) && self.live_tail_key == next_key {
|
||||
return;
|
||||
}
|
||||
let follow_bottom = self.view.is_scrolled_to_bottom();
|
||||
@@ -347,6 +358,7 @@ impl TranscriptOverlay {
|
||||
for index in [previous, cell].into_iter().flatten() {
|
||||
if let Some(history_cell) = self.cells.get(index) {
|
||||
self.view.renderables[index] = Self::render_cell(
|
||||
&self.cache,
|
||||
history_cell,
|
||||
index,
|
||||
self.highlight_cell,
|
||||
@@ -370,8 +382,12 @@ impl TranscriptOverlay {
|
||||
|
||||
// Detach the live tail before changing cells: their old count identifies the tail renderable.
|
||||
fn rebuild_renderables(&mut self, tail_renderable: Option<Box<dyn Renderable>>) {
|
||||
self.view.renderables =
|
||||
Self::render_cells(&self.cells, self.highlight_cell, self.history_state);
|
||||
self.view.renderables = Self::render_cells(
|
||||
&self.cache,
|
||||
&self.cells,
|
||||
self.highlight_cell,
|
||||
self.history_state,
|
||||
);
|
||||
if let Some(tail) = tail_renderable {
|
||||
self.view.renderables.push(tail);
|
||||
}
|
||||
@@ -433,6 +449,7 @@ impl TranscriptOverlay {
|
||||
}
|
||||
|
||||
pub(crate) fn render(&mut self, area: Rect, buf: &mut Buffer) {
|
||||
self.cache.borrow_mut().begin_frame();
|
||||
// Preserve following the tail before the composer changes the available height.
|
||||
if self.view.is_scrolled_to_bottom() {
|
||||
self.view.scroll_offset = usize::MAX;
|
||||
|
||||
@@ -47,17 +47,13 @@ struct HeightCountingCell {
|
||||
|
||||
impl crate::history_cell::HistoryCell for HeightCountingCell {
|
||||
fn display_lines(&self, _width: u16) -> Vec<Line<'static>> {
|
||||
self.height_calls.fetch_add(1, Ordering::Relaxed);
|
||||
vec![Line::from("counted")]
|
||||
}
|
||||
|
||||
fn raw_lines(&self) -> Vec<Line<'static>> {
|
||||
vec![Line::from("counted")]
|
||||
}
|
||||
|
||||
fn desired_transcript_height(&self, _width: u16) -> u16 {
|
||||
self.height_calls.fetch_add(1, Ordering::Relaxed);
|
||||
1
|
||||
}
|
||||
}
|
||||
|
||||
fn default_pager_keymap() -> crate::keymap::PagerKeymap {
|
||||
@@ -130,22 +126,23 @@ fn transcript_overlay_snapshots_paginated_history_states() {
|
||||
|
||||
#[test]
|
||||
fn transcript_overlay_snapshot_basic() {
|
||||
// Prepare a transcript overlay with a few lines
|
||||
let mut overlay = transcript_overlay(vec![
|
||||
Arc::new(TestCell {
|
||||
lines: vec![Line::from("alpha")],
|
||||
}),
|
||||
Arc::new(TestCell {
|
||||
lines: vec![Line::from("beta")],
|
||||
}),
|
||||
Arc::new(TestCell {
|
||||
lines: vec![Line::from("gamma")],
|
||||
lines: vec![
|
||||
Line::from(vec![" indented ".green(), "styled 漢字 ガ text".bold()]),
|
||||
Line::from("abc-def ghi continuation spaces and a-long-hyphenated-token"),
|
||||
Line::from("+ added text").on_green(),
|
||||
],
|
||||
}),
|
||||
Arc::new(history_cell::AgentMarkdownCell::new(
|
||||
"A **styled** [link](https://example.com/transcript)".to_string(),
|
||||
std::path::Path::new("/tmp"),
|
||||
)),
|
||||
]);
|
||||
let mut term = Terminal::new(TestBackend::new(40, 10)).expect("term");
|
||||
let mut term = Terminal::new(TestBackend::new(24, 16)).expect("term");
|
||||
term.draw(|f| overlay.render(f.area(), f.buffer_mut()))
|
||||
.expect("draw");
|
||||
assert_snapshot!(term.backend());
|
||||
assert_snapshot!(format!("{:?}", term.backend().buffer()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -177,6 +174,7 @@ fn transcript_overlay_renders_live_tail() {
|
||||
overlay.sync_live_tail(
|
||||
/*width*/ 40,
|
||||
Some(ActiveCellTranscriptKey {
|
||||
cacheable: true,
|
||||
revision: 1,
|
||||
is_stream_continuation: false,
|
||||
animation_tick: None,
|
||||
@@ -198,6 +196,7 @@ fn transcript_overlay_preserves_live_tail_when_prepending_history() {
|
||||
overlay.sync_live_tail(
|
||||
/*width*/ 40,
|
||||
Some(ActiveCellTranscriptKey {
|
||||
cacheable: true,
|
||||
revision: 1,
|
||||
is_stream_continuation: false,
|
||||
animation_tick: None,
|
||||
@@ -236,6 +235,7 @@ fn transcript_overlay_live_tail_preserves_semantic_web_links() {
|
||||
overlay.sync_live_tail(
|
||||
area.width,
|
||||
Some(ActiveCellTranscriptKey {
|
||||
cacheable: true,
|
||||
revision: 1,
|
||||
is_stream_continuation: false,
|
||||
animation_tick: None,
|
||||
@@ -259,6 +259,7 @@ fn transcript_overlay_sync_live_tail_is_noop_for_identical_key() {
|
||||
|
||||
let calls = std::cell::Cell::new(0usize);
|
||||
let key = ActiveCellTranscriptKey {
|
||||
cacheable: true,
|
||||
revision: 1,
|
||||
is_stream_continuation: false,
|
||||
animation_tick: None,
|
||||
@@ -479,6 +480,7 @@ fn transcript_overlay_history_rebuild_preserves_only_the_live_tail() {
|
||||
.to_vec(),
|
||||
);
|
||||
let key = tail.as_ref().map(|_| ActiveCellTranscriptKey {
|
||||
cacheable: true,
|
||||
revision: 1,
|
||||
is_stream_continuation: false,
|
||||
animation_tick: None,
|
||||
|
||||
7
codex-rs/tui/src/transcript_view.rs
Normal file
7
codex-rs/tui/src/transcript_view.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
//! Retained display rows and bounded caches for transcript rendering.
|
||||
|
||||
mod layout;
|
||||
mod text;
|
||||
|
||||
pub(crate) use layout::LayoutCache;
|
||||
pub(crate) use text::TextLayout;
|
||||
113
codex-rs/tui/src/transcript_view/layout.rs
Normal file
113
codex-rs/tui/src/transcript_view/layout.rs
Normal file
@@ -0,0 +1,113 @@
|
||||
//! Width-specific layouts retained only for recently displayed conversation entries.
|
||||
//!
|
||||
//! Mutable cells refresh each frame, then share that frame's layout across measurement and paint.
|
||||
//! Stable cells also invalidate when animation ticks, syntax themes, or terminal colors change.
|
||||
|
||||
use std::sync::Weak;
|
||||
|
||||
use super::TextLayout;
|
||||
use crate::history_cell::HistoryCell;
|
||||
use std::sync::Arc;
|
||||
|
||||
const MAX_CACHED_ENTRIES: usize = 64;
|
||||
const MAX_CACHED_TEXT_BYTES: usize = 8 * 1024 * 1024;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct LayoutCache {
|
||||
entries: Vec<CachedLayout>,
|
||||
frame: u64,
|
||||
render_state: Option<RenderState>,
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Eq)]
|
||||
struct RenderState {
|
||||
theme: u64,
|
||||
foreground: Option<(u8, u8, u8)>,
|
||||
background: Option<(u8, u8, u8)>,
|
||||
color_level: crate::terminal_palette::StdoutColorLevel,
|
||||
}
|
||||
|
||||
struct CachedLayout {
|
||||
source: Weak<dyn HistoryCell>,
|
||||
width: u16,
|
||||
separated: bool,
|
||||
layout: Arc<TextLayout>,
|
||||
rendered_frame: u64,
|
||||
animation_tick: Option<u64>,
|
||||
}
|
||||
|
||||
impl LayoutCache {
|
||||
pub(crate) fn begin_frame(&mut self) {
|
||||
self.frame = self.frame.wrapping_add(/*rhs*/ 1);
|
||||
let state = RenderState {
|
||||
theme: crate::render::highlight::syntax_theme_revision(),
|
||||
foreground: crate::terminal_palette::default_fg(),
|
||||
background: crate::terminal_palette::default_bg(),
|
||||
color_level: crate::terminal_palette::stdout_color_level(),
|
||||
};
|
||||
if self.render_state.as_ref() != Some(&state) {
|
||||
self.entries.clear();
|
||||
self.render_state = Some(state);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn get(
|
||||
&mut self,
|
||||
cell: &Arc<dyn HistoryCell>,
|
||||
width: u16,
|
||||
separated: bool,
|
||||
) -> Arc<TextLayout> {
|
||||
let source = Arc::downgrade(cell);
|
||||
let animation_tick = cell.transcript_animation_tick();
|
||||
if let Some(index) = self.entries.iter().position(|entry| {
|
||||
entry.width == width
|
||||
&& entry.separated == separated
|
||||
&& entry.source.ptr_eq(&source)
|
||||
&& (entry.rendered_frame == self.frame
|
||||
|| (cell.has_stable_transcript_height()
|
||||
&& entry.animation_tick == animation_tick))
|
||||
}) {
|
||||
let layout = Arc::clone(&self.entries[index].layout);
|
||||
if index + 1 != self.entries.len() {
|
||||
let entry = self.entries.remove(index);
|
||||
self.entries.push(entry);
|
||||
}
|
||||
return layout;
|
||||
}
|
||||
let layout = TextLayout::new(cell.transcript_hyperlink_lines(width), width);
|
||||
let layout = Arc::new(if separated {
|
||||
layout.with_leading_separator()
|
||||
} else {
|
||||
layout
|
||||
});
|
||||
self.entries.retain(|entry| !entry.source.ptr_eq(&source));
|
||||
self.entries.push(CachedLayout {
|
||||
source,
|
||||
width,
|
||||
separated,
|
||||
layout: Arc::clone(&layout),
|
||||
rendered_frame: self.frame,
|
||||
animation_tick,
|
||||
});
|
||||
self.evict();
|
||||
layout
|
||||
}
|
||||
|
||||
fn evict(&mut self) {
|
||||
let mut bytes = self
|
||||
.entries
|
||||
.iter()
|
||||
.map(|entry| entry.layout.byte_len)
|
||||
.sum::<usize>();
|
||||
// Retain a single oversized entry rather than repeatedly laying it out while visible.
|
||||
while self.entries.len() > 1
|
||||
&& (self.entries.len() > MAX_CACHED_ENTRIES || bytes > MAX_CACHED_TEXT_BYTES)
|
||||
{
|
||||
bytes -= self.entries.remove(/*index*/ 0).layout.byte_len;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "layout_tests.rs"]
|
||||
mod tests;
|
||||
103
codex-rs/tui/src/transcript_view/layout_tests.rs
Normal file
103
codex-rs/tui/src/transcript_view/layout_tests.rs
Normal file
@@ -0,0 +1,103 @@
|
||||
//! Cache reuse, bounded lifetime, and invalidation across frames.
|
||||
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use ratatui::text::Line;
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct Cell {
|
||||
renders: AtomicUsize,
|
||||
tick: AtomicUsize,
|
||||
mutable: bool,
|
||||
}
|
||||
|
||||
impl HistoryCell for Cell {
|
||||
fn display_lines(&self, _width: u16) -> Vec<Line<'static>> {
|
||||
self.renders.fetch_add(/*val*/ 1, Ordering::Relaxed);
|
||||
vec![format!("tick {}", self.tick.load(Ordering::Relaxed)).into()]
|
||||
}
|
||||
|
||||
fn raw_lines(&self) -> Vec<Line<'static>> {
|
||||
vec![]
|
||||
}
|
||||
|
||||
fn has_stable_transcript_height(&self) -> bool {
|
||||
!self.mutable
|
||||
}
|
||||
|
||||
fn transcript_animation_tick(&self) -> Option<u64> {
|
||||
Some(self.tick.load(Ordering::Relaxed) as u64)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layouts_refresh_for_width_animation_and_mutable_frames() {
|
||||
for mutable in [false, true] {
|
||||
let cell = Arc::new(Cell {
|
||||
mutable,
|
||||
..Cell::default()
|
||||
});
|
||||
let history = cell.clone() as Arc<dyn HistoryCell>;
|
||||
let mut cache = LayoutCache::default();
|
||||
cache.begin_frame();
|
||||
cache.get(&history, /*width*/ 20, /*separated*/ false);
|
||||
cache.get(&history, /*width*/ 20, /*separated*/ false);
|
||||
assert_eq!(cell.renders.load(Ordering::Relaxed), 1);
|
||||
cache.begin_frame();
|
||||
cache.get(&history, /*width*/ 20, /*separated*/ false);
|
||||
assert_eq!(
|
||||
cell.renders.load(Ordering::Relaxed),
|
||||
1 + usize::from(mutable)
|
||||
);
|
||||
cell.tick.store(/*val*/ 2, Ordering::Relaxed);
|
||||
cache.begin_frame();
|
||||
let next = cache.get(&history, /*width*/ 20, /*separated*/ false);
|
||||
assert_eq!(next.rows[0].line.line.to_string(), "tick 2");
|
||||
cache.get(&history, /*width*/ 10, /*separated*/ false);
|
||||
assert_eq!(
|
||||
cell.renders.load(Ordering::Relaxed),
|
||||
3 + usize::from(mutable)
|
||||
);
|
||||
crate::terminal_palette::with_test_default_colors(
|
||||
crate::terminal_probe::DefaultColors {
|
||||
fg: (12, 34, 56),
|
||||
bg: (65, 43, 21),
|
||||
},
|
||||
|| {
|
||||
cache.begin_frame();
|
||||
cache.get(&history, /*width*/ 10, /*separated*/ false);
|
||||
assert_eq!(
|
||||
cell.renders.load(Ordering::Relaxed),
|
||||
4 + usize::from(mutable)
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recent_entries_are_reused_and_old_entries_are_evicted() {
|
||||
let cells: Vec<_> = (0..100).map(|_| Arc::new(Cell::default())).collect();
|
||||
let mut cache = LayoutCache::default();
|
||||
cache.begin_frame();
|
||||
for cell in &cells {
|
||||
cache.get(
|
||||
&(cell.clone() as Arc<dyn HistoryCell>),
|
||||
/*width*/ 20,
|
||||
/*separated*/ false,
|
||||
);
|
||||
}
|
||||
for index in [99, 98, 0] {
|
||||
cache.get(
|
||||
&(cells[index].clone() as Arc<dyn HistoryCell>),
|
||||
/*width*/ 20,
|
||||
/*separated*/ false,
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
[99, 98, 0].map(|index| cells[index].renders.load(Ordering::Relaxed)),
|
||||
[1, 1, 2]
|
||||
);
|
||||
}
|
||||
86
codex-rs/tui/src/transcript_view/text.rs
Normal file
86
codex-rs/tui/src/transcript_view/text.rs
Normal file
@@ -0,0 +1,86 @@
|
||||
//! Retain renderer output and row offsets, preserving Ratatui wrapping for overflow lines.
|
||||
|
||||
use crate::terminal_hyperlinks::HyperlinkLine;
|
||||
use crate::terminal_hyperlinks::HyperlinkParagraph;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Style;
|
||||
use ratatui::widgets::Widget;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct TextLayout {
|
||||
pub(crate) rows: Vec<TextRow>,
|
||||
pub(crate) byte_len: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct TextRow {
|
||||
pub(crate) line: Arc<HyperlinkLine>,
|
||||
pub(crate) offset: u16,
|
||||
}
|
||||
|
||||
impl TextLayout {
|
||||
pub(crate) fn new(lines: Vec<HyperlinkLine>, width: u16) -> Self {
|
||||
let byte_len = lines
|
||||
.iter()
|
||||
.flat_map(|line| &line.line.spans)
|
||||
.map(|span| span.content.len())
|
||||
.sum();
|
||||
let rows = lines
|
||||
.into_iter()
|
||||
.flat_map(|line| {
|
||||
// Most history renderers already wrap to width. Use the original paragraph for
|
||||
// overflow so indentation, hyphens, and wide glyphs follow the same rules as paint.
|
||||
let count = if crate::line_truncation::line_width(&line.line)
|
||||
<= usize::from(width.max(/*other*/ 1))
|
||||
{
|
||||
1
|
||||
} else {
|
||||
HyperlinkParagraph::new(std::slice::from_ref(&line), Style::default())
|
||||
.line_count(width.max(/*other*/ 1))
|
||||
};
|
||||
let line = Arc::new(line);
|
||||
// Ratatui's paragraph scroll offset is u16, as in the existing pager.
|
||||
(0..u16::try_from(count).unwrap_or(u16::MAX)).map(move |offset| TextRow {
|
||||
line: Arc::clone(&line),
|
||||
offset,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Self { rows, byte_len }
|
||||
}
|
||||
|
||||
pub(crate) fn with_leading_separator(mut self) -> Self {
|
||||
if !self.rows.is_empty() {
|
||||
self.rows.insert(
|
||||
/*index*/ 0,
|
||||
TextRow {
|
||||
line: Arc::new(HyperlinkLine::from("")),
|
||||
offset: 0,
|
||||
},
|
||||
);
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn row_count(&self) -> usize {
|
||||
self.rows.len()
|
||||
}
|
||||
|
||||
pub(crate) fn render(&self, area: Rect, buf: &mut Buffer, row: usize) {
|
||||
let start = row.min(self.rows.len());
|
||||
let end = row
|
||||
.saturating_add(usize::from(area.height))
|
||||
.min(self.rows.len());
|
||||
let mut y = area.y;
|
||||
for rows in self.rows[start..end].chunk_by(|a, b| Arc::ptr_eq(&a.line, &b.line)) {
|
||||
let first = &rows[0];
|
||||
let height = rows.len() as u16;
|
||||
HyperlinkParagraph::new(std::slice::from_ref(first.line.as_ref()), Style::default())
|
||||
.scroll(first.offset)
|
||||
.render(Rect::new(area.x, y, area.width, height), buf);
|
||||
y += height;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user