Compare commits

...

4 Commits

Author SHA1 Message Date
Felipe Coury
61ac6bf526 fix(tui): scope transcript prompt selection to session 2026-05-18 20:08:08 -03:00
Felipe Coury
8144d71409 fix(tui): use ratatui terminal in perf probe 2026-05-18 16:02:35 -03:00
Felipe Coury
df9769d450 perf(tui): speed up transcript prompt selection 2026-05-18 15:51:55 -03:00
Felipe Coury
779a2ea084 test(tui): measure transcript prompt selection 2026-05-18 15:51:55 -03:00
2 changed files with 324 additions and 63 deletions

View File

@@ -320,7 +320,7 @@ impl App {
self.backtrack.primed = true;
self.backtrack.base_id = self.chat_widget.thread_id();
self.backtrack.overlay_preview_active = true;
let count = user_count(&self.transcript_cells);
let count = self.current_transcript_user_count();
if let Some(last) = count.checked_sub(1) {
self.apply_backtrack_selection_internal(last);
}
@@ -329,7 +329,7 @@ impl App {
/// Step selection to the next older user message and update overlay.
fn step_backtrack_and_highlight(&mut self, tui: &mut tui::Tui) {
let count = user_count(&self.transcript_cells);
let count = self.current_transcript_user_count();
if count == 0 {
return;
}
@@ -352,7 +352,7 @@ impl App {
/// Step selection to the next newer user message and update overlay.
fn step_forward_backtrack_and_highlight(&mut self, tui: &mut tui::Tui) {
let count = user_count(&self.transcript_cells);
let count = self.current_transcript_user_count();
if count == 0 {
return;
}
@@ -373,16 +373,27 @@ impl App {
/// Apply a computed backtrack selection to the overlay and internal counter.
fn apply_backtrack_selection_internal(&mut self, nth_user_message: usize) {
if let Some(cell_idx) = nth_user_position(&self.transcript_cells, nth_user_message) {
self.backtrack.nth_user_message = nth_user_message;
if let Some(Overlay::Transcript(t)) = &mut self.overlay {
t.set_highlight_cell(Some(cell_idx));
}
} else {
self.backtrack.nth_user_message = usize::MAX;
if let Some(Overlay::Transcript(t)) = &mut self.overlay {
if let Some(Overlay::Transcript(t)) = &mut self.overlay {
if t.set_highlighted_user_prompt(nth_user_message).is_some() {
self.backtrack.nth_user_message = nth_user_message;
} else {
self.backtrack.nth_user_message = usize::MAX;
t.set_highlight_cell(/*cell*/ None);
}
return;
}
if nth_user_position(&self.transcript_cells, nth_user_message).is_some() {
self.backtrack.nth_user_message = nth_user_message;
} else {
self.backtrack.nth_user_message = usize::MAX;
}
}
fn current_transcript_user_count(&self) -> usize {
match &self.overlay {
Some(Overlay::Transcript(t)) => t.user_prompt_count(),
_ => user_count(&self.transcript_cells),
}
}

View File

@@ -15,7 +15,10 @@
//! recomputed. `ChatWidget` is responsible for producing a key that changes when the active cell
//! mutates in place or when its transcript output is time-dependent.
use std::any::TypeId;
use std::cell::Cell as StdCell;
use std::io::Result;
use std::rc::Rc;
use std::sync::Arc;
use std::time::Duration;
use std::time::Instant;
@@ -28,6 +31,7 @@ use crate::footer_hints::render_footer_line_with_optional_right;
use crate::footer_hints::render_footer_separator;
use crate::history_cell::HistoryCell;
use crate::history_cell::HistoryRenderMode;
use crate::history_cell::SessionInfoCell;
use crate::history_cell::UserHistoryCell;
use crate::key_hint;
use crate::key_hint::KeyBinding;
@@ -459,17 +463,25 @@ impl Renderable for CachedRenderable {
struct CellRenderable {
cell: Arc<dyn HistoryCell>,
cell_index: usize,
style: Style,
selected_style: Option<Style>,
highlight_cell: Rc<StdCell<Option<usize>>>,
render_mode: HistoryRenderMode,
}
impl Renderable for CellRenderable {
fn render(&self, area: Rect, buf: &mut Buffer) {
let style = if self.highlight_cell.get() == Some(self.cell_index) {
self.selected_style.unwrap_or(self.style)
} else {
self.style
};
let p = Paragraph::new(Text::from(
self.cell
.transcript_lines_for_mode(area.width, self.render_mode),
))
.style(self.style)
.style(style)
.wrap(Wrap { trim: false });
p.render(area, buf);
}
@@ -488,7 +500,8 @@ pub(crate) struct TranscriptOverlay {
view: PagerView,
/// Committed transcript cells (does not include the live tail).
cells: Vec<Arc<dyn HistoryCell>>,
highlight_cell: Option<usize>,
highlight_cell: Rc<StdCell<Option<usize>>>,
user_prompt_positions: Vec<usize>,
render_mode: HistoryRenderMode,
copy_keymap: Vec<KeyBinding>,
toggle_raw_output_keymap: Vec<KeyBinding>,
@@ -534,15 +547,21 @@ impl TranscriptOverlay {
toggle_raw_output_keymap: Vec<KeyBinding>,
state: TranscriptOverlayState,
) -> Self {
let highlight_cell = Rc::new(StdCell::new(state.highlight_cell));
Self {
view: PagerView::new(
Self::render_cells(&transcript_cells, state.highlight_cell, state.render_mode),
Self::render_cells(
&transcript_cells,
Rc::clone(&highlight_cell),
state.render_mode,
),
"Transcript".to_string(),
state.scroll_offset,
keymap,
),
user_prompt_positions: Self::user_prompt_positions(&transcript_cells),
cells: transcript_cells,
highlight_cell: state.highlight_cell,
highlight_cell,
render_mode: state.render_mode,
copy_keymap,
toggle_raw_output_keymap,
@@ -555,7 +574,7 @@ impl TranscriptOverlay {
fn render_cells(
cells: &[Arc<dyn HistoryCell>],
highlight_cell: Option<usize>,
highlight_cell: Rc<StdCell<Option<usize>>>,
render_mode: HistoryRenderMode,
) -> Vec<Box<dyn Renderable>> {
cells
@@ -566,17 +585,19 @@ impl TranscriptOverlay {
let base_renderable = if c.as_any().is::<UserHistoryCell>() {
Box::new(CachedRenderable::new(CellRenderable {
cell: c.clone(),
style: if highlight_cell == Some(i) {
user_message_style().reversed()
} else {
user_message_style()
},
cell_index: i,
style: user_message_style(),
selected_style: Some(user_message_style().reversed()),
highlight_cell: Rc::clone(&highlight_cell),
render_mode,
})) as Box<dyn Renderable>
} else {
Box::new(CachedRenderable::new(CellRenderable {
cell: c.clone(),
cell_index: i,
style: Style::default(),
selected_style: None,
highlight_cell: Rc::clone(&highlight_cell),
render_mode,
})) as Box<dyn Renderable>
};
@@ -595,6 +616,16 @@ impl TranscriptOverlay {
.collect()
}
fn user_prompt_positions(cells: &[Arc<dyn HistoryCell>]) -> Vec<usize> {
let start = session_start_index(cells);
cells
.iter()
.enumerate()
.skip(start)
.filter_map(|(idx, cell)| cell.is_user_prompt().then_some(idx))
.collect()
}
/// Insert a committed history cell while keeping any cached live tail.
///
/// The live tail is temporarily removed, the committed cells are rebuilt,
@@ -610,8 +641,12 @@ impl TranscriptOverlay {
let had_prior_cells = !self.cells.is_empty();
let tail_renderable = self.take_live_tail_renderable();
self.cells.push(cell);
self.view.renderables =
Self::render_cells(&self.cells, self.highlight_cell, self.render_mode);
self.user_prompt_positions = Self::user_prompt_positions(&self.cells);
self.view.renderables = Self::render_cells(
&self.cells,
Rc::clone(&self.highlight_cell),
self.render_mode,
);
self.view.invalidate_layout();
if let Some(tail) = tail_renderable {
let tail = if !had_prior_cells
@@ -645,11 +680,13 @@ impl TranscriptOverlay {
pub(crate) fn replace_cells(&mut self, cells: Vec<Arc<dyn HistoryCell>>) {
let follow_bottom = self.view.is_scrolled_to_bottom();
self.cells = cells;
self.user_prompt_positions = Self::user_prompt_positions(&self.cells);
if self
.highlight_cell
.get()
.is_some_and(|idx| idx >= self.cells.len())
{
self.highlight_cell = None;
self.highlight_cell.set(None);
}
self.rebuild_renderables();
if follow_bottom {
@@ -676,22 +713,25 @@ impl TranscriptOverlay {
let clamped_start = range.start.min(clamped_end);
if clamped_start < clamped_end {
let removed = clamped_end - clamped_start;
if let Some(highlight_cell) = self.highlight_cell.as_mut()
&& *highlight_cell >= clamped_start
if let Some(mut highlight_cell) = self.highlight_cell.get()
&& highlight_cell >= clamped_start
{
if *highlight_cell < clamped_end {
*highlight_cell = clamped_start;
if highlight_cell < clamped_end {
highlight_cell = clamped_start;
} else {
*highlight_cell = highlight_cell.saturating_sub(removed.saturating_sub(1));
highlight_cell = highlight_cell.saturating_sub(removed.saturating_sub(1));
}
self.highlight_cell.set(Some(highlight_cell));
}
self.cells
.splice(clamped_start..clamped_end, std::iter::once(consolidated));
self.user_prompt_positions = Self::user_prompt_positions(&self.cells);
if self
.highlight_cell
.get()
.is_some_and(|highlight_cell| highlight_cell >= self.cells.len())
{
self.highlight_cell = None;
self.highlight_cell.set(None);
}
self.rebuild_renderables();
}
@@ -750,13 +790,22 @@ impl TranscriptOverlay {
}
pub(crate) fn set_highlight_cell(&mut self, cell: Option<usize>) {
self.highlight_cell = cell;
self.rebuild_renderables();
if let Some(idx) = self.highlight_cell {
self.highlight_cell.set(cell);
if let Some(idx) = self.highlight_cell.get() {
self.view.scroll_chunk_into_view(idx);
}
}
pub(crate) fn user_prompt_count(&self) -> usize {
self.user_prompt_positions.len()
}
pub(crate) fn set_highlighted_user_prompt(&mut self, nth_user_message: usize) -> Option<usize> {
let cell_idx = self.user_prompt_positions.get(nth_user_message).copied()?;
self.set_highlight_cell(Some(cell_idx));
Some(cell_idx)
}
/// Returns whether the underlying pager view is currently pinned to the bottom.
///
/// The `App` draw loop uses this to decide whether to schedule animation frames for the live
@@ -768,7 +817,7 @@ impl TranscriptOverlay {
pub(crate) fn state(&self) -> TranscriptOverlayState {
TranscriptOverlayState {
scroll_offset: self.view.scroll_offset,
highlight_cell: self.highlight_cell,
highlight_cell: self.highlight_cell.get(),
render_mode: self.render_mode,
}
}
@@ -784,7 +833,7 @@ impl TranscriptOverlay {
}
pub(crate) fn selected_user_cell(&self) -> Option<usize> {
self.highlight_cell.filter(|idx| {
self.highlight_cell.get().filter(|idx| {
self.cells
.get(*idx)
.is_some_and(|cell| cell.is_user_prompt())
@@ -800,27 +849,23 @@ impl TranscriptOverlay {
}
fn move_prompt_selection(&mut self, direction: PromptSelectionDirection) {
let prompt_positions = self
.cells
.iter()
.enumerate()
.filter_map(|(idx, cell)| cell.is_user_prompt().then_some(idx))
.collect::<Vec<_>>();
let Some(last_prompt) = prompt_positions.last().copied() else {
let Some(last_prompt) = self.user_prompt_positions.last().copied() else {
return;
};
let next_prompt = match self.highlight_cell {
let next_prompt = match self.highlight_cell.get() {
Some(current) => {
let current_idx = prompt_positions
let current_idx = self
.user_prompt_positions
.iter()
.position(|idx| *idx == current)
.unwrap_or(prompt_positions.len().saturating_sub(1));
.unwrap_or(self.user_prompt_positions.len().saturating_sub(1));
match direction {
PromptSelectionDirection::Previous => {
prompt_positions[current_idx.saturating_sub(1)]
self.user_prompt_positions[current_idx.saturating_sub(1)]
}
PromptSelectionDirection::Next => prompt_positions
PromptSelectionDirection::Next => self
.user_prompt_positions
.get(current_idx.saturating_add(1))
.copied()
.unwrap_or(last_prompt),
@@ -832,21 +877,14 @@ impl TranscriptOverlay {
}
fn header_title(&self) -> String {
let total = self
.cells
.iter()
.filter(|cell| cell.is_user_prompt())
.count();
let Some(highlight_cell) = self.highlight_cell else {
let total = self.user_prompt_positions.len();
let Some(highlight_cell) = self.highlight_cell.get() else {
let noun = if total == 1 { "prompt" } else { "prompts" };
return format!("Transcript · {total} {noun}");
};
let selected = self
.cells
.iter()
.take(highlight_cell.saturating_add(1))
.filter(|cell| cell.is_user_prompt())
.count();
.user_prompt_positions
.partition_point(|idx| *idx <= highlight_cell);
if selected == 0 || total == 0 {
let noun = if total == 1 { "prompt" } else { "prompts" };
return format!("Transcript · {total} {noun}");
@@ -856,8 +894,11 @@ impl TranscriptOverlay {
fn rebuild_renderables(&mut self) {
let tail_renderable = self.take_live_tail_renderable();
self.view.renderables =
Self::render_cells(&self.cells, self.highlight_cell, self.render_mode);
self.view.renderables = Self::render_cells(
&self.cells,
Rc::clone(&self.highlight_cell),
self.render_mode,
);
if let Some(tail) = tail_renderable {
self.view.renderables.push(tail);
}
@@ -994,7 +1035,7 @@ impl TranscriptOverlay {
/*priority*/ 4,
));
}
if self.highlight_cell.is_some() {
if self.highlight_cell.get().is_some() {
let previous_edit_keys = std::iter::once(key_hint::plain(KeyCode::Esc))
.chain(first_or_empty(&self.view.keymap.previous_user_prompt))
.collect::<Vec<_>>();
@@ -1228,18 +1269,37 @@ fn render_offset_content(
copy_height
}
fn session_start_index(cells: &[Arc<dyn HistoryCell>]) -> usize {
let session_start_type = TypeId::of::<SessionInfoCell>();
let type_of = |cell: &Arc<dyn HistoryCell>| cell.as_any().type_id();
cells
.iter()
.rposition(|cell| type_of(cell) == session_start_type)
.map_or(0, |idx| idx + 1)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::history_cell::ReviewDecision;
use codex_app_server_protocol::AskForApproval;
use codex_app_server_protocol::CommandExecutionSource as ExecCommandSource;
use codex_protocol::ThreadId;
use codex_protocol::config_types::ApprovalsReviewer;
use codex_protocol::models::PermissionProfile;
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
use codex_utils_absolute_path::test_support::PathBufExt;
use codex_utils_absolute_path::test_support::test_path_buf;
use insta::assert_snapshot;
use pretty_assertions::assert_eq;
use std::collections::HashMap;
use std::io::Write;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use std::time::Instant;
use tempfile::TempDir;
use crate::diff_model::FileChange;
use crate::exec_cell::CommandOutput;
@@ -1247,6 +1307,9 @@ mod tests {
use crate::history_cell::AgentMessageCell;
use crate::history_cell::HistoryCell;
use crate::history_cell::new_patch_event;
use crate::history_cell::new_session_info;
use crate::legacy_core::config::ConfigBuilder;
use crate::session_state::ThreadSessionState;
use codex_protocol::parse_command::ParsedCommand;
use ratatui::Terminal as RatatuiTerminal;
use ratatui::backend::TestBackend;
@@ -1305,6 +1368,63 @@ mod tests {
})
}
fn synthetic_transcript(prompt_count: usize) -> Vec<Arc<dyn HistoryCell>> {
let mut cells = Vec::with_capacity(prompt_count.saturating_mul(2));
for i in 0..prompt_count {
cells.push(user_cell(&format!("prompt {i}")));
cells.push(Arc::new(AgentMessageCell::new(
vec![
Line::from(format!("assistant response {i}")),
Line::from(
"additional detail to make wrapping and height measurement realistic",
),
],
/*is_first_line*/ true,
)) as Arc<dyn HistoryCell>);
}
cells
}
fn session_info_cell(cwd: &str) -> Arc<dyn HistoryCell> {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("runtime");
let temp_dir = TempDir::new().expect("tempdir");
let config = runtime
.block_on(
ConfigBuilder::default()
.codex_home(temp_dir.path().to_path_buf())
.build(),
)
.expect("config");
let session = ThreadSessionState {
thread_id: ThreadId::new(),
forked_from_id: None,
fork_parent_title: None,
thread_name: None,
model: "gpt-test".to_string(),
model_provider_id: "test-provider".to_string(),
service_tier: None,
approval_policy: AskForApproval::Never,
approvals_reviewer: ApprovalsReviewer::User,
permission_profile: PermissionProfile::read_only(),
active_permission_profile: None,
cwd: test_path_buf(cwd).abs(),
runtime_workspace_roots: Vec::new(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::High),
message_history: None,
network_proxy: None,
rollout_path: Some(PathBuf::new()),
};
Arc::new(new_session_info(
&config, "gpt-test", &session, /*is_first_event*/ false,
/*tooltip_override*/ None, /*auth_plan*/ None,
/*show_fast_status*/ false,
)) as Arc<dyn HistoryCell>
}
fn static_overlay(lines: Vec<Line<'static>>, title: &str) -> StaticOverlay {
StaticOverlay::with_title(lines, title.to_string(), default_pager_keymap())
}
@@ -1379,6 +1499,54 @@ mod tests {
assert_snapshot!(term.backend());
}
#[test]
#[ignore = "local performance probe for transcript prompt selection"]
fn transcript_prompt_selection_perf() {
const PROMPTS: usize = 1_500;
const STEPS: usize = 300;
const WIDTH: u16 = 120;
const HEIGHT: u16 = 40;
let cells = synthetic_transcript(PROMPTS);
let cell_count = cells.len();
let mut selection_only = transcript_overlay(cells.clone());
selection_only.set_highlight_cell(Some(cell_count.saturating_sub(2)));
let selection_start = Instant::now();
for _ in 0..STEPS {
selection_only.move_prompt_selection(PromptSelectionDirection::Previous);
selection_only.move_prompt_selection(PromptSelectionDirection::Next);
}
let selection_elapsed = selection_start.elapsed();
let mut selection_plus_render = transcript_overlay(cells);
selection_plus_render.set_highlight_cell(Some(cell_count.saturating_sub(2)));
let mut term = RatatuiTerminal::new(TestBackend::new(WIDTH, HEIGHT)).expect("term");
let render_start = Instant::now();
for _ in 0..STEPS {
selection_plus_render.move_prompt_selection(PromptSelectionDirection::Previous);
term.draw(|f| selection_plus_render.render(f.area(), f.buffer_mut()))
.expect("draw previous");
selection_plus_render.move_prompt_selection(PromptSelectionDirection::Next);
term.draw(|f| selection_plus_render.render(f.area(), f.buffer_mut()))
.expect("draw next");
}
let render_elapsed = render_start.elapsed();
let operations = STEPS.saturating_mul(2);
let mut stdout = std::io::stdout().lock();
writeln!(
stdout,
"transcript_prompt_selection_perf prompts={PROMPTS} cells={cell_count} steps={operations} selection_only_ms={:.3} selection_only_avg_us={:.3} selection_plus_render_ms={:.3} selection_plus_render_avg_us={:.3}",
selection_elapsed.as_secs_f64() * 1_000.0,
selection_elapsed.as_secs_f64() * 1_000_000.0 / operations as f64,
render_elapsed.as_secs_f64() * 1_000.0,
render_elapsed.as_secs_f64() * 1_000_000.0 / operations as f64,
)
.expect("write perf output");
}
#[test]
fn transcript_overlay_renders_live_tail() {
let mut overlay = transcript_overlay(vec![Arc::new(TestCell {
@@ -1459,6 +1627,37 @@ mod tests {
assert_eq!(overlay.header_title(), "Transcript · 1/2");
}
#[test]
fn transcript_prompt_selection_ignores_prompts_before_latest_session_header() {
let mut overlay = transcript_overlay(vec![
user_cell("old prompt"),
Arc::new(AgentMessageCell::new(
vec![Line::from("old assistant")],
/*is_first_line*/ true,
)),
session_info_cell("/tmp/project"),
user_cell("current first"),
Arc::new(AgentMessageCell::new(
vec![Line::from("current assistant")],
/*is_first_line*/ true,
)),
user_cell("current second"),
]);
assert_eq!(overlay.user_prompt_count(), 2);
assert_eq!(overlay.header_title(), "Transcript · 2 prompts");
overlay.move_prompt_selection(PromptSelectionDirection::Previous);
assert_eq!(overlay.selected_user_cell(), Some(5));
assert_eq!(overlay.header_title(), "Transcript · 2/2");
overlay.move_prompt_selection(PromptSelectionDirection::Previous);
assert_eq!(overlay.selected_user_cell(), Some(3));
assert_eq!(overlay.header_title(), "Transcript · 1/2");
assert_eq!(overlay.set_highlighted_user_prompt(2), None);
}
#[test]
fn selected_user_prompt_keeps_reversed_style_without_role_gutter() {
let mut overlay = transcript_overlay(vec![user_cell("selected prompt")]);
@@ -1485,6 +1684,57 @@ mod tests {
);
}
#[test]
fn prompt_selection_updates_style_without_rebuilding_layout() {
let mut overlay = transcript_overlay(vec![
user_cell("first prompt"),
Arc::new(AgentMessageCell::new(
vec![Line::from("assistant")],
/*is_first_line*/ true,
)),
user_cell("second prompt"),
]);
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 80, /*height*/ 14,
);
let mut buf = Buffer::empty(area);
overlay.render(area, &mut buf);
assert!(overlay.view.layout.is_some());
let renderable_count = overlay.view.renderables.len();
overlay.set_highlight_cell(Some(0));
assert!(overlay.view.layout.is_some());
assert_eq!(overlay.view.renderables.len(), renderable_count);
overlay.render(area, &mut buf);
let first_selection = prompt_marker_reversed_states(&buf, area);
assert_eq!(first_selection, vec![true, false]);
overlay.set_highlight_cell(Some(2));
assert!(overlay.view.layout.is_some());
assert_eq!(overlay.view.renderables.len(), renderable_count);
overlay.render(area, &mut buf);
let second_selection = prompt_marker_reversed_states(&buf, area);
assert_eq!(second_selection, vec![false, true]);
}
fn prompt_marker_reversed_states(buf: &Buffer, area: Rect) -> Vec<bool> {
let mut states = Vec::new();
for y in area.y..area.bottom() {
for x in area.x..area.right() {
if buf[(x, y)].symbol() == "" {
states.push(
buf[(x, y)]
.style()
.add_modifier
.contains(Modifier::REVERSED),
);
}
}
}
states
}
#[test]
fn transcript_overlay_sync_live_tail_is_noop_for_identical_key() {
let mut overlay = transcript_overlay(vec![Arc::new(TestCell {
@@ -1761,7 +2011,7 @@ mod tests {
);
assert_eq!(
overlay.highlight_cell,
overlay.highlight_cell.get(),
Some(2),
"highlight inside consolidated range should point to replacement cell",
);
@@ -1788,7 +2038,7 @@ mod tests {
);
assert_eq!(
overlay.highlight_cell,
overlay.highlight_cell.get(),
Some(4),
"highlight after consolidated range should shift left by removed cells",
);