Route pastes into the active history search query (#45262)

## 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
This commit is contained in:
Charlie Marsh
2026-09-13 17:08:45 +00:00
committed by copyberry
parent 516f2780fd
commit a505c71490
7 changed files with 285 additions and 31 deletions

View File

@@ -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;

View File

@@ -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<Line<'static>> {
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;

View File

@@ -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<TestBackend> {
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());
}
}

View File

@@ -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 "

View File

@@ -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))

View File

@@ -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 "

View File

@@ -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))