diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index 655c0abd9a..c7be652f85 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -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 { + let normalized = pasted.split_whitespace().collect::>().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>> = 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(|_| {});