From 61c42396abe1c5c406cc28f37a89e8e9214b9193 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Fri, 27 Feb 2026 16:19:11 -0800 Subject: [PATCH] Keep large-paste placeholders intact during file completion (#13070) Addresses https://github.com/openai/codex/issues/13040 Fixes a regression in 0.106.0 introduced in https://github.com/openai/codex/pull/9393 Summary - replace only the active completion range so unrelated text elements (e.g., large-paste placeholders) stay atomic and can still expand - add a regression test verifying large paste placeholders persist through completions and submit - could not fetch issue details via GitHub API because network access is disabled in this sandboxed environment --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 69 ++++++++++++++++--- 1 file changed, 59 insertions(+), 10 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index fe40f4fa7..5ea67d8c3 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -2085,16 +2085,10 @@ impl ChatComposer { path.to_string() }; - // Replace the slice `[start_idx, end_idx)` with the chosen path and a trailing space. - let mut new_text = - String::with_capacity(text.len() - (end_idx - start_idx) + inserted.len() + 1); - new_text.push_str(&text[..start_idx]); - new_text.push_str(&inserted); - new_text.push(' '); - new_text.push_str(&text[end_idx..]); - - // Path replacement is plain text; rebuild without carrying elements. - self.textarea.set_text_clearing_elements(&new_text); + // Replace just the active `@token` so unrelated text elements, such as + // large-paste placeholders, remain atomic and can still expand on submit. + self.textarea + .replace_range(start_idx..end_idx, &format!("{inserted} ")); let new_cursor = start_idx.saturating_add(inserted.len()).saturating_add(1); self.textarea.set_cursor(new_cursor); } @@ -6931,6 +6925,61 @@ mod tests { } } + #[test] + fn file_completion_preserves_large_paste_placeholder_elements() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + let large = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 5); + let placeholder = format!("[Pasted Content {} chars]", large.chars().count()); + + composer.handle_paste(large.clone()); + composer.insert_str(" @ma"); + composer.on_file_search_result( + "ma".to_string(), + vec![FileMatch { + score: 1, + path: PathBuf::from("src/main.rs"), + root: PathBuf::from("/tmp"), + indices: None, + }], + ); + + let (_result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + + let text = composer.textarea.text().to_string(); + assert_eq!(text, format!("{placeholder} src/main.rs ")); + let elements = composer.textarea.text_elements(); + assert_eq!(elements.len(), 1); + assert_eq!(elements[0].placeholder(&text), Some(placeholder.as_str())); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + match result { + InputResult::Submitted { + text, + text_elements, + } => { + assert_eq!(text, format!("{large} src/main.rs")); + assert!(text_elements.is_empty()); + } + _ => panic!("expected Submitted"), + } + } + /// Behavior: multiple paste operations can coexist; placeholders should be expanded to their /// original content on submission. #[test]