From 1cc9917508ab791e0bf996e6ada8d91eb3edfe33 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Thu, 1 Oct 2026 17:02:37 +0000 Subject: [PATCH] Keep transcript Find expansion scoped to the current match (#50045) ## Why Starting Find expanded the entire transcript into detailed mode. Searching hidden content should preserve the compact view and manually expanded activities. ## What changed - Search full transcript content, including hidden live output, and temporarily expand only the current matching entry. - Preserve manual disclosure state and retained text revisions while reading or selecting a match. Allow activity controls while reading results. - Use `Enter` or `Ctrl+P` for older matches and `Ctrl+N` for newer matches, with matching footer hints. ## Testing Add regression tests and snapshots for selective expansion, manual disclosures, hidden live matches, retained selections across updates and resizing, and Find in compact transcript overlays. GitOrigin-RevId: 618ee47ba6c3f3a5138c9e3fe0961e7faa3383da --- codex-rs/tui/src/app/owned_transcript.rs | 8 +- .../tui/src/app/owned_transcript_tests.rs | 24 +- ...ompact_overlay_finds_hidden_live_text.snap | 26 ++ codex-rs/tui/src/app_backtrack.rs | 3 + codex-rs/tui/src/pager_overlay/transcript.rs | 9 + codex-rs/tui/src/transcript_view.rs | 2 +- .../tui/src/transcript_view/disclosure.rs | 22 +- codex-rs/tui/src/transcript_view/footer.rs | 6 +- .../tui/src/transcript_view/footer_tests.rs | 2 +- codex-rs/tui/src/transcript_view/layout.rs | 32 +- .../tui/src/transcript_view/layout_tests.rs | 3 + codex-rs/tui/src/transcript_view/mutations.rs | 2 +- .../src/transcript_view/mutations_tests.rs | 4 +- codex-rs/tui/src/transcript_view/search.rs | 87 ++--- .../tui/src/transcript_view/search_footer.rs | 13 +- .../transcript_view/search_presentation.rs | 111 ++++++ .../tui/src/transcript_view/search_tests.rs | 335 +++++++++++++++++- codex-rs/tui/src/transcript_view/snapshot.rs | 25 +- ...n_and_search_keep_complete_exit_hints.snap | 6 +- ...in_distinct_from_prose_and_query_text.snap | 14 +- ...arch__tests__find_selective_expansion.snap | 19 + codex-rs/tui/src/transcript_view_tests.rs | 5 +- 22 files changed, 643 insertions(+), 115 deletions(-) create mode 100644 codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__tests__compact_overlay_finds_hidden_live_text.snap create mode 100644 codex-rs/tui/src/transcript_view/search_presentation.rs create mode 100644 codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__search__tests__find_selective_expansion.snap diff --git a/codex-rs/tui/src/app/owned_transcript.rs b/codex-rs/tui/src/app/owned_transcript.rs index 1702a4f4e8..9822d0324a 100644 --- a/codex-rs/tui/src/app/owned_transcript.rs +++ b/codex-rs/tui/src/app/owned_transcript.rs @@ -53,7 +53,10 @@ impl App { let active_ids = chat_widget.active_activity_ids(); let expanded = view.sync_live_activity(&self.transcript_cells, active_ids) && chat_widget.history_render_mode() == HistoryRenderMode::Rich; - if detailed { + let search_changed = view.sync_search_live_tail(transcript_width, active_key, |width| { + chat_widget.active_cell_transcript_hyperlink_lines(width) + }); + let changed = if detailed { view.sync_live_tail(transcript_width, active_key, |width| { chat_widget.active_cell_transcript_hyperlink_lines(width) }) @@ -61,7 +64,8 @@ impl App { view.sync_live_activity_tail(transcript_width, active_key, expanded, |width| { chat_widget.active_cell_owned_transcript_lines(width, expanded) }) - } + }; + changed || search_changed } pub(super) fn render_owned_transcript( diff --git a/codex-rs/tui/src/app/owned_transcript_tests.rs b/codex-rs/tui/src/app/owned_transcript_tests.rs index 3eaf04ed49..9a88d4711c 100644 --- a/codex-rs/tui/src/app/owned_transcript_tests.rs +++ b/codex-rs/tui/src/app/owned_transcript_tests.rs @@ -843,7 +843,7 @@ async fn inline_transcript_search_draws_and_escape_precedes_backtrack() -> Resul &tui.terminal, )); assert!( - rendered.contains("enter next"), + rendered.contains("enter/⌃p older"), "search advances on the inline overlay draw path" ); app.handle_backtrack_overlay_event( @@ -919,7 +919,7 @@ async fn find_owns_editor_chords_without_changing_the_composer_draft() -> Result crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal), ); assert!( - rendered.contains("enter next"), + rendered.contains("enter/⌃p older"), "Find must resume after pasting over selection" ); } @@ -1327,8 +1327,26 @@ async fn find_refreshes_live_details_before_searching_the_first_query() -> Resul let rendered = buffer_text(crate::custom_terminal::test_support::last_rendered_buffer( &tui.terminal, )); - assert!(rendered.contains("enter next"), "{rendered}"); + assert!(rendered.contains("enter/⌃p older"), "{rendered}"); tui.set_owned_screen(/*owned*/ false)?; + app.open_transcript_overlay(&mut tui); + let Some(Overlay::Transcript(overlay)) = &mut app.overlay else { + panic!("expected transcript overlay"); + }; + overlay.set_presentation(/*detailed*/ false, HistoryRenderMode::Rich); + for event in [ + TuiEvent::Key(KeyEvent::new(KeyCode::F(3), KeyModifiers::NONE)), + TuiEvent::Paste("needle".into()), + TuiEvent::Draw, + ] { + app.handle_tui_event(&mut tui, &mut app_server, event) + .await?; + } + let rendered = buffer_text(crate::custom_terminal::test_support::last_rendered_buffer( + &tui.terminal, + )); + assert!(rendered.contains("printf needle"), "{rendered}"); + insta::assert_snapshot!("compact_overlay_finds_hidden_live_text", rendered); app_server.shutdown().await?; Ok(()) } diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__tests__compact_overlay_finds_hidden_live_text.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__tests__compact_overlay_finds_hidden_live_text.snap new file mode 100644 index 0000000000..b6648a2908 --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__tests__compact_overlay_finds_hidden_live_text.snap @@ -0,0 +1,26 @@ +--- +source: tui/src/app/owned_transcript_tests.rs +expression: rendered +--- +/ T R A N S C R I P T / / / / / / / / / / / / / / / / / / / / / / / / / / / / / +$ 'printf visible + printf needle' + + + + + + + + + + + + + + + + + +Find: needle +enter/⌃p older · ⌃n newer · full transcript · esc close diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index 4e63f0caef..3a555a808d 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -371,6 +371,9 @@ impl App { let chat_widget = &self.chat_widget; let detailed = t.is_detailed(); let size = tui.prepare_draw_size()?; + t.sync_search_live_tail(size.width.max(/*other*/ 1), active_key, |width| { + chat_widget.active_cell_transcript_hyperlink_lines(width) + }); t.sync_live_tail(size.width.max(/*other*/ 1), active_key, |width| { if detailed { chat_widget.active_cell_transcript_hyperlink_lines(width) diff --git a/codex-rs/tui/src/pager_overlay/transcript.rs b/codex-rs/tui/src/pager_overlay/transcript.rs index 6a381930bb..508d5c34f4 100644 --- a/codex-rs/tui/src/pager_overlay/transcript.rs +++ b/codex-rs/tui/src/pager_overlay/transcript.rs @@ -397,6 +397,15 @@ impl TranscriptOverlay { self.view.sync_live_tail(width, key, compute_lines) } + pub(crate) fn sync_search_live_tail( + &mut self, + width: u16, + key: Option, + compute_lines: impl FnOnce(u16) -> Option>, + ) { + self.view.sync_search_live_tail(width, key, compute_lines); + } + pub(crate) fn set_highlight_cell(&mut self, cell: Option) { self.highlight_cell = cell.filter(|index| *index < self.cells.len()); self.pending_highlight = self.highlight_cell; diff --git a/codex-rs/tui/src/transcript_view.rs b/codex-rs/tui/src/transcript_view.rs index bc62926421..5a7a7e749e 100644 --- a/codex-rs/tui/src/transcript_view.rs +++ b/codex-rs/tui/src/transcript_view.rs @@ -336,7 +336,7 @@ impl TranscriptView { self.cache.clear(); self.suppressed_prompt_header = None; self.live_key = None; - // Search temporarily expands content without changing either presentation's position. + // Keep search's origin independent of either presentation's saved position. if self.detailed != detailed && !self.search.is_active() { let previous = self.position; self.position = self.saved_position.take().unwrap_or(previous); diff --git a/codex-rs/tui/src/transcript_view/disclosure.rs b/codex-rs/tui/src/transcript_view/disclosure.rs index ccbc373a18..d22138770d 100644 --- a/codex-rs/tui/src/transcript_view/disclosure.rs +++ b/codex-rs/tui/src/transcript_view/disclosure.rs @@ -167,7 +167,7 @@ impl TranscriptView { key: KeyEvent, cells: &[Arc], ) -> Option { - if self.detailed || self.mode != HistoryRenderMode::Rich || self.search.is_active() { + if self.detailed || self.mode != HistoryRenderMode::Rich || self.is_search_editing() { return None; } if self.disclosure.keymap.app.focus_activity.is_pressed(key) { @@ -209,7 +209,9 @@ impl TranscriptView { ListAction::Accept => self.toggle_activity(cells, focused), ListAction::MoveLeft | ListAction::MoveRight => { let ids = self.activity_ids(cells, focused); - if self.disclosure.is_expanded(&ids) != (action == ListAction::MoveRight) { + let expanded = self.disclosure.is_expanded(&ids) + || self.is_search_expanded(self.entry_key(cells, focused)); + if expanded != (action == ListAction::MoveRight) { self.toggle_activity(cells, focused); } } @@ -315,6 +317,8 @@ impl TranscriptView { if ids.is_empty() { return; } + let key = self.entry_key(cells, index); + let matched = self.is_search_expanded(key); let previous_top = self.visible.first().map(|visible| { let offset = visible.layout.position_at(visible.row, /*column*/ 0); ( @@ -325,7 +329,13 @@ impl TranscriptView { ) }); self.end_selection(cells); - self.release_live_reading(); + if self.search.has_active_query() && !matched { + if let Some(snapshot) = &mut self.held_reading { + snapshot.pinned.remove(&key); + } + } else { + self.release_live_reading(); + } // Source offsets only survive an unchanged cell. Regrouped/retired live entries resolve // through their member IDs and anchor at their header, never a stale positional index. let anchor = previous_top @@ -346,7 +356,7 @@ impl TranscriptView { offset: 0, row_bias: 0, }); - if self.disclosure.is_expanded(&ids) { + if matched || self.disclosure.is_expanded(&ids) { self.disclosure.expanded.retain(|id| !ids.contains(id)); } else { self.disclosure.expanded.extend(ids.iter().cloned()); @@ -356,7 +366,9 @@ impl TranscriptView { self.cache.clear(); self.live_key = None; self.live_separated = None; - self.restart_search(); + if matched { + self.invalidate_held_search(); + } } pub(super) fn disclosure_footer(&self, width: u16) -> Option { diff --git a/codex-rs/tui/src/transcript_view/footer.rs b/codex-rs/tui/src/transcript_view/footer.rs index 7e1c7aeeb7..27963d1037 100644 --- a/codex-rs/tui/src/transcript_view/footer.rs +++ b/codex-rs/tui/src/transcript_view/footer.rs @@ -66,14 +66,14 @@ impl TranscriptView { is_interactive: true, }); } - if self.search.is_reading() && !pending { + if self.search.is_reading() && !pending && !self.is_activity_focused() { return Some(TranscriptFooter { text: self.search.status_line(width, self.history).into(), cursor_column: None, is_interactive: false, }); } - if self.is_activity_focused() { + if self.is_activity_focused() && !pending { return self.disclosure_footer(width); } // Selection can pause following without hiding the current final row. @@ -230,6 +230,8 @@ pub(super) fn navigation_line(navigation: &str) -> Line<'static> { " clear selection", " copy+↓", " previous", + " older", + " newer", " latest", " retry", " select", diff --git a/codex-rs/tui/src/transcript_view/footer_tests.rs b/codex-rs/tui/src/transcript_view/footer_tests.rs index 55e0f2b52d..eb2e3a9f04 100644 --- a/codex-rs/tui/src/transcript_view/footer_tests.rs +++ b/codex-rs/tui/src/transcript_view/footer_tests.rs @@ -305,7 +305,7 @@ fn reading_a_find_result_shows_history_loading_and_failure() { insta::assert_snapshot!(hints.join("\n"), @" ↑ Loading earlier messages… · esc latest Retry history: ⌥], + index: usize, + ) -> Option> { + let key = self.entry_key(cells, index); if let Some(layout) = self .snapshot() .and_then(|snapshot| snapshot.pinned.get(&key)) @@ -121,6 +133,15 @@ impl TranscriptView { &mut self, cells: &[Arc], index: usize, + ) -> Option> { + self.entry_layout(cells, index, self.detailed) + } + + pub(super) fn entry_layout( + &mut self, + cells: &[Arc], + index: usize, + full_content: bool, ) -> Option> { let Some(cell) = cells.get(index) else { if index != cells.len() { @@ -143,12 +164,12 @@ impl TranscriptView { { return Some(Arc::new(TextLayout::new(Vec::new(), width))); } - let detailed = self.detailed; let mode = self.mode; let ids = cell.activity_ids(); - let disclosure = !detailed && mode == HistoryRenderMode::Rich && !ids.is_empty(); - let expanded = disclosure && self.disclosure.is_expanded(&ids); - if expanded { + let disclosure = !self.detailed && mode == HistoryRenderMode::Rich && !ids.is_empty(); + let manually_expanded = self.disclosure.is_expanded(&ids); + let expanded = disclosure && (full_content || manually_expanded); + if manually_expanded { self.disclosure.expanded.extend(ids); } let separated = index > 0 && !cell.is_stream_continuation(); @@ -157,6 +178,7 @@ impl TranscriptView { turn_tip_space: self.turn_tip_key == Some(EntryKey::cell(cell)), expanded, disclosure, + detailed: full_content, }; let shortcut = self .disclosure @@ -183,7 +205,7 @@ impl TranscriptView { crate::history_cell::fullscreen_session_lines( cell.as_ref(), width, - detailed, + full_content, mode, ), width, diff --git a/codex-rs/tui/src/transcript_view/layout_tests.rs b/codex-rs/tui/src/transcript_view/layout_tests.rs index f0b3702043..486adc4308 100644 --- a/codex-rs/tui/src/transcript_view/layout_tests.rs +++ b/codex-rs/tui/src/transcript_view/layout_tests.rs @@ -50,6 +50,7 @@ fn layouts_refresh_for_width_animation_and_mutable_frames() { turn_tip_space: false, expanded: false, disclosure: false, + detailed: false, }, || TextLayout::new(history.transcript_hyperlink_lines(width), width), ) @@ -105,6 +106,7 @@ fn recent_entries_are_reused_and_old_entries_are_evicted() { turn_tip_space: false, expanded: false, disclosure: false, + detailed: false, }, || { TextLayout::new( @@ -124,6 +126,7 @@ fn recent_entries_are_reused_and_old_entries_are_evicted() { turn_tip_space: false, expanded: false, disclosure: false, + detailed: false, }, || { TextLayout::new( diff --git a/codex-rs/tui/src/transcript_view/mutations.rs b/codex-rs/tui/src/transcript_view/mutations.rs index aab70bd635..b52a567cde 100644 --- a/codex-rs/tui/src/transcript_view/mutations.rs +++ b/codex-rs/tui/src/transcript_view/mutations.rs @@ -160,12 +160,12 @@ impl TranscriptView { if self.snapshot().is_some() { return; } - self.restart_search(); if let Position::Reading(anchor) = self.position && (anchor.key == tail_key || anchor.key == EntryKey::Live) { self.held_reading = Some(self.capture_snapshot(cells)); } + self.restart_search(); } /// Extend the frozen history only with the explicitly inserted older page. diff --git a/codex-rs/tui/src/transcript_view/mutations_tests.rs b/codex-rs/tui/src/transcript_view/mutations_tests.rs index 961ac5f90e..95d30354a6 100644 --- a/codex-rs/tui/src/transcript_view/mutations_tests.rs +++ b/codex-rs/tui/src/transcript_view/mutations_tests.rs @@ -418,7 +418,7 @@ fn folding_a_reasoning_only_page_retains_its_find_and_copy_revision() { view.search .status_line(/*width*/ 80, view.history) .to_string() - .starts_with("enter next") + .starts_with("enter/⌃p older") ); } let before = render(&mut view, &cells, /*width*/ 30); @@ -444,7 +444,7 @@ fn folding_a_reasoning_only_page_retains_its_find_and_copy_revision() { view.search .status_line(/*width*/ 80, view.history) .to_string() - .starts_with("enter next") + .starts_with("enter/⌃p older") ); } } diff --git a/codex-rs/tui/src/transcript_view/search.rs b/codex-rs/tui/src/transcript_view/search.rs index 5fd2f3feb9..375041079b 100644 --- a/codex-rs/tui/src/transcript_view/search.rs +++ b/codex-rs/tui/src/transcript_view/search.rs @@ -1,4 +1,4 @@ -//! Incremental literal search over the current transcript presentation. +//! Incremental literal search over full transcript content, independent of collapsed display. //! //! Search retains one match and one entry being scanned. Each frame examines a bounded text //! chunk; reaching the oldest loaded entry asks the app's existing history pager to continue. @@ -17,6 +17,8 @@ use super::*; #[path = "search_footer.rs"] mod footer; +#[path = "search_presentation.rs"] +mod presentation; const QUERY_BYTES: usize = 4096; const SCAN_BYTES: usize = 16 * 1024; @@ -70,6 +72,8 @@ pub(super) struct Search { scanning_layout: Option<(EntryKey, Arc)>, page_start: Option, query_truncated: bool, + pub(super) live: Option>, + live_key: Option<(u16, ActiveCellTranscriptKey)>, } impl Default for Search { @@ -86,6 +90,8 @@ impl Default for Search { scanning_layout: None, page_start: None, query_truncated: false, + live: None, + live_key: None, } } } @@ -113,7 +119,6 @@ impl TranscriptView { let saved_detailed = self.detailed; self.search.mode = SearchMode::Editing; self.disclosure.focused = None; - self.set_presentation(/*detailed*/ true, self.mode); self.search.saved_position = saved_position; self.search.saved_snapshot = saved_snapshot; self.search.saved_detailed = saved_detailed; @@ -161,11 +166,10 @@ impl TranscriptView { self.cancel_search(); } (KeyCode::Enter, _) if self.search.is_reading() => return false, - // Ctrl+N/P stay distinct when a legacy terminal encodes Shift+Enter as Enter. - (KeyCode::Enter, KeyModifiers::NONE) | (KeyCode::Char('n'), KeyModifiers::CONTROL) => { + (KeyCode::Char('n'), KeyModifiers::CONTROL) => { self.next_search_match(cells, Direction::Newer); } - (KeyCode::Enter, KeyModifiers::SHIFT) | (KeyCode::Char('p'), KeyModifiers::CONTROL) => { + (KeyCode::Enter, KeyModifiers::NONE) | (KeyCode::Char('p'), KeyModifiers::CONTROL) => { self.next_search_match(cells, Direction::Older); } (KeyCode::PageUp | KeyCode::PageDown, KeyModifiers::NONE) => return false, @@ -241,6 +245,8 @@ impl TranscriptView { self.search.editor.set_text_clearing_elements(""); self.search.query_changed(); self.search.progress = Progress::Idle; + self.search.live = None; + self.search.live_key = None; self.position = position; self.held_reading = snapshot; self.rewrap_snapshot(self.area.width); @@ -374,33 +380,7 @@ impl TranscriptView { offset: window.start + range.start, ..cursor.anchor }; - self.position = Position::Reading(anchor); - self.hold_live_reading(current_cells, Arc::clone(&layout)); - let end = window.start + range.end; - let row = layout.row_for_offset(anchor.offset); - let match_rows = layout.row_for_offset(end - 1) - row + 1; - let context_rows = usize::from(self.area.height / 3) - .min(usize::from(self.area.height).saturating_sub(match_rows)); - // Keep the hit's retained revision while placing its lead-in above it, even - // in the preceding entry, without clipping a match that fits in the viewport. - let (index, row) = - self.move_rows(cells, anchor.index, row, -(context_rows as isize)); - if let Some(context) = self.layout(cells, index) { - let offset = context.position_at(row, /*column*/ 0); - self.position = Position::Reading(Anchor { - key: self.entry_key(cells, index), - index, - offset, - row_bias: context.row_for_offset(offset) as isize - row as isize, - }); - } - self.search.current = Some(Match { - anchor, - end, - layout: Arc::clone(&layout), - }); - self.search.progress = Progress::Found; - self.search.scanning_layout = None; + self.install_search_match(current_cells, anchor, window.start + range.end, layout); return false; } scanned_bytes += window.len(); @@ -500,6 +480,7 @@ impl TranscriptView { }; if direction == Direction::Newer && let Some(snapshot) = self.held_reading.take() + && let Some(previous) = self.search.current.take() { // Continue through newly committed output, or rejoin a surviving cell. let index = if anchor.key == EntryKey::Live { @@ -521,13 +502,20 @@ impl TranscriptView { self.visible.clear(); return; }; - let prefix = snapshot - .pinned - .get(&anchor.key) - .and_then(|layout| layout.text().get(..offset)); - let layout = self.current_layout(cells, index); + let layout = self.search_layout( + cells, + Anchor { + key: cells.get(index).map_or(EntryKey::Live, EntryKey::cell), + index, + ..anchor + }, + ); let same_prefix = layout.as_ref().is_some_and(|layout| { - prefix.is_some_and(|prefix| layout.text().starts_with(prefix)) + previous + .layout + .text() + .get(..offset) + .is_some_and(|prefix| layout.text().starts_with(prefix)) }); anchor = Anchor { key: cells.get(index).map_or(EntryKey::Live, EntryKey::cell), @@ -562,7 +550,7 @@ impl TranscriptView { fn start_search_scan(&mut self, cells: &[Arc], direction: Direction) { self.search.page_start = None; - let count = cells.len() + usize::from(self.live.is_some()); + let count = cells.len() + usize::from(self.live.is_some() || self.search.live.is_some()); let index = if self.search.is_reading() { // Without a retained match, resume from the visible entry, not a transcript end. self.start(cells).0 @@ -587,21 +575,6 @@ impl TranscriptView { }); } - fn search_layout( - &mut self, - cells: &[Arc], - anchor: Anchor, - ) -> Option> { - if let Some((key, layout)) = &self.search.scanning_layout - && *key == anchor.key - { - return Some(Arc::clone(layout)); - } - let layout = self.layout(cells, anchor.index)?; - self.search.scanning_layout = Some((anchor.key, Arc::clone(&layout))); - Some(layout) - } - fn advance_search_cursor( &mut self, cells: &[Arc], @@ -632,7 +605,7 @@ impl TranscriptView { Direction::Older if self.search.page_start == Some(cursor.anchor.key) => None, Direction::Older => cursor.anchor.index.checked_sub(/*rhs*/ 1), Direction::Newer => (cursor.anchor.index + 1 - < cells.len() + usize::from(self.live.is_some())) + < cells.len() + usize::from(self.live.is_some() || self.search.live.is_some())) .then_some(cursor.anchor.index + 1), }; self.search.progress = match next { @@ -673,6 +646,10 @@ impl Search { .map(|found| Arc::clone(&found.layout)) } + pub(super) fn match_anchor(&self) -> Option { + self.current.as_ref().map(|found| found.anchor) + } + pub(super) fn is_active(&self) -> bool { self.mode != SearchMode::Closed } diff --git a/codex-rs/tui/src/transcript_view/search_footer.rs b/codex-rs/tui/src/transcript_view/search_footer.rs index d55626ab34..353a2a7116 100644 --- a/codex-rs/tui/src/transcript_view/search_footer.rs +++ b/codex-rs/tui/src/transcript_view/search_footer.rs @@ -49,13 +49,14 @@ impl Search { history: TranscriptHistoryState, ) -> Line<'static> { let previous = crate::key_hint::ctrl(crossterm::event::KeyCode::Char('p')).display_label(); + let next = crate::key_hint::ctrl(crossterm::event::KeyCode::Char('n')).display_label(); let retry_hint = format!("{previous} retry"); let unavailable_hint = format!("History unavailable · {retry_hint}"); - let next_hint = format!("enter next · {previous} previous"); + let next_hint = format!("enter/{previous} older · {next} newer"); let exhausted_hint = format!("No more matches · {next_hint}"); let (status, compact) = match self.progress { Progress::Idle | Progress::Found if self.is_reading() => { - ("ctrl+n next · ctrl+p previous", "ctrl+n next") + ("ctrl+p older · ctrl+n newer", "ctrl+p older") } Progress::Idle => ("Type to find", "Type to find"), Progress::Restart | Progress::Scanning(_) => ("Searching…", "Searching…"), @@ -63,13 +64,13 @@ impl Search { (unavailable_hint.as_str(), retry_hint.as_str()) } Progress::AwaitingHistory => ("Searching earlier history…", "Loading…"), - Progress::Found => (next_hint.as_str(), "enter next"), + Progress::Found => (next_hint.as_str(), "enter older"), Progress::Exhausted if self.is_reading() && self.current.is_some() => ( - "No more matches · ctrl+n next · ctrl+p previous", - "ctrl+n next", + "No more matches · ctrl+p older · ctrl+n newer", + "ctrl+p older", ), Progress::Exhausted if self.current.is_some() => { - (exhausted_hint.as_str(), "enter next") + (exhausted_hint.as_str(), "enter older") } Progress::Exhausted => ("No matches", "No matches"), }; diff --git a/codex-rs/tui/src/transcript_view/search_presentation.rs b/codex-rs/tui/src/transcript_view/search_presentation.rs new file mode 100644 index 0000000000..51bd8a9527 --- /dev/null +++ b/codex-rs/tui/src/transcript_view/search_presentation.rs @@ -0,0 +1,111 @@ +//! Search full sources without changing manual disclosure state. Only the current match supplies +//! an expanded display layout; retained reading snapshots keep their original display layouts. + +use super::*; + +impl TranscriptView { + pub(in crate::transcript_view) fn is_search_expanded(&self, key: EntryKey) -> bool { + self.search + .match_anchor() + .is_some_and(|anchor| anchor.key == key) + } + + /// Retain the original presentation before revealing a match and its surrounding context. + pub(super) fn install_search_match( + &mut self, + cells: &[Arc], + anchor: Anchor, + end: usize, + layout: Arc, + ) { + let current_cells = cells; + let snapshot = self.snapshot_cells(); + let cells = snapshot.as_deref().unwrap_or(cells); + let base = self + .base_layout(cells, anchor.index) + .unwrap_or_else(|| Arc::new(TextLayout::new(Vec::new(), self.area.width))); + self.position = Position::Reading(anchor); + self.hold_live_reading(current_cells, base); + self.search.current = Some(Match { + anchor, + end, + layout: Arc::clone(&layout), + }); + let row = layout.row_for_offset(anchor.offset); + let match_rows = layout.row_for_offset(end - 1) - row + 1; + let context_rows = usize::from(self.area.height / 3) + .min(usize::from(self.area.height).saturating_sub(match_rows)); + // Keep the hit's retained revision while placing its lead-in above it, even + // in the preceding entry, without clipping a match that fits in the viewport. + let (index, row) = self.move_rows(cells, anchor.index, row, -(context_rows as isize)); + if let Some(context) = self.layout(cells, index) { + let offset = context.position_at(row, /*column*/ 0); + self.position = Position::Reading(Anchor { + key: self.entry_key(cells, index), + index, + offset, + row_bias: context.row_for_offset(offset) as isize - row as isize, + }); + } + self.search.progress = Progress::Found; + self.search.scanning_layout = None; + } + + /// Cache the full live source only while finding, independently of the compact live display. + pub(crate) fn sync_search_live_tail( + &mut self, + width: u16, + key: Option, + lines: impl FnOnce(u16) -> Option>, + ) -> bool { + if !self.search.is_active() || self.detailed { + return false; + } + let next = key.map(|key| (width, key)); + if key.is_some_and(|key| key.cacheable) && self.search.live_key == next { + return false; + } + self.search.live_key = next; + let live = lines(width).map(|lines| Arc::new(TextLayout::new(lines, width))); + let changed = self.search.live.as_ref().map(|layout| layout.text()) + != live.as_ref().map(|layout| layout.text()); + if changed { + self.invalidate_live_search(); + } + self.search.live = live; + changed + } + + pub(super) fn search_layout( + &mut self, + cells: &[Arc], + anchor: Anchor, + ) -> Option> { + if let Some((key, layout)) = &self.search.scanning_layout + && *key == anchor.key + { + return Some(Arc::clone(layout)); + } + let layout = if let Some(layout) = self.search.match_layout(anchor.key) { + layout + } else if anchor.key == EntryKey::Live && !self.detailed { + let live = self + .snapshot() + .and_then(|snapshot| snapshot.search_live.clone()) + .or_else(|| self.search.live.clone()); + match live { + Some(live) if !cells.is_empty() && !self.live_continuation => { + Arc::new(live.rewrap(self.area.width).with_leading_separator()) + } + Some(live) => live, + None => self.layout(cells, anchor.index)?, + } + } else if self.detailed { + self.layout(cells, anchor.index)? + } else { + self.entry_layout(cells, anchor.index, /*full_content*/ true)? + }; + self.search.scanning_layout = Some((anchor.key, Arc::clone(&layout))); + Some(layout) + } +} diff --git a/codex-rs/tui/src/transcript_view/search_tests.rs b/codex-rs/tui/src/transcript_view/search_tests.rs index add8f5e5e7..1c5f449b75 100644 --- a/codex-rs/tui/src/transcript_view/search_tests.rs +++ b/codex-rs/tui/src/transcript_view/search_tests.rs @@ -65,7 +65,7 @@ fn find_keeps_context_and_allows_reading_without_losing_the_query() { view.handle_key(KeyCode::Esc.into(), &cells); insta::assert_snapshot!( view.footer(/*width*/ 80, crate::motion::MotionMode::Reduced).unwrap().text.to_string(), - @"ctrl+n next · ctrl+p previous · esc latest" + @"ctrl+p older · ctrl+n newer · esc latest" ); assert_eq!( ( @@ -723,8 +723,11 @@ fn enter_preserves_the_initial_scan_and_each_older_page_until_the_first_match() .expect("scanning layout") .1, ); - for modifiers in [KeyModifiers::NONE, KeyModifiers::SHIFT] { - view.handle_search_key(KeyEvent::new(KeyCode::Enter, modifiers), &cells); + for key in [ + KeyCode::Enter.into(), + KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL), + ] { + view.handle_search_key(key, &cells); } let Progress::Scanning(after) = view.search.progress else { panic!("confirmation must retain the scan cursor"); @@ -838,13 +841,16 @@ fn next_previous_highlight_and_cancel_share_the_transcript_position() { view.search.current.as_ref().map(|found| found.anchor.index), Some(1) ); - view.handle_search_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT), &cells); + view.handle_search_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), &cells); finish_scan(&mut view, &cells); assert_eq!( view.search.current.as_ref().map(|found| found.anchor.index), Some(0) ); - view.handle_search_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), &cells); + view.handle_search_key( + KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL), + &cells, + ); finish_scan(&mut view, &cells); let mut buffer = Buffer::empty(area); view.render(area, &mut buffer, &cells); @@ -856,7 +862,7 @@ fn next_previous_highlight_and_cancel_share_the_transcript_position() { true, true, true, true, true, true, false, false, false, false ] ); - insta::assert_snapshot!(view.search.status_line(/*width*/ 80, view.history).to_string(), @"enter next · ⌃p previous · full transcript · esc close"); + insta::assert_snapshot!(view.search.status_line(/*width*/ 80, view.history).to_string(), @"enter/⌃p older · ⌃n newer · full transcript · esc close"); view.handle_search_key(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE), &cells); assert!(matches!(view.search.progress, Progress::Found)); view.handle_search_key( @@ -903,7 +909,274 @@ fn legacy_search_shortcuts_navigate_in_both_directions_without_wrapping() { (2, true) ], ); - insta::assert_snapshot!(view.search.status_line(/*width*/ 80, view.history).to_string(), @"No more matches · enter next · ⌃p previous · full transcript · esc close"); + insta::assert_snapshot!(view.search.status_line(/*width*/ 80, view.history).to_string(), @"No more matches · enter/⌃p older · ⌃n newer · full transcript · esc close"); +} + +#[test] +fn find_expands_only_the_match_and_preserves_manual_disclosures() { + let cells: Vec> = ["unrelated", "older needle", "newer needle", "manual"] + .into_iter() + .map(|name| { + Arc::new(crate::exec_cell::ExecCell::new( + crate::exec_cell::ExecCall { + call_id: name.into(), + command: vec![format!("echo visible\necho hidden {name}")], + parsed: Vec::new(), + output: Some(crate::exec_cell::CommandOutput::new( + /*exit_code*/ 0, + String::new(), + )), + source: codex_app_server_protocol::CommandExecutionSource::Agent, + start_time: None, + duration: None, + interaction_input: None, + }, + /*animations_enabled*/ false, + )) as Arc + }) + .collect(); + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 80, /*height*/ 24, + ); + let mut view = TranscriptView::default(); + view.render(area, &mut Buffer::empty(area), &cells); + view.handle_key(KeyCode::F(4).into(), &cells); + view.handle_key(KeyCode::Enter.into(), &cells); + view.begin_search(); + view.paste_search("needle"); + finish_scan(&mut view, &cells); + for (key, expected) in [ + (None, [false, false, true, true]), + (Some(KeyCode::Enter.into()), [false, true, false, true]), + ( + Some(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL)), + [false, false, true, true], + ), + ] { + if let Some(key) = key { + view.handle_key(key, &cells); + finish_scan(&mut view, &cells); + } + assert_eq!( + (0..cells.len()) + .map(|index| view + .layout(&cells, index) + .unwrap() + .text() + .contains("hidden")) + .collect::>(), + expected, + ); + } + view.jump_to_entry(&cells, /*index*/ 0); + let mut buffer = Buffer::empty(area); + view.render(area, &mut buffer, &cells); + insta::assert_snapshot!( + "find_selective_expansion", + crate::transcript_view::tests::text(&buffer) + ); + view.handle_key(KeyCode::Esc.into(), &cells); + // Reading keeps the result and lets the normal activity controls open another block. + view.handle_key(KeyCode::F(4).into(), &cells); + view.handle_key(KeyCode::Home.into(), &cells); + let mut hints = Vec::new(); + for history in [ + TranscriptHistoryState::LoadingOlder, + TranscriptHistoryState::Failed, + TranscriptHistoryState::Complete, + ] { + view.history = history; + hints.push( + view.footer(/*width*/ 80, crate::motion::MotionMode::Reduced) + .unwrap() + .text + .to_string(), + ); + } + insta::assert_snapshot!(hints.join("\n"), @" + ↑ Loading earlier messages… · esc latest + Retry history: ⌥>(), + [true, false, false, true], + ); +} + +#[test] +fn find_searches_hidden_live_content_and_collapses_it_when_leaving_the_match() { + let cells = vec![cell("older needle")]; + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 30, /*height*/ 4, + ); + for compact in [None, Some("compact live")] { + let mut view = TranscriptView { + area, + ..TranscriptView::default() + }; + view.sync_live_tail( + /*width*/ 30, + /*key*/ None, + |_| compact.map(|text| vec![text.into()]), + ); + view.begin_search(); + view.sync_search_live_tail( + /*width*/ 30, + /*key*/ None, + |_| Some(vec!["hidden live needle".into()]), + ); + view.paste_search("needle"); + finish_scan(&mut view, &cells); + assert_eq!( + view.layout(&cells, /*index*/ 1).unwrap().text(), + "hidden live needle" + ); + view.handle_key(KeyCode::Esc.into(), &cells); + view.prepare_width(/*width*/ 20); + assert_eq!( + view.layout(&cells, /*index*/ 1).unwrap().text(), + "hidden live needle" + ); + view.sync_search_live_tail( + /*width*/ 20, + /*key*/ None, + |_| Some(vec!["updated live needle".into()]), + ); + assert_eq!( + view.layout(&cells, /*index*/ 1).unwrap().text(), + "hidden live needle" + ); + view.handle_key( + KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL), + &cells, + ); + finish_scan(&mut view, &cells); + assert_eq!( + view.layout(&cells, /*index*/ 1) + .map(|layout| layout.text().to_owned()) + .as_deref(), + compact + ); + view.handle_key( + KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL), + &cells, + ); + finish_scan(&mut view, &cells); + assert_eq!( + view.layout(&cells, /*index*/ 1).unwrap().text(), + "updated live needle" + ); + } +} + +#[test] +fn selected_hidden_live_match_keeps_its_revision_without_persisting_expansion() { + let cells = vec![cell("older needle")]; + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 32, /*height*/ 6, + ); + let mut view = TranscriptView { + area, + ..TranscriptView::default() + }; + view.sync_live_tail( + /*width*/ 32, + /*key*/ None, + |_| Some(vec!["compact live".into()]), + ); + view.begin_search(); + view.sync_search_live_tail( + /*width*/ 32, + /*key*/ None, + |_| Some(vec!["hidden live needle".into()]), + ); + view.paste_search("needle"); + finish_scan(&mut view, &cells); + let mut found = Buffer::empty(area); + view.render(area, &mut found, &cells); + let row = crate::transcript_view::tests::text(&found) + .lines() + .position(|line| line.contains("hidden live needle")) + .expect("the hidden match is visible") as u16; + view.begin_selection(&cells, /*column*/ 2, row, /*clicks*/ 3); + assert_eq!( + view.selected_text(&cells).as_deref(), + Some("hidden live needle") + ); + + view.sync_live_tail( + /*width*/ 32, + /*key*/ None, + |_| Some(vec!["updated compact live".into()]), + ); + view.sync_search_live_tail( + /*width*/ 32, + /*key*/ None, + |_| Some(vec!["updated live needle".into()]), + ); + view.prepare_width(/*width*/ 14); + assert_eq!( + view.selected_text(&cells).as_deref(), + Some("hidden live needle") + ); + view.end_selection(&cells); + assert_eq!( + view.layout(&cells, /*index*/ 1).unwrap().text(), + "hidden live needle" + ); + view.handle_key( + KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL), + &cells, + ); + finish_scan(&mut view, &cells); + assert_eq!( + view.layout(&cells, /*index*/ 1).unwrap().text(), + "updated compact live" + ); + view.handle_key( + KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL), + &cells, + ); + finish_scan(&mut view, &cells); + assert_eq!( + view.layout(&cells, /*index*/ 1).unwrap().text(), + "updated live needle" + ); } #[test] @@ -945,12 +1218,12 @@ fn find_reveals_hidden_command_output_and_restores_compact_presentation() { view.paste_search("hidden needle"); finish_scan(&mut view, &cells); assert!(view.search.current.is_some()); - assert!(view.is_detailed()); + assert!(!view.is_detailed()); view.handle_search_key( KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL), &cells, ); - assert!(view.is_detailed()); + assert!(!view.is_detailed()); let mut found = Buffer::empty(view.area); view.render(view.area, &mut found, &cells); view.handle_search_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), &cells); @@ -958,7 +1231,7 @@ fn find_reveals_hidden_command_output_and_restores_compact_presentation() { view.render(view.area, &mut reading, &cells); assert_eq!(reading, found); assert!(!view.is_search_editing()); - assert!(view.is_detailed()); + assert!(!view.is_detailed()); view.handle_search_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), &cells); assert_eq!( (view.is_detailed(), view.position), @@ -1049,7 +1322,10 @@ fn failed_history_waits_for_explicit_retry_and_empty_query_cancels_loading() { .to_string(), "⌃p retry · esc close", ); - view.handle_search_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), &cells); + view.handle_search_key( + KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL), + &cells, + ); assert_eq!(view.history, TranscriptHistoryState::Failed); view.handle_search_key( KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL), @@ -1121,7 +1397,10 @@ fn live_match_stays_displayed_after_commit_and_a_fresh_query_searches_current_co let mut buffer = Buffer::empty(area); view.render(area, &mut buffer, &cells); assert_eq!(view.visible[0].layout.text(), "live needle"); - view.handle_search_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), &cells); + view.handle_search_key( + KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL), + &cells, + ); finish_scan(&mut view, &cells); let found = view.search.current.as_ref().expect("new live match"); assert_eq!( @@ -1136,21 +1415,27 @@ fn live_match_stays_displayed_after_commit_and_a_fresh_query_searches_current_co view.render(area, &mut buffer, &cells); assert!(view.held_reading.is_some()); assert_eq!(view.visible[0].layout.text(), "live needle again needle"); - view.handle_search_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), &cells); + view.handle_search_key( + KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL), + &cells, + ); finish_scan(&mut view, &cells); let found = view.search.current.as_ref().expect("new committed match"); assert_eq!( (found.anchor.key, found.anchor.offset..found.end), (EntryKey::cell(&cells[1]), 35..41) ); - view.handle_search_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), &cells); + view.handle_search_key( + KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL), + &cells, + ); finish_scan(&mut view, &cells); let found = view.search.current.as_ref().expect("current live match"); assert_eq!( (found.anchor.key, found.anchor.offset..found.end), (EntryKey::Live, 8..14) ); - view.handle_search_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT), &cells); + view.handle_search_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), &cells); finish_scan(&mut view, &cells); assert!(view.held_reading.is_none()); assert_eq!( @@ -1173,7 +1458,10 @@ fn live_match_stays_displayed_after_commit_and_a_fresh_query_searches_current_co (found.anchor.key, found.anchor.offset..found.end), (EntryKey::Live, 0..7) ); - view.handle_search_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), &cells); + view.handle_search_key( + KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL), + &cells, + ); finish_scan(&mut view, &cells); let found = view .search @@ -1190,7 +1478,10 @@ fn live_match_stays_displayed_after_commit_and_a_fresh_query_searches_current_co /*key*/ None, |_| Some(vec![HyperlinkLine::from("other")]), ); - view.handle_search_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), &cells); + view.handle_search_key( + KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL), + &cells, + ); finish_scan(&mut view, &cells); let found = view .search @@ -1237,10 +1528,16 @@ fn leaving_a_regrouped_search_match_restarts_from_current_history() { ); assert!(view.held_reading.is_some()); - view.handle_search_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), &cells); + view.handle_search_key( + KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL), + &cells, + ); assert!(view.held_reading.is_none()); assert!(view.search.current.is_none()); - view.handle_search_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), &cells); + view.handle_search_key( + KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL), + &cells, + ); finish_scan(&mut view, &cells); let found = view.search.current.as_ref().expect("current group match"); assert_eq!( diff --git a/codex-rs/tui/src/transcript_view/snapshot.rs b/codex-rs/tui/src/transcript_view/snapshot.rs index 0b6f55390b..5073db24a9 100644 --- a/codex-rs/tui/src/transcript_view/snapshot.rs +++ b/codex-rs/tui/src/transcript_view/snapshot.rs @@ -1,4 +1,5 @@ //! Retain displayed revisions while selecting or reading a live tail or a replaced tool group. +//! Pinned layouts respect manual disclosures; Find's expansion is applied only at display time. use std::collections::HashMap; @@ -9,6 +10,8 @@ pub(super) struct ViewSnapshot { pub(super) cells: Arc<[Arc]>, pub(super) pinned: HashMap>, pub(super) activities: HashMap>, + // Hidden live text cannot be reconstructed from the pinned compact display. + pub(super) search_live: Option>, } impl TranscriptView { @@ -51,17 +54,30 @@ impl TranscriptView { .iter() .map(|visible| (visible.key, Arc::clone(&visible.activity_ids))), ); - if let Some(live) = self.layout(&cells, cells.len()) { + if let Some(live) = self.base_layout(&cells, cells.len()) { pinned.insert(EntryKey::Live, live); activities.insert( EntryKey::Live, self.displayed_activity_ids(&cells, cells.len()), ); } + // visible may contain the expanded match. Pin its underlying revision even when + // the hit has moved offscreen, so selection never retains a temporary expansion. + if let Some(anchor) = self.search.match_anchor() { + let index = self.resolve(&cells, anchor); + let base = self + .base_layout(&cells, index) + .unwrap_or_else(|| Arc::new(TextLayout::new(Vec::new(), self.area.width))); + pinned.insert(anchor.key, base); + } ViewSnapshot { cells, pinned, activities, + search_live: self + .snapshot() + .and_then(|snapshot| snapshot.search_live.clone()) + .or_else(|| self.search.live.clone()), } } @@ -92,7 +108,9 @@ impl TranscriptView { { self.held_reading = None; let mut snapshot = self.capture_snapshot(cells); - snapshot.pinned.insert(anchor.key, layout); + if !self.is_search_expanded(anchor.key) { + snapshot.pinned.insert(anchor.key, layout); + } self.held_reading = Some(snapshot); } } else if self.held_reading.is_some() @@ -117,6 +135,9 @@ impl TranscriptView { for layout in snapshot.pinned.values_mut() { *layout = Arc::new(layout.rewrap(width)); } + if let Some(layout) = &mut snapshot.search_live { + *layout = Arc::new(layout.rewrap(width)); + } } } } diff --git a/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__footer__tests__narrow_selection_and_search_keep_complete_exit_hints.snap b/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__footer__tests__narrow_selection_and_search_keep_complete_exit_hints.snap index 08d18c1f77..074c5bd4ed 100644 --- a/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__footer__tests__narrow_selection_and_search_keep_complete_exit_hints.snap +++ b/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__footer__tests__narrow_selection_and_search_keep_complete_exit_hints.snap @@ -7,6 +7,6 @@ search=false, 20 columns: enter copy+↓ · esc search=false, 32 columns: enter copy & follow · esc clear search=false, 80 columns: ⌃c copy · enter copy & follow · esc clear search=true, 12 columns: esc close -search=true, 20 columns: enter next · esc -search=true, 32 columns: enter next · esc close -search=true, 80 columns: enter next · ⌃p previous · full transcript · esc close +search=true, 20 columns: enter older · esc +search=true, 32 columns: enter older · esc close +search=true, 80 columns: enter/⌃p older · ⌃n newer · full transcript · esc close diff --git a/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__footer__tests__transcript_footer_keys_remain_distinct_from_prose_and_query_text.snap b/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__footer__tests__transcript_footer_keys_remain_distinct_from_prose_and_query_text.snap index 55b7d8ad63..29e8efed3f 100644 --- a/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__footer__tests__transcript_footer_keys_remain_distinct_from_prose_and_query_text.snap +++ b/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__footer__tests__transcript_footer_keys_remain_distinct_from_prose_and_query_text.snap @@ -6,17 +6,17 @@ Buffer { area: Rect { x: 0, y: 0, width: 80, height: 2 }, content: [ "Find: Activity ", - "enter next · ⌃p previous · full transcript · esc close ", + "enter/⌃p older · ⌃n newer · full transcript · esc close ", ], styles: [ x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, x: 6, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, - x: 5, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 13, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, - x: 15, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 45, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, - x: 48, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 54, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 8, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 17, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, + x: 19, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 46, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD, + x: 49, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 55, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, ] } diff --git a/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__search__tests__find_selective_expansion.snap b/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__search__tests__find_selective_expansion.snap new file mode 100644 index 0000000000..0d2d28c15b --- /dev/null +++ b/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__search__tests__find_selective_expansion.snap @@ -0,0 +1,19 @@ +--- +source: tui/src/transcript_view/search_tests.rs +expression: "crate::transcript_view::tests::text(&buffer)" +--- +• Running 'echo visible … + └ (no output) + + Show details + +• Running 'echo visible … + └ (no output) + + Show details + +$ 'echo visible + echo hidden newer needle' + − Show less + +$ 'echo visible + echo hidden manual' + − Show less diff --git a/codex-rs/tui/src/transcript_view_tests.rs b/codex-rs/tui/src/transcript_view_tests.rs index 54dbe15e25..e5c7c47fb4 100644 --- a/codex-rs/tui/src/transcript_view_tests.rs +++ b/codex-rs/tui/src/transcript_view_tests.rs @@ -831,7 +831,10 @@ fn mutable_history_formats_once_per_frame_and_refreshes_the_next_frame() { )), matched ); - view.handle_key(KeyCode::Enter.into(), &cells); + view.handle_key( + KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL), + &cells, + ); assert!(!view.advance_search(&cells)); assert!( text(&render(