mirror of
https://github.com/openai/codex.git
synced 2026-09-15 12:08:01 +00:00
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:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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 "
|
||||
@@ -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))
|
||||
@@ -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 "
|
||||
@@ -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))
|
||||
Reference in New Issue
Block a user