diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 909ecbc0..986dfd61 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -34,6 +34,13 @@ use std::cell::RefCell; use std::collections::HashMap; use std::path::Path; use std::path::PathBuf; +use std::time::Duration; +use std::time::Instant; + +// Heuristic thresholds for detecting paste-like input bursts. +const PASTE_BURST_MIN_CHARS: u16 = 3; +const PASTE_BURST_CHAR_INTERVAL: Duration = Duration::from_millis(8); +const PASTE_ENTER_SUPPRESS_WINDOW: Duration = Duration::from_millis(120); /// If the pasted content exceeds this number of characters, replace it with a /// placeholder in the UI. @@ -82,6 +89,13 @@ pub(crate) struct ChatComposer { has_focus: bool, attached_images: Vec, placeholder_text: String, + // Heuristic state to detect non-bracketed paste bursts. + last_plain_char_time: Option, + consecutive_plain_char_burst: u16, + paste_burst_until: Option, + // Buffer to accumulate characters during a detected non-bracketed paste burst. + paste_burst_buffer: String, + in_paste_burst_mode: bool, } /// Popup state – at most one can be visible at any time. @@ -115,6 +129,11 @@ impl ChatComposer { has_focus: has_input_focus, attached_images: Vec::new(), placeholder_text, + last_plain_char_time: None, + consecutive_plain_char_burst: 0, + paste_burst_until: None, + paste_burst_buffer: String::new(), + in_paste_burst_mode: false, } } @@ -202,6 +221,10 @@ impl ChatComposer { } else { self.textarea.insert_str(&pasted); } + // Explicit paste events should not trigger Enter suppression. + self.last_plain_char_time = None; + self.consecutive_plain_char_burst = 0; + self.paste_burst_until = None; self.sync_command_popup(); self.sync_file_search_popup(); true @@ -614,6 +637,60 @@ impl ChatComposer { modifiers: KeyModifiers::NONE, .. } => { + // If we're in a paste-like burst capture, treat Enter as part of the burst + // and accumulate it rather than submitting or inserting immediately. + // Do not treat Enter as paste inside a slash-command context. + let in_slash_context = matches!(self.active_popup, ActivePopup::Command(_)) + || self + .textarea + .text() + .lines() + .next() + .unwrap_or("") + .starts_with('/'); + if (self.in_paste_burst_mode || !self.paste_burst_buffer.is_empty()) + && !in_slash_context + { + self.paste_burst_buffer.push('\n'); + let now = Instant::now(); + // Keep the window alive so subsequent lines are captured too. + self.paste_burst_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW); + return (InputResult::None, true); + } + // If we have pending placeholder pastes, submit immediately to expand them. + if !self.pending_pastes.is_empty() { + let mut text = self.textarea.text().to_string(); + self.textarea.set_text(""); + for (placeholder, actual) in &self.pending_pastes { + if text.contains(placeholder) { + text = text.replace(placeholder, actual); + } + } + self.pending_pastes.clear(); + if text.is_empty() { + return (InputResult::None, true); + } + self.history.record_local_submission(&text); + return (InputResult::Submitted(text), true); + } + + // During a paste-like burst, treat Enter as a newline instead of submit. + let now = Instant::now(); + let tight_after_char = self + .last_plain_char_time + .is_some_and(|t| now.duration_since(t) <= PASTE_BURST_CHAR_INTERVAL); + let recent_after_char = self + .last_plain_char_time + .is_some_and(|t| now.duration_since(t) <= PASTE_ENTER_SUPPRESS_WINDOW); + let burst_by_count = + recent_after_char && self.consecutive_plain_char_burst >= PASTE_BURST_MIN_CHARS; + let in_burst_window = self.paste_burst_until.is_some_and(|until| now <= until); + + if tight_after_char || burst_by_count || in_burst_window { + self.textarea.insert_str("\n"); + self.paste_burst_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW); + return (InputResult::None, true); + } let mut text = self.textarea.text().to_string(); self.textarea.set_text(""); @@ -645,6 +722,94 @@ impl ChatComposer { /// Handle generic Input events that modify the textarea content. fn handle_input_basic(&mut self, input: KeyEvent) -> (InputResult, bool) { + // If we have a buffered non-bracketed paste burst and enough time has + // elapsed since the last char, flush it before handling a new input. + let now = Instant::now(); + let timed_out = self + .last_plain_char_time + .is_some_and(|t| now.duration_since(t) > PASTE_BURST_CHAR_INTERVAL); + if timed_out && (!self.paste_burst_buffer.is_empty() || self.in_paste_burst_mode) { + let pasted = std::mem::take(&mut self.paste_burst_buffer); + self.in_paste_burst_mode = false; + // Reuse normal paste path (handles large-paste placeholders). + self.handle_paste(pasted); + } + + // If we're capturing a burst and receive Enter, accumulate it instead of inserting. + if matches!(input.code, KeyCode::Enter) + && (self.in_paste_burst_mode || !self.paste_burst_buffer.is_empty()) + { + self.paste_burst_buffer.push('\n'); + self.paste_burst_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW); + return (InputResult::None, true); + } + + // Intercept plain Char inputs to optionally accumulate into a burst buffer. + if let KeyEvent { + code: KeyCode::Char(ch), + modifiers, + .. + } = input + { + let has_ctrl_or_alt = + modifiers.contains(KeyModifiers::CONTROL) || modifiers.contains(KeyModifiers::ALT); + if !has_ctrl_or_alt { + // Update burst heuristics. + match self.last_plain_char_time { + Some(prev) if now.duration_since(prev) <= PASTE_BURST_CHAR_INTERVAL => { + self.consecutive_plain_char_burst = + self.consecutive_plain_char_burst.saturating_add(1); + } + _ => { + self.consecutive_plain_char_burst = 1; + } + } + self.last_plain_char_time = Some(now); + + // If we're already buffering, capture the char into the buffer. + if self.in_paste_burst_mode { + self.paste_burst_buffer.push(ch); + // Keep the window alive while we receive the burst. + self.paste_burst_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW); + return (InputResult::None, true); + } else if self.consecutive_plain_char_burst >= PASTE_BURST_MIN_CHARS { + // Do not start burst buffering while typing a slash command (first line starts with '/'). + let first_line = self.textarea.text().lines().next().unwrap_or(""); + if first_line.starts_with('/') { + // Keep heuristics but do not buffer. + self.paste_burst_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW); + // Insert normally. + self.textarea.input(input); + let text_after = self.textarea.text(); + self.pending_pastes + .retain(|(placeholder, _)| text_after.contains(placeholder)); + return (InputResult::None, true); + } + // Begin buffering from this character onward. + self.paste_burst_buffer.push(ch); + self.in_paste_burst_mode = true; + // Keep the window alive to continue capturing. + self.paste_burst_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW); + return (InputResult::None, true); + } + + // Not buffering: insert normally and continue. + self.textarea.input(input); + let text_after = self.textarea.text(); + self.pending_pastes + .retain(|(placeholder, _)| text_after.contains(placeholder)); + return (InputResult::None, true); + } else { + // Modified char ends any burst: flush buffered content before applying. + if !self.paste_burst_buffer.is_empty() || self.in_paste_burst_mode { + let pasted = std::mem::take(&mut self.paste_burst_buffer); + self.in_paste_burst_mode = false; + self.handle_paste(pasted); + } + } + } + + // For non-char inputs (or after flushing), handle normally. // Special handling for backspace on placeholders if let KeyEvent { code: KeyCode::Backspace, @@ -659,6 +824,37 @@ impl ChatComposer { self.textarea.input(input); let text_after = self.textarea.text(); + // Update paste-burst heuristic for plain Char (no Ctrl/Alt) events. + let crossterm::event::KeyEvent { + code, modifiers, .. + } = input; + match code { + KeyCode::Char(_) => { + let has_ctrl_or_alt = modifiers.contains(KeyModifiers::CONTROL) + || modifiers.contains(KeyModifiers::ALT); + if has_ctrl_or_alt { + // Modified char: clear burst window. + self.consecutive_plain_char_burst = 0; + self.last_plain_char_time = None; + self.paste_burst_until = None; + self.in_paste_burst_mode = false; + self.paste_burst_buffer.clear(); + } + // Plain chars handled above. + } + KeyCode::Enter => { + // Keep burst window alive (supports blank lines in paste). + } + _ => { + // Other keys: clear burst window and any buffer (after flushing earlier). + self.consecutive_plain_char_burst = 0; + self.last_plain_char_time = None; + self.paste_burst_until = None; + self.in_paste_burst_mode = false; + // Do not clear paste_burst_buffer here; it should have been flushed above. + } + } + // Check if any placeholders were removed and remove their corresponding pending pastes self.pending_pastes .retain(|(placeholder, _)| text_after.contains(placeholder));