feat(tui): handle paste in session picker (#23338)

## Why

The session picker already supports typed search, but it ignored
bracketed paste events entirely. On macOS terminals this makes pasted
text look like a no-op on the resume screen, which is especially
noticeable when a user wants to paste part of a thread name, branch, or
path into the search field.

## What Changed

- route `TuiEvent::Paste(String)` into the session picker instead of
dropping it
- normalize pasted search text into a single-line query by collapsing
whitespace
- ignore whitespace-only pastes
- reuse the existing `set_query(...)` path so pasted searches keep the
same filtering and pagination behavior as typed input
- add focused tests for append behavior, whitespace normalization,
whitespace-only paste, and the existing search-loading path

This PR is stacked on top of #23234 and contains only the net change
relative to `etraut/clarify-resume-hints`.

## How to Test

1. Start Codex in a terminal that emits bracketed paste, for example
iTerm2 on macOS.
2. Open the resume picker so the search UI is visible.
3. Copy a term that should match one of the visible sessions, then paste
it into the picker.
4. Confirm the query updates immediately and the list filters as if the
text had been typed.
5. Also verify that pasting text with newlines or tabs still produces a
usable single-line search query.
6. Also verify that normal typed search still works and that `Esc` still
clears the query / exits as before.

Targeted tests:
- `cargo test -p codex-tui`

---------

Co-authored-by: Eric Traut <etraut@openai.com>
This commit is contained in:
Felipe Coury
2026-05-18 16:04:41 -03:00
committed by GitHub
parent 55f6bbc667
commit 8e52578e66

View File

@@ -477,6 +477,9 @@ async fn run_session_picker_with_loader(
return Ok(sel);
}
}
TuiEvent::Paste(pasted) => {
state.handle_paste(pasted);
}
TuiEvent::Draw | TuiEvent::Resize => {
if let Ok(size) = alt.tui.terminal.size() {
let list_height =
@@ -489,7 +492,6 @@ async fn run_session_picker_with_loader(
state.open_pending_transcript_if_ready();
}
}
_ => {}
}
}
Some(event) = background_events.next() => {
@@ -543,6 +545,11 @@ fn picker_cwd_filter(
}
}
fn normalize_pasted_query(pasted: &str) -> Option<String> {
let normalized = pasted.split_whitespace().collect::<Vec<_>>().join(" ");
(!normalized.is_empty()).then_some(normalized)
}
fn spawn_app_server_page_loader(
app_server: AppServerSession,
include_non_interactive: bool,
@@ -1227,6 +1234,21 @@ impl PickerState {
Ok(None)
}
fn handle_paste(&mut self, pasted: String) {
if self.is_transcript_loading() {
return;
}
let Some(pasted) = normalize_pasted_query(&pasted) else {
return;
};
let mut new_query = self.query.clone();
if !new_query.is_empty() && !new_query.ends_with(char::is_whitespace) {
new_query.push(' ');
}
new_query.push_str(&pasted);
self.set_query(new_query);
}
fn start_initial_load(&mut self) {
self.relative_time_reference = Some(Utc::now());
self.reset_pagination();
@@ -6218,6 +6240,87 @@ session_picker_view = "dense"
assert!(state.pagination.reached_scan_cap);
}
#[tokio::test]
async fn paste_appends_to_existing_query() {
let loader = page_only_loader(|_| {});
let mut state = PickerState::new(
FrameRequester::test_dummy(),
loader,
ProviderFilter::MatchDefault(String::from("openai")),
/*show_all*/ true,
/*filter_cwd*/ None,
SessionPickerAction::Resume,
);
state.query = String::from("resize");
state.handle_paste(String::from("results"));
assert_eq!(state.query, "resize results");
}
#[test]
fn normalize_pasted_query_collapses_whitespace() {
assert_eq!(
normalize_pasted_query(" alpha\n\tbeta\r\n gamma "),
Some(String::from("alpha beta gamma"))
);
}
#[tokio::test]
async fn whitespace_only_paste_is_ignored() {
let loader = page_only_loader(|_| {});
let mut state = PickerState::new(
FrameRequester::test_dummy(),
loader,
ProviderFilter::MatchDefault(String::from("openai")),
/*show_all*/ true,
/*filter_cwd*/ None,
SessionPickerAction::Resume,
);
state.query = String::from("resize");
state.handle_paste(String::from(" \n\t "));
assert_eq!(state.query, "resize");
}
#[tokio::test]
async fn paste_uses_existing_search_loading_path() {
let recorded_requests: Arc<Mutex<Vec<PageLoadRequest>>> = Arc::new(Mutex::new(Vec::new()));
let request_sink = recorded_requests.clone();
let loader = page_only_loader(move |req: PageLoadRequest| {
request_sink.lock().unwrap().push(req);
});
let mut state = PickerState::new(
FrameRequester::test_dummy(),
loader,
ProviderFilter::MatchDefault(String::from("openai")),
/*show_all*/ true,
/*filter_cwd*/ None,
SessionPickerAction::Resume,
);
state.reset_pagination();
state.ingest_page(page(
vec![make_row(
"/tmp/start.jsonl",
"2025-01-01T00:00:00Z",
"alpha",
)],
Some("2025-01-02T00:00:00Z"),
/*num_scanned_files*/ 1,
/*reached_scan_cap*/ false,
));
recorded_requests.lock().unwrap().clear();
state.handle_paste(String::from("target"));
let guard = recorded_requests.lock().unwrap();
assert_eq!(state.query, "target");
assert_eq!(guard.len(), 1);
assert!(guard[0].search_token.is_some());
}
#[tokio::test]
async fn esc_with_empty_query_starts_fresh() {
let loader = page_only_loader(|_| {});