Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
  • Loading branch information
charliemarsh-oai authored and copyberry committed Sep 13, 2026
commit a505c71490885a44979df056284badbfdd75b3fb
17 changes: 13 additions & 4 deletions codex-rs/tui/src/bottom_pane/chat_composer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand Down
60 changes: 33 additions & 27 deletions codex-rs/tui/src/bottom_pane/chat_composer/history_search.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -213,31 +221,25 @@ 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 {
code: KeyCode::Char('u'),
modifiers: KeyModifiers::CONTROL,
..
} => {
self.update_history_search_query(String::new());
self.update_history_search_query(String::clear);
(InputResult::None, true)
}
KeyEvent {
code: KeyCode::Char(ch),
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),
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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 => {}
Expand Down Expand Up @@ -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)
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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());
}
}
Original file line number Diff line number Diff line change
@@ -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 "
Original file line number Diff line number Diff line change
@@ -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))
Loading
Loading