diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 3a3f5d407be..e3fe43db7fa 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -37,6 +37,17 @@ //! The numeric auto-submit path used by the slash popup performs the same pending-paste expansion //! and attachment pruning, and clears pending paste state on success. //! +//! # Large Paste Placeholders +//! +//! Large pastes insert an element placeholder in the buffer and store the full text in +//! `pending_pastes`. The placeholder label is derived from the pasted character count: +//! +//! - First paste of a given size uses `[Pasted Content N chars]`. +//! - Additional pending pastes of the same size add a numeric suffix (`#2`, `#3`, ...), where the +//! next suffix is computed from the placeholders that still exist in `pending_pastes`. +//! - When all placeholders for a size are deleted (which removes their pending entries), the next +//! paste of that size reuses the base label without a suffix. +//! //! # Non-bracketed Paste Bursts //! //! On some terminals (especially on Windows), pastes arrive as a rapid sequence of @@ -256,7 +267,6 @@ pub(crate) struct ChatComposer { dismissed_file_popup_token: Option, current_file_query: Option, pending_pastes: Vec<(String, String)>, - large_paste_counters: HashMap, has_focus: bool, /// Invariant: attached images are labeled `[Image #1]..[Image #N]` in vec order. attached_images: Vec, @@ -347,7 +357,6 @@ impl ChatComposer { dismissed_file_popup_token: None, current_file_query: None, pending_pastes: Vec::new(), - large_paste_counters: HashMap::new(), has_focus: has_input_focus, attached_images: Vec::new(), placeholder_text, @@ -891,14 +900,27 @@ impl ChatComposer { .is_some_and(|expires_at| Instant::now() < expires_at) } - fn next_large_paste_placeholder(&mut self, char_count: usize) -> String { + fn next_large_paste_placeholder(&self, char_count: usize) -> String { let base = format!("[Pasted Content {char_count} chars]"); - let next_suffix = self.large_paste_counters.entry(char_count).or_insert(0); - *next_suffix += 1; - if *next_suffix == 1 { + let prefix = format!("{base} #"); + let mut max_suffix = 0usize; + + for (placeholder, _) in &self.pending_pastes { + if placeholder == &base { + max_suffix = max_suffix.max(1); + continue; + } + if let Some(suffix) = placeholder.strip_prefix(&prefix) + && let Ok(value) = suffix.parse::() + { + max_suffix = max_suffix.max(value); + } + } + + if max_suffix == 0 { base } else { - format!("{base} #{next_suffix}") + format!("{base} #{}", max_suffix + 1) } } @@ -4662,8 +4684,8 @@ mod tests { assert_eq!(composer.pending_pastes[0].1, paste); } - /// Behavior: large-paste placeholder numbering does not get reused after deletion, so a new - /// paste of the same length gets a new unique placeholder label. + /// Behavior: large-paste placeholder numbering continues when another placeholder of the + /// same length still exists, so a new paste gets a new unique placeholder label. #[test] fn large_paste_numbering_does_not_reuse_after_deletion() { use crossterm::event::KeyCode; @@ -4704,6 +4726,42 @@ mod tests { assert_eq!(composer.pending_pastes[1].0, third); } + /// Behavior: if all placeholders of a given length are removed, numbering resets to the + /// base placeholder on the next paste. + #[test] + fn large_paste_numbering_reuses_after_all_deleted() { + 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 paste = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 4); + let base = format!("[Pasted Content {} chars]", paste.chars().count()); + + composer.handle_paste(paste.clone()); + assert_eq!(composer.textarea.text(), base); + assert_eq!(composer.pending_pastes.len(), 1); + + composer.textarea.set_cursor(composer.textarea.text().len()); + composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); + assert!(composer.textarea.text().is_empty()); + assert!(composer.pending_pastes.is_empty()); + + composer.handle_paste(paste); + assert_eq!(composer.textarea.text(), base); + assert_eq!(composer.pending_pastes.len(), 1); + assert_eq!(composer.pending_pastes[0].0, base); + } + #[test] fn test_partial_placeholder_deletion() { use crossterm::event::KeyCode; diff --git a/docs/tui-chat-composer.md b/docs/tui-chat-composer.md index 01b5334d940..70e57556d2d 100644 --- a/docs/tui-chat-composer.md +++ b/docs/tui-chat-composer.md @@ -115,6 +115,17 @@ positional args, Enter auto-submits without calling `prepare_submission_text`. T - Prunes attachments based on expanded placeholders. - Clears pending pastes after a successful auto-submit. +### Large paste placeholders + +Large pastes (over `LARGE_PASTE_CHAR_THRESHOLD`) insert an element placeholder and store the full +text in `pending_pastes`. Placeholder labels are derived from the pasted character count: + +- First paste of a given size uses `[Pasted Content N chars]`. +- Additional pending pastes of the same size add a numeric suffix (`#2`, `#3`, ...), where the + next suffix is computed from placeholders that still exist in `pending_pastes`. +- When all placeholders for a size are deleted (which removes their pending entries), the next + paste of that size reuses the base label without a suffix. + ## Paste burst: concepts and assumptions The burst detector is intentionally conservative: it only processes “plain” character input