From 5396defe86be9f8a191ce37bad8c6f5e04832821 Mon Sep 17 00:00:00 2001 From: Tuan-Lung Wang Date: Mon, 16 Mar 2026 21:54:18 -0700 Subject: [PATCH] Add transcript search navigation --- codex-rs/tui/src/app_backtrack.rs | 8 +- codex-rs/tui/src/pager_overlay.rs | 1179 +++++++++++++++-- ..._tests__static_overlay_snapshot_basic.snap | 4 +- ...ests__static_overlay_wraps_long_lines.snap | 5 +- ...ript_overlay_apply_patch_scroll_vt100.snap | 23 +- ..._transcript_overlay_renders_live_tail.snap | 6 +- ...s__transcript_overlay_search_snapshot.snap | 20 + ...ts__transcript_overlay_snapshot_basic.snap | 6 +- 8 files changed, 1134 insertions(+), 117 deletions(-) create mode 100644 codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_search_snapshot.snap diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index 1bb2c29569..4ff75b1564 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -156,8 +156,12 @@ impl App { .. }) = event { - // First Esc in transcript overlay: begin backtrack preview at latest user message. - self.begin_overlay_backtrack_preview(tui); + if self.overlay.as_ref().is_some_and(Overlay::consumes_escape) { + self.overlay_forward_event(tui, event)?; + } else { + // First Esc in transcript overlay: begin backtrack preview at latest user message. + self.begin_overlay_backtrack_preview(tui); + } Ok(true) } else { // Not in backtrack mode: forward events to the overlay widget. diff --git a/codex-rs/tui/src/pager_overlay.rs b/codex-rs/tui/src/pager_overlay.rs index 901f51c7fe..eb3590798b 100644 --- a/codex-rs/tui/src/pager_overlay.rs +++ b/codex-rs/tui/src/pager_overlay.rs @@ -32,8 +32,10 @@ use crate::style::user_message_style; use crate::text_formatting::truncate_text; use crate::tui; use crate::tui::TuiEvent; +use crate::wrapping::word_wrap_lines; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; +use crossterm::event::KeyEventKind; use ratatui::buffer::Buffer; use ratatui::buffer::Cell; use ratatui::layout::Constraint; @@ -51,6 +53,7 @@ use ratatui::widgets::Paragraph; use ratatui::widgets::Widget; use ratatui::widgets::WidgetRef; use ratatui::widgets::Wrap; +use unicode_segmentation::UnicodeSegmentation; pub(crate) enum Overlay { Transcript(TranscriptOverlay), @@ -86,6 +89,13 @@ impl Overlay { Overlay::Static(o) => o.is_done(), } } + + pub(crate) fn consumes_escape(&self) -> bool { + match self { + Overlay::Transcript(o) => o.consumes_escape(), + Overlay::Static(_) => false, + } + } } const KEY_UP: KeyBinding = key_hint::plain(KeyCode::Up); @@ -113,8 +123,14 @@ const KEY_TAB: KeyBinding = key_hint::plain(KeyCode::Tab); const KEY_A: KeyBinding = key_hint::plain(KeyCode::Char('a')); const KEY_E: KeyBinding = key_hint::plain(KeyCode::Char('e')); const KEY_D: KeyBinding = key_hint::plain(KeyCode::Char('d')); +const KEY_SLASH: KeyBinding = key_hint::plain(KeyCode::Char('/')); +const KEY_QUESTION: KeyBinding = key_hint::plain(KeyCode::Char('?')); +const KEY_N: KeyBinding = key_hint::plain(KeyCode::Char('n')); +const KEY_CAPITAL_N: KeyBinding = key_hint::plain(KeyCode::Char('N')); +const KEY_BACKSPACE: KeyBinding = key_hint::plain(KeyCode::Backspace); const MAX_ANCHOR_LINES: usize = 2; const DETAILS_PLACEHOLDER_LABEL: &str = "Details..."; +const SEARCH_MATCH_CONTEXT_GRAPHEMES: usize = 48; // Common pager navigation hints rendered on the first line const PAGER_KEY_HINTS: &[(&[KeyBinding], &str)] = &[ @@ -146,6 +162,12 @@ fn render_key_hints(area: Rect, buf: &mut Buffer, pairs: &[(&[KeyBinding], &str) Paragraph::new(vec![Line::from(spans).dim()]).render_ref(area, buf); } +fn is_shift_char_press(event: KeyEvent, ch: char) -> bool { + matches!(event.kind, KeyEventKind::Press | KeyEventKind::Repeat) + && event.code == KeyCode::Char(ch) + && event.modifiers == crossterm::event::KeyModifiers::SHIFT +} + /// Generic widget for rendering a pager view. struct PagerView { renderables: Vec>, @@ -416,6 +438,19 @@ impl PagerView { .map(|r| r.desired_height(area.width) as usize) .sum::(); } + + fn scroll_chunk_offset_to_top(&mut self, idx: usize, line_offset: usize, area: Rect) { + if idx >= self.renderables.len() { + return; + } + self.scroll_offset = self + .renderables + .iter() + .take(idx) + .map(|r| r.desired_height(area.width) as usize) + .sum::() + .saturating_add(line_offset); + } } /// A renderable that caches its desired height. @@ -452,6 +487,8 @@ impl Renderable for CachedRenderable { struct CellRenderable { cell: Arc, highlight_style: Option