From a505c71490885a44979df056284badbfdd75b3fb Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Sun, 13 Sep 2026 17:08:45 +0000 Subject: [PATCH] Route pastes into the active history search query (#45262) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why Pasting during `Ctrl+R` history search previously went through normal composer paste handling instead of updating the search query. ## What changed - Append sanitized pasted text to the active query and restart matching from the newest history entry, including for large pastes and image paths. - Ignore empty pastes so they preserve the selected match. - Display newlines and tabs as `↵` and `⇥` in the footer while matching the original query, and clamp the cursor safely for very large queries. ## Testing Add regression tests and snapshots covering pasted query acceptance, empty pastes, draft restoration on misses and cancellation, sanitization, large pastes, image paths, separator rendering, and cursor placement. GitOrigin-RevId: 2ec13fdddcb7503a72a21fc03b2a27ed36313a8a --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 17 +- .../chat_composer/history_search.rs | 60 +++--- .../history_search_paste_tests.rs | 195 ++++++++++++++++++ ...ste_tests__history_search_empty_paste.snap | 10 + ...ts__history_search_large_paste_cursor.snap | 12 ++ ...te_tests__history_search_pasted_query.snap | 10 + ...sts__history_search_pasted_separators.snap | 12 ++ 7 files changed, 285 insertions(+), 31 deletions(-) create mode 100644 codex-rs/tui/src/bottom_pane/chat_composer/history_search_paste_tests.rs create mode 100644 codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__history_search__paste_tests__history_search_empty_paste.snap create mode 100644 codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__history_search__paste_tests__history_search_large_paste_cursor.snap create mode 100644 codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__history_search__paste_tests__history_search_pasted_query.snap create mode 100644 codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__history_search__paste_tests__history_search_pasted_separators.snap diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 7c3d64193b..2ffcafade3 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -60,6 +60,7 @@ //! Recall moves the cursor to the end. Question editors copy primary history on recall/search; //! draft capture cancels previews, and restoration resets traversal. //! Ctrl+R searches history in the footer and previews matches in the composer. +//! Typing and pasting edit the active search query, including large pastes and image paths. //! Enter accepts the preview; Esc restores the original draft. //! Vim undo/redo snapshots complete drafts and groups direct edits with active Vim transactions. //! An active edit keeps one separately capped snapshot; canceling does not evict committed history. @@ -1208,17 +1209,25 @@ impl ChatComposer { /// /// Behavior: /// - /// - If the paste is larger than `LARGE_PASTE_CHAR_THRESHOLD` chars, inserts a placeholder - /// element (expanded on submit) and stores the full text in `pending_pastes`. + /// - If history search is active, inserts nonempty text into its query and ignores empty pastes. + /// - If Vim search is active, inserts text into its query. + /// - Otherwise, if the paste is larger than `LARGE_PASTE_CHAR_THRESHOLD` chars, inserts a + /// placeholder element (expanded on submit) and stores the full text in `pending_pastes`. /// - Otherwise, if the paste looks like an image path, attaches the image and inserts a /// trailing space so the user can keep typing naturally. /// - Otherwise, inserts the pasted text directly into the textarea. /// - /// In all cases, clears any paste-burst Enter suppression state so a real paste cannot affect - /// the next user Enter key, then syncs popup state. + /// For composer edits, clears any paste-burst Enter suppression state so a real paste cannot + /// affect the next user Enter key, then syncs popup state. pub fn handle_paste(&mut self, pasted: String) -> bool { let pasted = pasted.replace("\r\n", "\n").replace('\r', "\n"); let pasted = sanitize_user_text(pasted.into()); + if self.history_search.is_some() { + if !pasted.is_empty() { + self.update_history_search_query(|query| query.push_str(&pasted)); + } + return true; + } if let Some(query) = self.draft.textarea.vim_query_mut() { query.editor.insert_str(&pasted); return true; diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/history_search.rs b/codex-rs/tui/src/bottom_pane/chat_composer/history_search.rs index 892e8d00fd..419a3b6f25 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/history_search.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/history_search.rs @@ -13,7 +13,7 @@ //! traversal invariants stay with `ChatComposerHistory`. //! //! A search session starts idle with an empty footer query, so opening Ctrl+R never previews the -//! latest history entry by itself. Typing a query restarts traversal from newest to oldest, +//! latest history entry by itself. Typing or pasting a query restarts traversal from newest to oldest, //! repeated Ctrl+R/Up and Ctrl+S/Down move between unique matches, `Enter` accepts the current //! preview as an editable draft, and `Esc` or Ctrl+C restores the exact draft that existed before //! search started. @@ -61,12 +61,20 @@ pub(super) struct HistorySearchSession { original_vim_history: VimHistory, /// Active and completed Vim commands suspended during temporary draft replacement. original_vim_state: VimPersistentState, - /// Footer-owned query text typed while Ctrl+R search is active. + /// Footer-owned query text typed or pasted while Ctrl+R search is active. query: String, /// User-visible search status used to choose footer hints and composer preview behavior. status: HistorySearchStatus, } +impl HistorySearchSession { + /// Renders newlines and tabs as visible markers for the footer and cursor placement. + /// Matching continues to use the original query. + fn display_query(&self) -> String { + self.query.replace('\n', "↵").replace('\t', "⇥") + } +} + /// User-visible phase of the active Ctrl+R search session. /// /// Search keeps the footer query and the composer preview separate: `Idle` leaves the original @@ -213,11 +221,9 @@ impl ChatComposer { modifiers: KeyModifiers::CONTROL, .. } => { - if let Some(search) = self.history_search.as_ref() { - let mut query = search.query.clone(); + self.update_history_search_query(|query| { query.pop(); - self.update_history_search_query(query); - } + }); (InputResult::None, true) } KeyEvent { @@ -225,7 +231,7 @@ impl ChatComposer { modifiers: KeyModifiers::CONTROL, .. } => { - self.update_history_search_query(String::new()); + self.update_history_search_query(String::clear); (InputResult::None, true) } KeyEvent { @@ -233,11 +239,7 @@ impl ChatComposer { modifiers, .. } if !has_ctrl_or_alt(modifiers) => { - if let Some(search) = self.history_search.as_ref() { - let mut query = search.query.clone(); - query.push(ch); - self.update_history_search_query(query); - } + self.update_history_search_query(|query| query.push(ch)); (InputResult::None, true) } _ => (InputResult::None, true), @@ -270,18 +272,16 @@ impl ChatComposer { InputResult::None } - fn update_history_search_query(&mut self, query: String) { - let Some(original_draft) = self - .history_search - .as_ref() - .map(|search| search.original_draft.clone()) - else { + /// Edits the footer query and restarts history traversal from the newest entry. + /// An empty query restores the original draft and leaves search open. + pub(super) fn update_history_search_query(&mut self, edit: impl FnOnce(&mut String)) { + let Some(search) = self.history_search.as_mut() else { return; }; - if let Some(search) = self.history_search.as_mut() { - search.query = query.clone(); - search.status = HistorySearchStatus::Searching; - } + edit(&mut search.query); + search.status = HistorySearchStatus::Searching; + let query = search.query.clone(); + let original_draft = search.original_draft.clone(); self.restore_draft(original_draft); if query.is_empty() { self.history.reset_search(); @@ -367,14 +367,15 @@ impl ChatComposer { /// Builds the footer line shown while reverse history search is active. /// /// The footer displays the query as the editable field and uses the status to decide whether - /// to show searching, match actions, or no-match feedback. The line is intentionally separate - /// from cursor placement so rendering can fall back to normal footer layout if a small terminal - /// cannot allocate a distinct hint row. + /// to show searching, match actions, or no-match feedback. Newlines and tabs use visible markers + /// while matching keeps the original query. The line is intentionally separate from cursor + /// placement so rendering can fall back to normal footer layout if a small terminal cannot + /// allocate a distinct hint row. pub(super) fn history_search_footer_line(&self) -> Option> { let search = self.history_search.as_ref()?; let mut line = Line::from(vec![ "reverse-i-search: ".dim(), - search.query.clone().cyan(), + search.display_query().cyan(), ]); match search.status { HistorySearchStatus::Idle => {} @@ -502,7 +503,8 @@ impl ChatComposer { return None; } let prompt_width = Line::from("reverse-i-search: ").width() as u16; - let query_width = Line::from(search.query.clone()).width() as u16; + let query_width = + u16::try_from(Line::from(search.display_query()).width()).unwrap_or(u16::MAX); let desired_x = area .x .saturating_add(prompt_width) @@ -512,6 +514,10 @@ impl ChatComposer { } } +#[cfg(test)] +#[path = "history_search_paste_tests.rs"] +mod paste_tests; + #[cfg(test)] mod tests { use crossterm::event::KeyCode; diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/history_search_paste_tests.rs b/codex-rs/tui/src/bottom_pane/chat_composer/history_search_paste_tests.rs new file mode 100644 index 0000000000..f8c6c0d9c5 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer/history_search_paste_tests.rs @@ -0,0 +1,195 @@ +//! Pasted text edits the active history query while preserving the original draft. + +use super::super::super::chat_composer_history::HistoryEntry; +use super::super::ChatComposer; +use super::super::InputResult; +use super::super::LARGE_PASTE_CHAR_THRESHOLD; +use crate::app_event_sender::AppEventSender; +use crate::render::renderable::Renderable; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyModifiers; +use pretty_assertions::assert_eq; +use ratatui::Terminal; +use ratatui::backend::TestBackend; +use tokio::sync::mpsc::unbounded_channel; + +fn composer_with_history() -> ChatComposer { + let (tx, _rx) = unbounded_channel(); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + AppEventSender::new(tx), + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + for entry in ["git status", "git log"] { + composer + .history + .record_local_submission(HistoryEntry::new(entry.to_string())); + } + composer.set_text_content("draft".to_string(), Vec::new(), Vec::new()); + composer +} + +fn render_composer(composer: &ChatComposer, width: u16) -> Terminal { + let mut terminal = Terminal::new(TestBackend::new(width, /*height*/ 5)).unwrap(); + terminal + .draw(|frame| composer.render(frame.area(), frame.buffer_mut())) + .unwrap(); + terminal +} + +#[test] +fn history_search_paste_appends_query_and_accepts_match() { + let mut composer = composer_with_history(); + composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); + assert!(composer.handle_paste("git".to_string())); + assert_eq!(composer.draft.textarea.text(), "git log"); + + composer.handle_key_event(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); + assert!(composer.handle_paste("status".to_string())); + assert_eq!( + composer.history_search.as_ref().unwrap().query, + "git status" + ); + assert_eq!(composer.draft.textarea.text(), "git status"); + + let terminal = render_composer(&composer, /*width*/ 70); + insta::assert_snapshot!("history_search_pasted_query", terminal.backend()); + + let (result, _) = composer.handle_key_event(KeyCode::Enter.into()); + assert!(matches!(result, InputResult::None)); + assert!(!composer.history_search_active()); + assert_eq!(composer.draft.textarea.text(), "git status"); +} + +#[test] +fn history_search_large_paste_clamps_cursor() { + let mut composer = composer_with_history(); + composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); + composer.handle_paste("x".repeat(usize::from(u16::MAX) + 1)); + + let terminal = render_composer(&composer, /*width*/ 80); + let cursor = composer.history_search_cursor_pos(terminal.size().unwrap().into()); + assert_eq!(cursor, Some((79, 4))); + insta::assert_snapshot!( + "history_search_large_paste_cursor", + format!("{}\nCursor: {cursor:?}", terminal.backend()) + ); +} + +#[test] +fn history_search_empty_paste_preserves_selected_match() { + let mut composer = composer_with_history(); + let reverse_search = KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL); + composer.handle_key_event(reverse_search); + composer.handle_paste("git".to_string()); + assert_eq!(composer.draft.textarea.text(), "git log"); + composer.handle_key_event(reverse_search); + assert_eq!(composer.draft.textarea.text(), "git status"); + let selected_draft = composer.snapshot_draft(); + + for paste in ["", "\x1b[31m\x1b[0m"] { + composer.handle_paste(paste.to_string()); + assert_eq!(composer.snapshot_draft(), selected_draft); + } + + let terminal = render_composer(&composer, /*width*/ 70); + insta::assert_snapshot!("history_search_empty_paste", terminal.backend()); +} + +#[test] +fn history_search_paste_shows_separators_and_matches_original_query() { + let mut composer = composer_with_history(); + for entry in ["foobarbaz", "foo↵bar⇥baz"] { + composer + .history + .record_local_submission(HistoryEntry::new(entry.to_string())); + } + let query = "foo\nbar\tbaz"; + let reverse_search = KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL); + composer.handle_key_event(reverse_search); + composer.handle_paste(query.to_string()); + assert_eq!(composer.history_search.as_ref().unwrap().query, query); + assert_eq!(composer.draft.textarea.text(), "draft"); + + let terminal = render_composer(&composer, /*width*/ 70); + let cursor = composer.history_search_cursor_pos(terminal.size().unwrap().into()); + assert_eq!(cursor, Some((31, 4))); + insta::assert_snapshot!( + "history_search_pasted_separators", + format!("{}\nCursor: {cursor:?}", terminal.backend()) + ); + + composer.handle_key_event(KeyCode::Esc.into()); + composer + .history + .record_local_submission(HistoryEntry::new(query.to_string())); + composer.handle_key_event(reverse_search); + composer.handle_paste(query.to_string()); + composer.handle_key_event(KeyCode::Enter.into()); + assert!(!composer.history_search_active()); + assert_eq!(composer.draft.textarea.text(), query); +} + +#[test] +fn history_search_paste_preserves_original_draft_on_miss_and_cancel() { + let mut composer = composer_with_history(); + composer.handle_paste("x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 1)); + composer.draft.textarea.set_cursor(/*pos*/ 2); + let original_draft = composer.snapshot_draft(); + + for cancel_key in [ + KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), + KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL), + ] { + composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); + composer.handle_paste("git".to_string()); + assert_eq!(composer.draft.textarea.text(), "git log"); + + composer.handle_paste(" missing".to_string()); + assert_eq!(composer.snapshot_draft(), original_draft); + assert_eq!( + composer.history_search.as_ref().unwrap().query, + "git missing" + ); + + composer.handle_key_event(cancel_key); + assert!(!composer.history_search_active()); + assert_eq!(composer.snapshot_draft(), original_draft); + } +} + +#[test] +fn history_search_paste_uses_full_sanitized_text() { + let temp = tempfile::tempdir().unwrap(); + let image_path = temp.path().join("history.png"); + image::RgbaImage::new(/*width*/ 1, /*height*/ 1) + .save(&image_path) + .unwrap(); + let image_path = image_path.to_string_lossy().into_owned(); + let large_paste = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 1); + + for (pasted, query) in [ + (image_path.clone(), image_path), + (large_paste.clone(), large_paste), + ( + "é\r\n中\r\x1b[31mtext\x1b[0m".to_string(), + "é\n中\ntext".to_string(), + ), + ] { + let mut composer = composer_with_history(); + composer + .history + .record_local_submission(HistoryEntry::new(query.clone())); + composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); + composer.handle_paste(pasted); + + assert_eq!(composer.history_search.as_ref().unwrap().query, query); + assert_eq!(composer.draft.textarea.text(), query); + assert!(composer.attachments.local_image_paths().is_empty()); + assert!(composer.draft.pending_pastes.is_empty()); + assert!(composer.current_text_elements().is_empty()); + } +} diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__history_search__paste_tests__history_search_empty_paste.snap b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__history_search__paste_tests__history_search_empty_paste.snap new file mode 100644 index 0000000000..3f02c08a36 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__history_search__paste_tests__history_search_empty_paste.snap @@ -0,0 +1,10 @@ +--- +source: tui/src/bottom_pane/chat_composer/history_search_paste_tests.rs +assertion_line: 101 +expression: terminal.backend() +--- +" " +"› git status " +" " +" " +" reverse-i-search: git enter accept · esc cancel " diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__history_search__paste_tests__history_search_large_paste_cursor.snap b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__history_search__paste_tests__history_search_large_paste_cursor.snap new file mode 100644 index 0000000000..5ff09c1b55 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__history_search__paste_tests__history_search_large_paste_cursor.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/bottom_pane/chat_composer/history_search_paste_tests.rs +assertion_line: 75 +expression: "format!(\"{}\\nCursor: {cursor:?}\", terminal.backend())" +--- +" " +"› draft " +" " +" " +" reverse-i-search: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + +Cursor: Some((79, 4)) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__history_search__paste_tests__history_search_pasted_query.snap b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__history_search__paste_tests__history_search_pasted_query.snap new file mode 100644 index 0000000000..44c520d77b --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__history_search__paste_tests__history_search_pasted_query.snap @@ -0,0 +1,10 @@ +--- +source: tui/src/bottom_pane/chat_composer/history_search_paste_tests.rs +assertion_line: 51 +expression: terminal.backend() +--- +" " +"› git status " +" " +" " +" reverse-i-search: git status enter accept · esc cancel " diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__history_search__paste_tests__history_search_pasted_separators.snap b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__history_search__paste_tests__history_search_pasted_separators.snap new file mode 100644 index 0000000000..ed43df1994 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__history_search__paste_tests__history_search_pasted_separators.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/bottom_pane/chat_composer/history_search_paste_tests.rs +assertion_line: 125 +expression: "format!(\"{}\\nCursor: {cursor:?}\", terminal.backend())" +--- +" " +"› draft " +" " +" " +" reverse-i-search: foo↵bar⇥baz no match " + +Cursor: Some((31, 4))