diff --git a/codex-rs/tui/src/transcript_view/input.rs b/codex-rs/tui/src/transcript_view/input.rs index 6e3ebd290d..c52b388d9a 100644 --- a/codex-rs/tui/src/transcript_view/input.rs +++ b/codex-rs/tui/src/transcript_view/input.rs @@ -202,6 +202,21 @@ impl TranscriptView { return Some(action); } if self.select_mode.is_some() { + if self.is_search_editing() { + let (code, modifiers) = + crate::key_hint::normalize_key_parts(key.code, key.modifiers); + let accepting = code == KeyCode::Enter && modifiers == KeyModifiers::NONE; + let cancelling = code == KeyCode::Esc + || (code == KeyCode::Char('c') && modifiers == KeyModifiers::CONTROL); + if self.handle_search_key(key, cells) { + if accepting && self.search.is_reading() { + self.accept_search_into_select_mode(cells); + } else if cancelling { + self.restore_select_search_origin(); + } + return Some(ViewAction::Changed); + } + } return self .handle_select_mode_key(key, cells) .or(Some(ViewAction::Changed)); diff --git a/codex-rs/tui/src/transcript_view/search.rs b/codex-rs/tui/src/transcript_view/search.rs index b69e3c536c..7511992c39 100644 --- a/codex-rs/tui/src/transcript_view/search.rs +++ b/codex-rs/tui/src/transcript_view/search.rs @@ -13,6 +13,7 @@ use crossterm::event::KeyModifiers; use std::ops::Range; use unicode_segmentation::UnicodeSegmentation; +use super::select_mode::SelectSearchOrigin; use super::*; #[path = "search_footer.rs"] @@ -47,12 +48,20 @@ enum Progress { Exhausted, } +#[derive(Clone)] struct Match { anchor: Anchor, end: usize, layout: Arc, } +struct SavedSelectSearch { + query: String, + current: Option, + progress: Progress, + page_start: Option, +} + #[derive(Clone, Copy, Eq, PartialEq)] enum SearchMode { Closed, @@ -68,6 +77,7 @@ pub(super) struct Search { saved_snapshot: Option, saved_detailed: bool, current: Option, + saved_select: Option, progress: Progress, scanning_layout: Option<(EntryKey, Arc)>, page_start: Option, @@ -86,6 +96,7 @@ impl Default for Search { saved_snapshot: None, saved_detailed: false, current: None, + saved_select: None, progress: Progress::Idle, scanning_layout: None, page_start: None, @@ -103,13 +114,42 @@ impl TranscriptView { return; } self.copy_mode = None; - let saved_position = self.position; - let saved_snapshot = self - .selection - .take() - .map(|selection| selection.snapshot) - .or(self.held_reading.take()); if self.search.is_reading() { + self.pin_current_search_match(); + } + if let (Some(mode), Some(selection)) = (&mut self.select_mode, &mut self.selection) { + let cursor = mode.cursor(selection); + mode.search_origin = Some(SelectSearchOrigin { + mark: mode.mark, + preferred_column: mode.preferred_column, + start: selection.start, + end: selection.end, + }); + mode.mark = None; + selection.set_keyboard_range(cursor, cursor); + } + let saved_position = self.position; + let saved_snapshot = if self.select_mode.is_some() { + None + } else { + self.selection + .take() + .map(|selection| selection.snapshot) + .or(self.held_reading.take()) + }; + if self.search.is_reading() { + if self.select_mode.is_some() { + self.search.saved_select = Some(SavedSelectSearch { + query: self.search.editor.text().to_string(), + current: self.search.current.clone(), + progress: self.search.progress, + page_start: self.search.page_start, + }); + self.search.editor.set_text_clearing_elements(""); + self.search.saved_position = saved_position; + self.search.saved_snapshot = None; + self.search.saved_detailed = self.detailed; + } self.held_reading = saved_snapshot; self.search.mode = SearchMode::Editing; if self.search.current.is_none() { @@ -161,6 +201,9 @@ impl TranscriptView { return modifiers == KeyModifiers::NONE; } (KeyCode::Enter, KeyModifiers::NONE) => { + if self.search.editor.is_empty() && self.restore_saved_select_search() { + return true; + } // Accept the displayed match even if finding an older one is still pending. // An idle nonempty query can also mean scrolling released a live match. // Leave initial scans and empty/unmatched queries open until they find a hit. @@ -175,6 +218,7 @@ impl TranscriptView { _ => Progress::Idle, }; self.search.scanning_layout = None; + self.search.saved_select = None; } } (KeyCode::Char('n'), KeyModifiers::CONTROL) => { @@ -233,6 +277,9 @@ impl TranscriptView { if !self.search.is_active() { return; } + if self.select_mode.is_some() && self.restore_saved_select_search() { + return; + } let position = self.search.saved_position; let snapshot = self.search.saved_snapshot.take().or_else(|| { let Position::Reading(anchor) = position else { @@ -256,6 +303,7 @@ impl TranscriptView { self.search.editor.set_text_clearing_elements(""); self.search.query_changed(); self.search.progress = Progress::Idle; + self.search.saved_select = None; self.search.live = None; self.search.live_key = None; self.position = position; @@ -263,6 +311,122 @@ impl TranscriptView { self.rewrap_snapshot(self.area.width); } + pub(super) fn accept_search_into_select_mode(&mut self, cells: &[Arc]) { + let Some(found) = self.search.current.as_ref() else { + self.search.mode = SearchMode::Editing; + return; + }; + let anchor = found.anchor; + let layout = Arc::clone(&found.layout); + self.move_select_cursor_to_search_match(anchor, &layout, cells); + } + + pub(super) fn restore_select_search_origin(&mut self) { + let (Some(mode), Some(selection)) = (&mut self.select_mode, &mut self.selection) else { + return; + }; + let Some(origin) = mode.search_origin.take() else { + return; + }; + mode.mark = origin.mark; + mode.preferred_column = origin.preferred_column; + selection.set_keyboard_range(origin.start, origin.end); + } + + pub(super) fn cancel_pending_select_search(&mut self) { + if self.search.is_reading() + && matches!( + self.search.progress, + Progress::Scanning(_) | Progress::AwaitingHistory + ) + { + self.search.progress = if self.search.current.is_some() { + Progress::Found + } else { + Progress::Idle + }; + self.search.scanning_layout = None; + } + } + + pub(super) fn repeat_select_search( + &mut self, + cells: &[Arc], + newer: bool, + ) -> bool { + if !self.search.is_reading() { + return false; + } + let direction = if newer { + Direction::Newer + } else { + Direction::Older + }; + let Some(cursor) = self + .select_mode + .zip(self.selection.as_ref()) + .map(|(mode, selection)| mode.cursor(selection)) + else { + return false; + }; + self.pin_current_search_match(); + let index = self.resolve(cells, cursor); + let Some(layout) = self.layout(cells, index) else { + return false; + }; + let offset = layout + .text() + .floor_char_boundary(cursor.offset.min(layout.text().len())); + let offset = if direction == Direction::Newer { + let row = layout + .row_for_offset(offset) + .saturating_add_signed(cursor.row_bias.saturating_neg()); + layout.after_cursor_grapheme(row, offset) + } else { + offset + }; + self.search.current = None; + self.search.scanning_layout = None; + self.search.page_start = None; + self.search.progress = Progress::Scanning(Cursor { + anchor: Anchor { + key: self.entry_key(cells, index), + index, + offset, + row_bias: 0, + }, + direction, + }); + true + } + + /// Drop Find's transient expansion after cursor mode exits while preserving its paused view. + pub(super) fn close_search_after_select_mode(&mut self) { + if !self.search.is_active() { + return; + } + self.pin_current_search_match(); + self.search.mode = SearchMode::Closed; + self.search.editor.set_text_clearing_elements(""); + self.search.query_changed(); + self.search.progress = Progress::Idle; + self.search.saved_select = None; + self.search.live = None; + self.search.live_key = None; + self.rewrap_snapshot(self.area.width); + } + + fn pin_current_search_match(&mut self) { + let retained_match = self + .search + .current + .as_ref() + .map(|found| (found.anchor.key, Arc::clone(&found.layout))); + if let (Some((key, layout)), Some(snapshot)) = (retained_match, self.snapshot_mut()) { + snapshot.pinned.insert(key, layout); + } + } + pub(crate) fn paste_search(&mut self, text: &str) -> bool { if !self.is_search_editing() { return false; @@ -334,6 +498,14 @@ impl TranscriptView { if let Some(found) = &mut self.search.current { found.layout = Arc::new(found.layout.rewrap(width)); } + if let Some(found) = self + .search + .saved_select + .as_mut() + .and_then(|saved| saved.current.as_mut()) + { + found.layout = Arc::new(found.layout.rewrap(width)); + } self.area.width = width; if self.search.current.is_none() || !matches!(self.search.progress, Progress::Found | Progress::Exhausted) @@ -342,9 +514,17 @@ impl TranscriptView { } } + fn restore_saved_select_search(&mut self) -> bool { + if !self.search.restore_saved_select() { + return false; + } + self.position = self.search.saved_position; + true + } + /// Return true only while another local scanning frame can make progress. pub(crate) fn advance_search(&mut self, cells: &[Arc]) -> bool { - if self.selection.is_some() { + if self.selection.is_some() && self.select_mode.is_none() { return false; } let current_cells = cells; @@ -427,11 +607,11 @@ impl TranscriptView { ..anchor }); } - if !matches!(self.search.progress, Progress::AwaitingHistory) || inserted.is_empty() { + if inserted.is_empty() { return; } let index = inserted.end - 1; - self.search.progress = Progress::Scanning(Cursor { + let cursor = Cursor { anchor: Anchor { key: self.entry_key(cells, index), index, @@ -439,14 +619,34 @@ impl TranscriptView { row_bias: 0, }, direction: Direction::Older, - }); + }; + let page_start = self.entry_key(cells, inserted.start); + if let Some(saved) = &mut self.search.saved_select + && matches!( + saved.progress, + Progress::AwaitingHistory + | Progress::Scanning(Cursor { + direction: Direction::Older, + .. + }) + ) + { + if matches!(saved.progress, Progress::AwaitingHistory) { + saved.progress = Progress::Scanning(cursor); + } + saved.page_start = Some(page_start); + } + if !matches!(self.search.progress, Progress::AwaitingHistory) { + return; + } + self.search.progress = Progress::Scanning(cursor); self.search.scanning_layout = None; - self.search.page_start = Some(self.entry_key(cells, inserted.start)); + self.search.page_start = Some(page_start); } pub(super) fn render_search(&self, buf: &mut Buffer) { // The copy range takes precedence until selection ends. - if self.selection.is_some() { + if !self.search.is_active() || self.has_selection_range() { return; } let Some(found) = &self.search.current else { @@ -700,6 +900,20 @@ impl Search { self.progress = Progress::Restart; self.query_truncated = false; } + + fn restore_saved_select(&mut self) -> bool { + let Some(saved) = self.saved_select.take() else { + return false; + }; + self.editor.set_text_clearing_elements(&saved.query); + self.folded_query = saved.query.chars().flat_map(char::to_lowercase).collect(); + self.current = saved.current; + self.progress = saved.progress; + self.scanning_layout = None; + self.page_start = saved.page_start; + self.mode = SearchMode::Reading; + true + } } fn scan_window(text: &str, cursor: Cursor, query_len: usize) -> Range { diff --git a/codex-rs/tui/src/transcript_view/search_presentation.rs b/codex-rs/tui/src/transcript_view/search_presentation.rs index fc8963bce3..912d2a0e9f 100644 --- a/codex-rs/tui/src/transcript_view/search_presentation.rs +++ b/codex-rs/tui/src/transcript_view/search_presentation.rs @@ -31,6 +31,9 @@ impl TranscriptView { end, layout: Arc::clone(&layout), }); + if self.search.is_reading() { + self.move_select_cursor_to_search_match(anchor, &layout, current_cells); + } 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) diff --git a/codex-rs/tui/src/transcript_view/search_tests.rs b/codex-rs/tui/src/transcript_view/search_tests.rs index 1ddcb869b9..8308ca55c1 100644 --- a/codex-rs/tui/src/transcript_view/search_tests.rs +++ b/codex-rs/tui/src/transcript_view/search_tests.rs @@ -886,9 +886,20 @@ fn search_scans_a_page_inserted_after_the_retained_session_header() { view.paste_search("needle"); finish_scan(&mut view, &cells); assert!(view.search.needs_history(view.history)); - cells.insert(/*index*/ 1, cell("older NEEDLE")); + view.search.saved_select = Some(SavedSelectSearch { + query: "needle".to_string(), + current: None, + progress: view.search.progress, + page_start: view.search.page_start, + }); + view.search.progress = Progress::Restart; + cells.insert(/*index*/ 1, cell("older messages")); + view.history = TranscriptHistoryState::Partial; + view.history_loaded(&cells, 1..2); + cells.insert(/*index*/ 1, cell("oldest NEEDLE")); view.history = TranscriptHistoryState::Complete; view.history_loaded(&cells, 1..2); + assert!(view.search.restore_saved_select()); finish_scan(&mut view, &cells); let found = view .search @@ -902,7 +913,7 @@ fn search_scans_a_page_inserted_after_the_retained_session_header() { found.anchor.offset, found.end ), - (EntryKey::cell(&cells[1]), 1, 6, 12) + (EntryKey::cell(&cells[1]), 1, 7, 13) ); assert!(Arc::ptr_eq(&cells[0], &header)); } diff --git a/codex-rs/tui/src/transcript_view/select_mode.rs b/codex-rs/tui/src/transcript_view/select_mode.rs index 44e1d571a2..5a2077f7ae 100644 --- a/codex-rs/tui/src/transcript_view/select_mode.rs +++ b/codex-rs/tui/src/transcript_view/select_mode.rs @@ -13,6 +13,7 @@ use crossterm::event::KeyEventKind; use crossterm::event::KeyModifiers; use ratatui::buffer::Buffer; use ratatui::layout::Rect; +use unicode_segmentation::UnicodeSegmentation; mod motion; @@ -20,6 +21,7 @@ mod motion; pub(super) struct SelectMode { pub(super) mark: Option, pub(super) preferred_column: Option, + pub(super) search_origin: Option, } #[derive(Clone, Copy)] @@ -31,6 +33,14 @@ pub(super) struct Mark { unit: SelectionUnit, } +#[derive(Clone, Copy)] +pub(super) struct SelectSearchOrigin { + pub(super) mark: Option, + pub(super) preferred_column: Option, + pub(super) start: Anchor, + pub(super) end: Anchor, +} + #[derive(Clone, Copy)] enum CursorMotion { Character(KeyCode), @@ -81,7 +91,7 @@ impl SelectMode { } impl TranscriptView { - /// Enter at the bottommost visible selectable position. + /// Enter at the accepted Find hit, preferring the bottommost visible selectable position. #[cfg_attr(not(test), expect(dead_code, reason = "used by a later stack layer"))] pub(crate) fn begin_select_mode(&mut self, cells: &[Arc]) -> bool { if self.select_mode.is_some() { @@ -92,17 +102,18 @@ impl TranscriptView { let has_range = self.has_selection_range(); if self.selection.is_none() { let cursor = self - .visible - .iter() - .rev() - .find_map(|visible| { - let offset = visible.layout.first_cursor_position(visible.row)?; - Some(Anchor { - key: visible.key, - index: visible.index, - offset, - row_bias: visible.layout.row_for_offset(offset) as isize - - visible.row as isize, + .search + .match_anchor() + .or_else(|| { + self.visible.iter().rev().find_map(|visible| { + let offset = visible.layout.first_cursor_position(visible.row)?; + Some(Anchor { + key: visible.key, + index: visible.index, + offset, + row_bias: visible.layout.row_for_offset(offset) as isize + - visible.row as isize, + }) }) }) .or_else(|| self.select_boundary(cells, /*end*/ true)); @@ -118,6 +129,8 @@ impl TranscriptView { self.select_mode = Some(SelectMode::default()); if has_range { self.adopt_select_mouse_range(); + } else if self.search.is_reading() { + self.accept_search_into_select_mode(cells); } self.reveal_select_cursor(cells); true @@ -175,6 +188,10 @@ impl TranscriptView { if pressed && code == KeyCode::Char('q') && modifiers == KeyModifiers::NONE { return Some(self.exit_select_mode()); } + if pressed && code == KeyCode::Char('/') && modifiers == KeyModifiers::NONE { + self.begin_search(); + return Some(ViewAction::Changed); + } if pressed && matches!(code, KeyCode::Char('v') | KeyCode::Char(' ')) && modifiers == KeyModifiers::NONE @@ -190,6 +207,13 @@ impl TranscriptView { self.swap_select_ends(cells); return Some(ViewAction::Changed); } + if code == KeyCode::Char('n') + && matches!(modifiers, KeyModifiers::NONE | KeyModifiers::SHIFT) + && self.repeat_select_search(cells, modifiers == KeyModifiers::SHIFT) + { + return Some(ViewAction::Changed); + } + let shift_arrow = modifiers == KeyModifiers::SHIFT && matches!( code, @@ -253,10 +277,12 @@ impl TranscriptView { fn exit_select_mode(&mut self) -> ViewAction { self.end_select_mode(); + self.close_search_after_select_mode(); ViewAction::ExitSelectMode } fn start_select_mark(&mut self, cells: &[Arc], unit: SelectionUnit) { + self.cancel_pending_select_search(); let Some(mut mode) = self.select_mode else { return; }; @@ -289,6 +315,7 @@ impl TranscriptView { } fn clear_select_mark(&mut self) { + self.cancel_pending_select_search(); let Some(mode) = &mut self.select_mode else { return; }; @@ -302,6 +329,7 @@ impl TranscriptView { } fn swap_select_ends(&mut self, cells: &[Arc]) { + self.cancel_pending_select_search(); let Some(mode) = &mut self.select_mode else { return; }; @@ -368,6 +396,7 @@ impl TranscriptView { } pub(super) fn adopt_select_mouse_range(&mut self) { + self.cancel_pending_select_search(); let has_range = self.has_selection_range(); let Some(selection) = &self.selection else { return; @@ -413,6 +442,54 @@ impl TranscriptView { mode.mark = mark; } } + + pub(super) fn move_select_cursor_to_search_match( + &mut self, + mut anchor: Anchor, + layout: &TextLayout, + cells: &[Arc], + ) { + let text = layout.text(); + let containing = text + .grapheme_indices(/*is_extended*/ true) + .rfind(|(offset, _)| *offset <= anchor.offset) + .map(|(offset, _)| offset); + anchor.offset = containing + .filter(|offset| layout.is_cursor_position(*offset)) + .or_else(|| { + text.grapheme_indices(/*is_extended*/ true) + .map(|(offset, _)| offset) + .find(|offset| *offset >= anchor.offset && layout.is_cursor_position(*offset)) + }) + .or_else(|| { + text.grapheme_indices(/*is_extended*/ true) + .map(|(offset, _)| offset) + .rfind(|offset| layout.is_cursor_position(*offset)) + }) + .unwrap_or(anchor.offset); + + let (Some(mut mode), Some(mut selection)) = + (self.select_mode.take(), self.selection.take()) + else { + return; + }; + if let Some(origin) = mode.search_origin.take() { + mode.mark = origin.mark; + mode.preferred_column = origin.preferred_column; + selection.set_keyboard_range(origin.start, origin.end); + } + mode.preferred_column = None; + if let Some(mark) = &mut mode.mark { + mark.active = anchor; + mark.active_edges = None; + } else { + selection.set_keyboard_range(anchor, anchor); + } + self.select_mode = Some(mode); + self.selection = Some(selection); + self.sync_select_range(cells); + self.reveal_select_cursor(cells); + } } #[cfg(test)] diff --git a/codex-rs/tui/src/transcript_view/select_mode/motion.rs b/codex-rs/tui/src/transcript_view/select_mode/motion.rs index 911f29c623..ff36090b84 100644 --- a/codex-rs/tui/src/transcript_view/select_mode/motion.rs +++ b/codex-rs/tui/src/transcript_view/select_mode/motion.rs @@ -10,6 +10,7 @@ impl TranscriptView { cells: &[Arc], motion: CursorMotion, ) { + self.cancel_pending_select_search(); let Some(mut mode) = self.select_mode else { return; }; diff --git a/codex-rs/tui/src/transcript_view/select_mode_tests.rs b/codex-rs/tui/src/transcript_view/select_mode_tests.rs index a2dc9202f6..1694572aa2 100644 --- a/codex-rs/tui/src/transcript_view/select_mode_tests.rs +++ b/codex-rs/tui/src/transcript_view/select_mode_tests.rs @@ -12,7 +12,6 @@ fn cell(lines: &[&str]) -> Arc { .collect(), )) } - fn render(view: &mut TranscriptView, cells: &[Arc]) -> Buffer { let area = Rect::new( /*x*/ 0, /*y*/ 0, /*width*/ 32, /*height*/ 6, @@ -351,3 +350,261 @@ fn confirmed_copy_keeps_the_select_mode_caret_on_the_active_grapheme() { press(&mut view, &cells, KeyCode::Char('v')); assert_eq!(view.selected_text(&cells).as_deref(), Some("b")); } + +#[test] +fn find_accepts_at_the_match_and_cancel_restores_the_saved_selection() { + let mut cells = vec![cell(&["alpha beta gamma"])]; + for _ in 0..6 { + cells.push(cell(&["filler"])); + } + cells.push(cell(&["distant"])); + let mut view = TranscriptView::default(); + render(&mut view, &cells); + assert!(view.begin_select_mode(&cells)); + press(&mut view, &cells, KeyCode::Char('/')); + assert!(view.paste_search("gamma")); + while view.advance_search(&cells) {} + press(&mut view, &cells, KeyCode::Enter); + let accepted_cursor = view + .select_mode + .expect("select mode") + .cursor(view.selection.as_ref().expect("selection")); + let accepted_position = view.position; + + press(&mut view, &cells, KeyCode::Char('/')); + assert!(view.paste_search("distant")); + while view.advance_search(&cells) {} + render(&mut view, &cells); + press(&mut view, &cells, KeyCode::Esc); + + assert_eq!(view.position, accepted_position); + assert_eq!( + view.select_mode + .expect("select mode") + .cursor(view.selection.as_ref().expect("selection")), + accepted_cursor + ); + press(&mut view, &cells, KeyCode::Char('v')); + press(&mut view, &cells, KeyCode::Right); + assert_eq!(view.selected_text(&cells).as_deref(), Some("ga")); + + press(&mut view, &cells, KeyCode::Char('/')); + let narrow = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 8, /*height*/ 6, + ); + view.render(narrow, &mut Buffer::empty(narrow), &cells); + press(&mut view, &cells, KeyCode::Esc); + view.render(narrow, &mut Buffer::empty(narrow), &cells); + + press(&mut view, &cells, KeyCode::Char('/')); + assert!(matches!( + view.handle_key( + KeyEvent::new(KeyCode::Char('\u{3}'), KeyModifiers::NONE), + &cells, + ), + Some(ViewAction::Changed) + )); + assert_eq!(view.selected_text(&cells).as_deref(), Some("ga")); + + press(&mut view, &cells, KeyCode::Char('/')); + assert!(view.paste_search("alpha")); + while view.advance_search(&cells) {} + press(&mut view, &cells, KeyCode::Enter); + assert_eq!( + view.select_mode + .expect("select mode") + .cursor(view.selection.as_ref().expect("selection")) + .offset, + 0 + ); + assert_eq!(view.selected_text(&cells).as_deref(), Some("alpha beta g")); + + let key = accepted_cursor.key; + view.select_mode = None; + view.selection + .as_mut() + .expect("selection") + .set_keyboard_range( + Anchor { + offset: 6, + ..accepted_cursor + }, + Anchor { + key, + offset: 10, + ..accepted_cursor + }, + ); + assert!(view.begin_select_mode(&cells)); + assert_eq!(view.selected_text(&cells).as_deref(), Some("beta")); + + for (query, expected_offset, expected_text) in [("\u{301}", 0, "a\u{301}"), ("\nbeta", 4, "b")] + { + let cells = vec![cell(&["a\u{301}", "beta"])]; + let mut view = TranscriptView::default(); + render(&mut view, &cells); + assert!(view.begin_select_mode(&cells)); + press(&mut view, &cells, KeyCode::Char('/')); + assert!(view.paste_search(query)); + while view.advance_search(&cells) {} + press(&mut view, &cells, KeyCode::Enter); + assert_eq!( + view.select_mode + .expect("select mode") + .cursor(view.selection.as_ref().expect("selection")) + .offset, + expected_offset + ); + press(&mut view, &cells, KeyCode::Char('v')); + assert_eq!(view.selected_text(&cells).as_deref(), Some(expected_text)); + } +} + +#[test] +fn accepted_find_repeats_into_the_select_cursor() { + let cells = vec![cell(&["needle first needle second"])]; + let mut view = TranscriptView::default(); + render(&mut view, &cells); + assert!(view.begin_select_mode(&cells)); + press(&mut view, &cells, KeyCode::Char('/')); + assert!(view.paste_search("needle")); + while view.advance_search(&cells) {} + press(&mut view, &cells, KeyCode::Enter); + assert_eq!( + view.select_mode + .expect("select mode") + .cursor(view.selection.as_ref().expect("selection")) + .offset, + 13 + ); + + press(&mut view, &cells, KeyCode::Char('/')); + press(&mut view, &cells, KeyCode::Enter); + assert_eq!( + view.select_mode + .expect("select mode") + .cursor(view.selection.as_ref().expect("selection")) + .offset, + 13 + ); + + press(&mut view, &cells, KeyCode::Char('n')); + press(&mut view, &cells, KeyCode::Right); + while view.advance_search(&cells) {} + assert_eq!( + view.select_mode + .expect("select mode") + .cursor(view.selection.as_ref().expect("selection")) + .offset, + 14 + ); + + press(&mut view, &cells, KeyCode::Char('n')); + while view.advance_search(&cells) {} + assert_eq!( + view.select_mode + .expect("select mode") + .cursor(view.selection.as_ref().expect("selection")) + .offset, + 0 + ); + + press(&mut view, &cells, KeyCode::Char('v')); + assert!(matches!( + view.handle_key( + KeyEvent::new(KeyCode::Char('n'), KeyModifiers::SHIFT), + &cells, + ), + Some(ViewAction::Changed) + )); + while view.advance_search(&cells) {} + let mode = view.select_mode.expect("select mode"); + assert!(mode.mark.is_some()); + assert_eq!( + mode.cursor(view.selection.as_ref().expect("selection")) + .offset, + 13 + ); +} + +#[test] +fn accepted_find_pins_an_expanded_match_before_select_mode_exits() { + let output = format!( + "{}hidden needle\n{}", + "head\n".repeat(/*n*/ 20), + "tail\n".repeat(/*n*/ 20) + ); + let command = crate::exec_cell::ExecCell::new( + crate::exec_cell::ExecCall { + call_id: "command".to_string(), + command: vec!["echo".to_string()], + parsed: Vec::new(), + output: Some(crate::exec_cell::CommandOutput::new( + /*exit_code*/ 0, output, + )), + source: codex_app_server_protocol::CommandExecutionSource::Agent, + start_time: None, + duration: None, + interaction_input: None, + }, + /*animations_enabled*/ false, + ); + let cells = vec![Arc::new(command) as Arc]; + let mut view = TranscriptView::default(); + render(&mut view, &cells); + assert!(view.begin_select_mode(&cells)); + press(&mut view, &cells, KeyCode::Char('/')); + assert!(view.paste_search("needle")); + while view.advance_search(&cells) {} + press(&mut view, &cells, KeyCode::Enter); + + let anchor = view.search.match_anchor().expect("accepted hidden match"); + render(&mut view, &cells); + let cursor = view + .select_mode + .expect("select mode") + .cursor(view.selection.as_ref().expect("selection")); + let index = view.resolve(&cells, cursor); + let row = view + .layout(&cells, index) + .expect("cursor layout") + .row_for_offset(cursor.offset); + assert!( + view.visible + .iter() + .any(|visible| visible.index == index && visible.row == row) + ); + let expanded = view + .search + .match_layout(anchor.key) + .expect("expanded search layout") + .text() + .to_owned(); + press(&mut view, &cells, KeyCode::Char('/')); + press(&mut view, &cells, KeyCode::Esc); + assert_eq!( + view.selection + .as_ref() + .expect("selection") + .snapshot + .pinned + .get(&anchor.key) + .expect("expanded origin pinned") + .text(), + expanded + ); + assert!(matches!( + view.handle_key(KeyCode::Char('q').into(), &cells), + Some(ViewAction::ExitSelectMode) + )); + assert_eq!( + view.held_reading + .as_ref() + .expect("paused snapshot") + .pinned + .get(&anchor.key) + .expect("expanded match pinned") + .text(), + expanded + ); +}