From c9ca63dc1e7ff89abcf6d4972561c20f9a1f11e3 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Thu, 28 Aug 2025 12:54:12 -0700 Subject: [PATCH] burst paste edge cases (#2683) This PR fixes two edge cases in managing burst paste (mainly on power shell). Bugs: - Needs an event key after paste to render the pasted items > ChatComposer::flush_paste_burst_if_due() flushes on timeout. Called: > - Pre-render in App on TuiEvent::Draw. > - Via a delayed frame > BottomPane::request_redraw_in(ChatComposer::recommended_paste_flush_delay()). - Parses two key events separately before starting parsing burst paste > When threshold is crossed, pull preceding burst chars out of the textarea and prepend to paste_burst_buffer, then keep buffering. - Integrates with #2567 to bring image pasting to windows. --- codex-rs/core/src/config.rs | 13 + codex-rs/tui/src/app.rs | 6 + .../src/bottom_pane/approval_modal_view.rs | 1 + codex-rs/tui/src/bottom_pane/chat_composer.rs | 480 ++++++++++++------ codex-rs/tui/src/bottom_pane/mod.rs | 25 + codex-rs/tui/src/bottom_pane/paste_burst.rs | 246 +++++++++ codex-rs/tui/src/chatwidget.rs | 20 + codex-rs/tui/src/chatwidget/tests.rs | 1 + 8 files changed, 633 insertions(+), 159 deletions(-) create mode 100644 codex-rs/tui/src/bottom_pane/paste_burst.rs diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs index 4d623c3e..b47f717c 100644 --- a/codex-rs/core/src/config.rs +++ b/codex-rs/core/src/config.rs @@ -181,6 +181,10 @@ pub struct Config { /// Include the `view_image` tool that lets the agent attach a local image path to context. pub include_view_image_tool: bool, + /// When true, disables burst-paste detection for typed input entirely. + /// All characters are inserted as they are received, and no buffering + /// or placeholder replacement will occur for fast keypress bursts. + pub disable_paste_burst: bool, } impl Config { @@ -488,6 +492,11 @@ pub struct ConfigToml { /// Nested tools section for feature toggles pub tools: Option, + + /// When true, disables burst-paste detection for typed input entirely. + /// All characters are inserted as they are received, and no buffering + /// or placeholder replacement will occur for fast keypress bursts. + pub disable_paste_burst: Option, } #[derive(Deserialize, Debug, Clone, PartialEq, Eq)] @@ -798,6 +807,7 @@ impl Config { .experimental_use_exec_command_tool .unwrap_or(false), include_view_image_tool, + disable_paste_burst: cfg.disable_paste_burst.unwrap_or(false), }; Ok(config) } @@ -1167,6 +1177,7 @@ disable_response_storage = true preferred_auth_method: AuthMode::ChatGPT, use_experimental_streamable_shell_tool: false, include_view_image_tool: true, + disable_paste_burst: false, }, o3_profile_config ); @@ -1224,6 +1235,7 @@ disable_response_storage = true preferred_auth_method: AuthMode::ChatGPT, use_experimental_streamable_shell_tool: false, include_view_image_tool: true, + disable_paste_burst: false, }; assert_eq!(expected_gpt3_profile_config, gpt3_profile_config); @@ -1296,6 +1308,7 @@ disable_response_storage = true preferred_auth_method: AuthMode::ChatGPT, use_experimental_streamable_shell_tool: false, include_view_image_tool: true, + disable_paste_burst: false, }; assert_eq!(expected_zdr_profile_config, zdr_profile_config); diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 6c978e27..6d107d67 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -133,6 +133,12 @@ impl App { self.chat_widget.handle_paste(pasted); } TuiEvent::Draw => { + if self + .chat_widget + .handle_paste_burst_tick(tui.frame_requester()) + { + return Ok(true); + } tui.draw( self.chat_widget.desired_height(tui.terminal.size()?.width), |frame| { diff --git a/codex-rs/tui/src/bottom_pane/approval_modal_view.rs b/codex-rs/tui/src/bottom_pane/approval_modal_view.rs index 518d9d03..e204051d 100644 --- a/codex-rs/tui/src/bottom_pane/approval_modal_view.rs +++ b/codex-rs/tui/src/bottom_pane/approval_modal_view.rs @@ -100,6 +100,7 @@ mod tests { has_input_focus: true, enhanced_keys_supported: false, placeholder_text: "Ask Codex to do anything".to_string(), + disable_paste_burst: false, }); assert_eq!(CancellationEvent::Handled, view.on_ctrl_c(&mut pane)); assert!(view.queue.is_empty()); diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 26b9a571..c21d5a4b 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -24,6 +24,8 @@ use ratatui::widgets::WidgetRef; use super::chat_composer_history::ChatComposerHistory; use super::command_popup::CommandPopup; use super::file_search_popup::FileSearchPopup; +use super::paste_burst::CharDecision; +use super::paste_burst::PasteBurst; use crate::slash_command::SlashCommand; use crate::app_event::AppEvent; @@ -40,11 +42,6 @@ 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. const LARGE_PASTE_CHAR_THRESHOLD: usize = 1000; @@ -93,13 +90,10 @@ 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, + // Non-bracketed paste burst tracker. + paste_burst: PasteBurst, + // When true, disables paste-burst logic and inserts characters immediately. + disable_paste_burst: bool, } /// Popup state – at most one can be visible at any time. @@ -115,10 +109,11 @@ impl ChatComposer { app_event_tx: AppEventSender, enhanced_keys_supported: bool, placeholder_text: String, + disable_paste_burst: bool, ) -> Self { let use_shift_enter_hint = enhanced_keys_supported; - Self { + let mut this = Self { textarea: TextArea::new(), textarea_state: RefCell::new(TextAreaState::default()), active_popup: ActivePopup::None, @@ -134,12 +129,12 @@ 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, - } + paste_burst: PasteBurst::default(), + disable_paste_burst: false, + }; + // Apply configuration via the setter to keep side-effects centralized. + this.set_disable_paste_burst(disable_paste_burst); + this } pub fn desired_height(&self, width: u16) -> u16 { @@ -229,11 +224,15 @@ impl ChatComposer { 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.paste_burst.clear_after_explicit_paste(); + // Keep popup sync consistent with key handling: prefer slash popup; only + // sync file popup when slash popup is NOT active. self.sync_command_popup(); - self.sync_file_search_popup(); + if matches!(self.active_popup, ActivePopup::Command(_)) { + self.dismissed_file_popup_token = None; + } else { + self.sync_file_search_popup(); + } true } @@ -256,6 +255,14 @@ impl ChatComposer { } } + pub(crate) fn set_disable_paste_burst(&mut self, disabled: bool) { + let was_disabled = self.disable_paste_burst; + self.disable_paste_burst = disabled; + if disabled && !was_disabled { + self.paste_burst.clear_window_after_non_char(); + } + } + /// Replace the entire composer content with `text` and reset cursor. pub(crate) fn set_text_content(&mut self, text: String) { self.textarea.set_text(&text); @@ -270,6 +277,7 @@ impl ChatComposer { self.textarea.text().to_string() } + /// Attempt to start a burst by retro-capturing recent chars before the cursor. pub fn attach_image(&mut self, path: PathBuf, width: u32, height: u32, format_label: &str) { let placeholder = format!("[image {width}x{height} {format_label}]"); // Insert as an element to match large paste placeholder behavior: @@ -284,6 +292,23 @@ impl ChatComposer { images.into_iter().map(|img| img.path).collect() } + pub(crate) fn flush_paste_burst_if_due(&mut self) -> bool { + let now = Instant::now(); + if let Some(pasted) = self.paste_burst.flush_if_due(now) { + let _ = self.handle_paste(pasted); + return true; + } + false + } + + pub(crate) fn is_in_paste_burst(&self) -> bool { + self.paste_burst.is_active() + } + + pub(crate) fn recommended_paste_flush_delay() -> Duration { + PasteBurst::recommended_flush_delay() + } + /// Integrate results from an asynchronous file search. pub(crate) fn on_file_search_result(&mut self, query: String, matches: Vec) { // Only apply if user is still editing a token starting with `query`. @@ -423,9 +448,7 @@ impl ChatComposer { #[inline] fn handle_non_ascii_char(&mut self, input: KeyEvent) -> (InputResult, bool) { - 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; + if let Some(pasted) = self.paste_burst.flush_before_modified_input() { self.handle_paste(pasted); } self.textarea.input(input); @@ -740,14 +763,11 @@ impl ChatComposer { .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'); + if self.paste_burst.is_active() && !in_slash_context { 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 self.paste_burst.append_newline_if_active(now) { + return (InputResult::None, true); + } } // If we have pending placeholder pastes, submit immediately to expand them. if !self.pending_pastes.is_empty() { @@ -768,19 +788,12 @@ impl ChatComposer { // 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 { + if self + .paste_burst + .newline_should_insert_instead_of_submit(now) + { self.textarea.insert_str("\n"); - self.paste_burst_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW); + self.paste_burst.extend_window(now); return (InputResult::None, true); } let mut text = self.textarea.text().to_string(); @@ -810,22 +823,16 @@ impl ChatComposer { // 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; + if let Some(pasted) = self.paste_burst.flush_if_due(now) { // 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.is_active() + && self.paste_burst.append_newline_if_active(now) { - self.paste_burst_buffer.push('\n'); - self.paste_burst_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW); return (InputResult::None, true); } @@ -840,65 +847,50 @@ impl ChatComposer { modifiers.contains(KeyModifiers::CONTROL) || modifiers.contains(KeyModifiers::ALT); if !has_ctrl_or_alt { // Non-ASCII characters (e.g., from IMEs) can arrive in quick bursts and be - // misclassified by our non-bracketed paste heuristic. To avoid leaving - // residual buffered content or misdetecting a paste, flush any burst buffer - // and insert non-ASCII characters directly. + // misclassified by paste heuristics. Flush any active burst buffer and insert + // non-ASCII characters directly. if !ch.is_ascii() { return self.handle_non_ascii_char(input); } - // 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)); + match self.paste_burst.on_plain_char(ch, now) { + CharDecision::BufferAppend => { + self.paste_burst.append_char_to_buffer(ch, now); + return (InputResult::None, true); + } + CharDecision::BeginBuffer { retro_chars } => { + let cur = self.textarea.cursor(); + let txt = self.textarea.text(); + let safe_cur = Self::clamp_to_char_boundary(txt, cur); + let before = &txt[..safe_cur]; + if let Some(grab) = + self.paste_burst + .decide_begin_buffer(now, before, retro_chars as usize) + { + if !grab.grabbed.is_empty() { + self.textarea.replace_range(grab.start_byte..safe_cur, ""); + } + self.paste_burst.begin_with_retro_grabbed(grab.grabbed, now); + self.paste_burst.append_char_to_buffer(ch, now); + return (InputResult::None, true); + } + // If decide_begin_buffer opted not to start buffering, + // fall through to normal insertion below. + } + CharDecision::BeginBufferFromPending => { + // First char was held; now append the current one. + self.paste_burst.append_char_to_buffer(ch, now); + return (InputResult::None, true); + } + CharDecision::RetainFirstChar => { + // Keep the first fast char pending momentarily. 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); } } + if let Some(pasted) = self.paste_burst.flush_before_modified_input() { + self.handle_paste(pasted); + } } // For non-char inputs (or after flushing), handle normally. @@ -925,25 +917,15 @@ impl ChatComposer { 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(); + self.paste_burst.clear_window_after_non_char(); } - // 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. + // Other keys: clear burst window (buffer should have been flushed above if needed). + self.paste_burst.clear_window_after_non_char(); } } @@ -1480,8 +1462,13 @@ mod tests { 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()); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); let needs_redraw = composer.handle_paste("hello".to_string()); assert!(needs_redraw); @@ -1504,8 +1491,13 @@ mod tests { 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()); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); let large = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 10); let needs_redraw = composer.handle_paste(large.clone()); @@ -1534,8 +1526,13 @@ mod tests { let large = "y".repeat(LARGE_PASTE_CHAR_THRESHOLD + 1); 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()); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); composer.handle_paste(large); assert_eq!(composer.pending_pastes.len(), 1); @@ -1576,6 +1573,7 @@ mod tests { sender.clone(), false, "Ask Codex to do anything".to_string(), + false, ); if let Some(text) = input { @@ -1605,6 +1603,18 @@ mod tests { } } + // Test helper: simulate human typing with a brief delay and flush the paste-burst buffer + fn type_chars_humanlike(composer: &mut ChatComposer, chars: &[char]) { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + for &ch in chars { + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); + std::thread::sleep(ChatComposer::recommended_paste_flush_delay()); + let _ = composer.flush_paste_burst_if_due(); + } + } + #[test] fn slash_init_dispatches_command_and_does_not_submit_literal_text() { use crossterm::event::KeyCode; @@ -1613,15 +1623,16 @@ mod tests { 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()); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); // Type the slash command. - for ch in [ - '/', 'i', 'n', 'i', 't', // "/init" - ] { - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); - } + type_chars_humanlike(&mut composer, &['/', 'i', 'n', 'i', 't']); // Press Enter to dispatch the selected command. let (result, _needs_redraw) = @@ -1649,12 +1660,15 @@ mod tests { 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()); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); - for ch in ['/', 'c'] { - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); - } + type_chars_humanlike(&mut composer, &['/', 'c']); let (_result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); @@ -1671,12 +1685,15 @@ mod tests { 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()); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); - for ch in ['/', 'm', 'e', 'n', 't', 'i', 'o', 'n'] { - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); - } + type_chars_humanlike(&mut composer, &['/', 'm', 'e', 'n', 't', 'i', 'o', 'n']); let (result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); @@ -1703,8 +1720,13 @@ mod tests { 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()); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); // Define test cases: (paste content, is_large) let test_cases = [ @@ -1777,8 +1799,13 @@ mod tests { 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()); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); // Define test cases: (content, is_large) let test_cases = [ @@ -1844,8 +1871,13 @@ mod tests { 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()); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); // Define test cases: (cursor_position_from_end, expected_pending_count) let test_cases = [ @@ -1887,8 +1919,13 @@ mod tests { fn attach_image_and_submit_includes_image_paths() { 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()); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); let path = PathBuf::from("/tmp/image1.png"); composer.attach_image(path.clone(), 32, 16, "PNG"); composer.handle_paste(" hi".into()); @@ -1906,8 +1943,13 @@ mod tests { fn attach_image_without_text_submits_empty_text_and_images() { 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()); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); let path = PathBuf::from("/tmp/image2.png"); composer.attach_image(path.clone(), 10, 5, "PNG"); let (result, _) = @@ -1926,8 +1968,13 @@ mod tests { fn image_placeholder_backspace_behaves_like_text_placeholder() { 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()); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); let path = PathBuf::from("/tmp/image3.png"); composer.attach_image(path.clone(), 20, 10, "PNG"); let placeholder = composer.attached_images[0].placeholder.clone(); @@ -1962,8 +2009,13 @@ mod tests { 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()); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); // Insert an image placeholder at the start let path = PathBuf::from("/tmp/image_multibyte.png"); @@ -1983,8 +2035,13 @@ mod tests { fn deleting_one_of_duplicate_image_placeholders_removes_matching_entry() { 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()); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); let path1 = PathBuf::from("/tmp/image_dup1.png"); let path2 = PathBuf::from("/tmp/image_dup2.png"); @@ -2025,8 +2082,13 @@ mod tests { 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()); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); let needs_redraw = composer.handle_paste(tmp_path.to_string_lossy().to_string()); assert!(needs_redraw); @@ -2035,4 +2097,104 @@ mod tests { let imgs = composer.take_recent_submission_images(); assert_eq!(imgs, vec![tmp_path.clone()]); } + + #[test] + fn burst_paste_fast_small_buffers_and_flushes_on_stop() { + 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 count = 32; + for _ in 0..count { + let _ = + composer.handle_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)); + assert!( + composer.is_in_paste_burst(), + "expected active paste burst during fast typing" + ); + assert!( + composer.textarea.text().is_empty(), + "text should not appear during burst" + ); + } + + assert!( + composer.textarea.text().is_empty(), + "text should remain empty until flush" + ); + std::thread::sleep(ChatComposer::recommended_paste_flush_delay()); + let flushed = composer.flush_paste_burst_if_due(); + assert!(flushed, "expected buffered text to flush after stop"); + assert_eq!(composer.textarea.text(), "a".repeat(count)); + assert!( + composer.pending_pastes.is_empty(), + "no placeholder for small burst" + ); + } + + #[test] + fn burst_paste_fast_large_inserts_placeholder_on_flush() { + 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 count = LARGE_PASTE_CHAR_THRESHOLD + 1; // > threshold to trigger placeholder + for _ in 0..count { + let _ = + composer.handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)); + } + + // Nothing should appear until we stop and flush + assert!(composer.textarea.text().is_empty()); + std::thread::sleep(ChatComposer::recommended_paste_flush_delay()); + let flushed = composer.flush_paste_burst_if_due(); + assert!(flushed, "expected flush after stopping fast input"); + + let expected_placeholder = format!("[Pasted Content {count} chars]"); + assert_eq!(composer.textarea.text(), expected_placeholder); + assert_eq!(composer.pending_pastes.len(), 1); + assert_eq!(composer.pending_pastes[0].0, expected_placeholder); + assert_eq!(composer.pending_pastes[0].1.len(), count); + assert!(composer.pending_pastes[0].1.chars().all(|c| c == 'x')); + } + + #[test] + fn humanlike_typing_1000_chars_appears_live_no_placeholder() { + 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 count = LARGE_PASTE_CHAR_THRESHOLD; // 1000 in current config + let chars: Vec = vec!['z'; count]; + type_chars_humanlike(&mut composer, &chars); + + assert_eq!(composer.textarea.text(), "z".repeat(count)); + assert!(composer.pending_pastes.is_empty()); + } } diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 949283f6..c1e84bee 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -13,6 +13,7 @@ use ratatui::layout::Constraint; use ratatui::layout::Layout; use ratatui::layout::Rect; use ratatui::widgets::WidgetRef; +use std::time::Duration; mod approval_modal_view; mod bottom_pane_view; @@ -21,6 +22,7 @@ mod chat_composer_history; mod command_popup; mod file_search_popup; mod list_selection_view; +mod paste_burst; mod popup_consts; mod scroll_state; mod selection_popup_common; @@ -69,6 +71,7 @@ pub(crate) struct BottomPaneParams { pub(crate) has_input_focus: bool, pub(crate) enhanced_keys_supported: bool, pub(crate) placeholder_text: String, + pub(crate) disable_paste_burst: bool, } impl BottomPane { @@ -81,6 +84,7 @@ impl BottomPane { params.app_event_tx.clone(), enhanced_keys_supported, params.placeholder_text, + params.disable_paste_burst, ), active_view: None, app_event_tx: params.app_event_tx, @@ -182,6 +186,9 @@ impl BottomPane { if needs_redraw { self.request_redraw(); } + if self.composer.is_in_paste_burst() { + self.request_redraw_in(ChatComposer::recommended_paste_flush_delay()); + } input_result } } @@ -382,12 +389,24 @@ impl BottomPane { self.frame_requester.schedule_frame(); } + pub(crate) fn request_redraw_in(&self, dur: Duration) { + self.frame_requester.schedule_frame_in(dur); + } + // --- History helpers --- pub(crate) fn set_history_metadata(&mut self, log_id: u64, entry_count: usize) { self.composer.set_history_metadata(log_id, entry_count); } + pub(crate) fn flush_paste_burst_if_due(&mut self) -> bool { + self.composer.flush_paste_burst_if_due() + } + + pub(crate) fn is_in_paste_burst(&self) -> bool { + self.composer.is_in_paste_burst() + } + pub(crate) fn on_history_entry_response( &mut self, log_id: u64, @@ -473,6 +492,7 @@ mod tests { has_input_focus: true, enhanced_keys_supported: false, placeholder_text: "Ask Codex to do anything".to_string(), + disable_paste_burst: false, }); pane.push_approval_request(exec_request()); assert_eq!(CancellationEvent::Handled, pane.on_ctrl_c()); @@ -492,6 +512,7 @@ mod tests { has_input_focus: true, enhanced_keys_supported: false, placeholder_text: "Ask Codex to do anything".to_string(), + disable_paste_burst: false, }); // Create an approval modal (active view). @@ -522,6 +543,7 @@ mod tests { has_input_focus: true, enhanced_keys_supported: false, placeholder_text: "Ask Codex to do anything".to_string(), + disable_paste_burst: false, }); // Start a running task so the status indicator is active above the composer. @@ -589,6 +611,7 @@ mod tests { has_input_focus: true, enhanced_keys_supported: false, placeholder_text: "Ask Codex to do anything".to_string(), + disable_paste_burst: false, }); // Begin a task: show initial status. @@ -619,6 +642,7 @@ mod tests { has_input_focus: true, enhanced_keys_supported: false, placeholder_text: "Ask Codex to do anything".to_string(), + disable_paste_burst: false, }); // Activate spinner (status view replaces composer) with no live ring. @@ -669,6 +693,7 @@ mod tests { has_input_focus: true, enhanced_keys_supported: false, placeholder_text: "Ask Codex to do anything".to_string(), + disable_paste_burst: false, }); pane.set_task_running(true); diff --git a/codex-rs/tui/src/bottom_pane/paste_burst.rs b/codex-rs/tui/src/bottom_pane/paste_burst.rs new file mode 100644 index 00000000..b353d867 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/paste_burst.rs @@ -0,0 +1,246 @@ +use std::time::Duration; +use std::time::Instant; + +// Heuristic thresholds for detecting paste-like input bursts. +// Detect quickly to avoid showing typed prefix before paste is recognized +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); + +#[derive(Default)] +pub(crate) struct PasteBurst { + last_plain_char_time: Option, + consecutive_plain_char_burst: u16, + burst_window_until: Option, + buffer: String, + active: bool, + // Hold first fast char briefly to avoid rendering flicker + pending_first_char: Option<(char, Instant)>, +} + +pub(crate) enum CharDecision { + /// Start buffering and retroactively capture some already-inserted chars. + BeginBuffer { retro_chars: u16 }, + /// We are currently buffering; append the current char into the buffer. + BufferAppend, + /// Do not insert/render this char yet; temporarily save the first fast + /// char while we wait to see if a paste-like burst follows. + RetainFirstChar, + /// Begin buffering using the previously saved first char (no retro grab needed). + BeginBufferFromPending, +} + +pub(crate) struct RetroGrab { + pub start_byte: usize, + pub grabbed: String, +} + +impl PasteBurst { + /// Recommended delay to wait between simulated keypresses (or before + /// scheduling a UI tick) so that a pending fast keystroke is flushed + /// out of the burst detector as normal typed input. + /// + /// Primarily used by tests and by the TUI to reliably cross the + /// paste-burst timing threshold. + pub fn recommended_flush_delay() -> Duration { + PASTE_BURST_CHAR_INTERVAL + Duration::from_millis(1) + } + + /// Entry point: decide how to treat a plain char with current timing. + pub fn on_plain_char(&mut self, ch: char, now: Instant) -> CharDecision { + 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 self.active { + self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW); + return CharDecision::BufferAppend; + } + + // If we already held a first char and receive a second fast char, + // start buffering without retro-grabbing (we never rendered the first). + if let Some((held, held_at)) = self.pending_first_char + && now.duration_since(held_at) <= PASTE_BURST_CHAR_INTERVAL + { + self.active = true; + // take() to clear pending; we already captured the held char above + let _ = self.pending_first_char.take(); + self.buffer.push(held); + self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW); + return CharDecision::BeginBufferFromPending; + } + + if self.consecutive_plain_char_burst >= PASTE_BURST_MIN_CHARS { + return CharDecision::BeginBuffer { + retro_chars: self.consecutive_plain_char_burst.saturating_sub(1), + }; + } + + // Save the first fast char very briefly to see if a burst follows. + self.pending_first_char = Some((ch, now)); + CharDecision::RetainFirstChar + } + + /// Flush the buffered burst if the inter-key timeout has elapsed. + /// + /// Returns Some(String) when either: + /// - We were actively buffering paste-like input and the buffer is now + /// emitted as a single pasted string; or + /// - We had saved a single fast first-char with no subsequent burst and we + /// now emit that char as normal typed input. + /// + /// Returns None if the timeout has not elapsed or there is nothing to flush. + pub fn flush_if_due(&mut self, now: Instant) -> Option { + let timed_out = self + .last_plain_char_time + .is_some_and(|t| now.duration_since(t) > PASTE_BURST_CHAR_INTERVAL); + if timed_out && self.is_active_internal() { + self.active = false; + let out = std::mem::take(&mut self.buffer); + Some(out) + } else if timed_out { + // If we were saving a single fast char and no burst followed, + // flush it as normal typed input. + if let Some((ch, _at)) = self.pending_first_char.take() { + Some(ch.to_string()) + } else { + None + } + } else { + None + } + } + + /// While bursting: accumulate a newline into the buffer instead of + /// submitting the textarea. + /// + /// Returns true if a newline was appended (we are in a burst context), + /// false otherwise. + pub fn append_newline_if_active(&mut self, now: Instant) -> bool { + if self.is_active() { + self.buffer.push('\n'); + self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW); + true + } else { + false + } + } + + /// Decide if Enter should insert a newline (burst context) vs submit. + pub fn newline_should_insert_instead_of_submit(&self, now: Instant) -> bool { + let in_burst_window = self.burst_window_until.is_some_and(|until| now <= until); + self.is_active() || in_burst_window + } + + /// Keep the burst window alive. + pub fn extend_window(&mut self, now: Instant) { + self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW); + } + + /// Begin buffering with retroactively grabbed text. + pub fn begin_with_retro_grabbed(&mut self, grabbed: String, now: Instant) { + if !grabbed.is_empty() { + self.buffer.push_str(&grabbed); + } + self.active = true; + self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW); + } + + /// Append a char into the burst buffer. + pub fn append_char_to_buffer(&mut self, ch: char, now: Instant) { + self.buffer.push(ch); + self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW); + } + + /// Decide whether to begin buffering by retroactively capturing recent + /// chars from the slice before the cursor. + /// + /// Heuristic: if the retro-grabbed slice contains any whitespace or is + /// sufficiently long (>= 16 characters), treat it as paste-like to avoid + /// rendering the typed prefix momentarily before the paste is recognized. + /// This favors responsiveness and prevents flicker for typical pastes + /// (URLs, file paths, multiline text) while not triggering on short words. + /// + /// Returns Some(RetroGrab) with the start byte and grabbed text when we + /// decide to buffer retroactively; otherwise None. + pub fn decide_begin_buffer( + &mut self, + now: Instant, + before: &str, + retro_chars: usize, + ) -> Option { + let start_byte = retro_start_index(before, retro_chars); + let grabbed = before[start_byte..].to_string(); + let looks_pastey = + grabbed.chars().any(|c| c.is_whitespace()) || grabbed.chars().count() >= 16; + if looks_pastey { + // Note: caller is responsible for removing this slice from UI text. + self.begin_with_retro_grabbed(grabbed.clone(), now); + Some(RetroGrab { + start_byte, + grabbed, + }) + } else { + None + } + } + + /// Before applying modified/non-char input: flush buffered burst immediately. + pub fn flush_before_modified_input(&mut self) -> Option { + if self.is_active() { + self.active = false; + Some(std::mem::take(&mut self.buffer)) + } else { + None + } + } + + /// Clear only the timing window and any pending first-char. + /// + /// Does not emit or clear the buffered text itself; callers should have + /// already flushed (if needed) via one of the flush methods above. + pub fn clear_window_after_non_char(&mut self) { + self.consecutive_plain_char_burst = 0; + self.last_plain_char_time = None; + self.burst_window_until = None; + self.active = false; + self.pending_first_char = None; + } + + /// Returns true if we are in any paste-burst related transient state + /// (actively buffering, have a non-empty buffer, or have saved the first + /// fast char while waiting for a potential burst). + pub fn is_active(&self) -> bool { + self.is_active_internal() || self.pending_first_char.is_some() + } + + fn is_active_internal(&self) -> bool { + self.active || !self.buffer.is_empty() + } + + pub fn clear_after_explicit_paste(&mut self) { + self.last_plain_char_time = None; + self.consecutive_plain_char_burst = 0; + self.burst_window_until = None; + self.active = false; + self.buffer.clear(); + self.pending_first_char = None; + } +} + +pub(crate) fn retro_start_index(before: &str, retro_chars: usize) -> usize { + if retro_chars == 0 { + return before.len(); + } + before + .char_indices() + .rev() + .nth(retro_chars.saturating_sub(1)) + .map(|(idx, _)| idx) + .unwrap_or(0) +} diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index e687fc03..5e1fd45f 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -604,6 +604,7 @@ impl ChatWidget { has_input_focus: true, enhanced_keys_supported, placeholder_text: placeholder, + disable_paste_burst: config.disable_paste_burst, }), active_exec_cell: None, config: config.clone(), @@ -652,6 +653,7 @@ impl ChatWidget { has_input_focus: true, enhanced_keys_supported, placeholder_text: placeholder, + disable_paste_burst: config.disable_paste_burst, }), active_exec_cell: None, config: config.clone(), @@ -858,6 +860,24 @@ impl ChatWidget { self.bottom_pane.handle_paste(text); } + // Returns true if caller should skip rendering this frame (a future frame is scheduled). + pub(crate) fn handle_paste_burst_tick(&mut self, frame_requester: FrameRequester) -> bool { + if self.bottom_pane.flush_paste_burst_if_due() { + // A paste just flushed; request an immediate redraw and skip this frame. + self.request_redraw(); + true + } else if self.bottom_pane.is_in_paste_burst() { + // While capturing a burst, schedule a follow-up tick and skip this frame + // to avoid redundant renders between ticks. + frame_requester.schedule_frame_in( + crate::bottom_pane::ChatComposer::recommended_paste_flush_delay(), + ); + true + } else { + false + } + } + fn flush_active_exec_cell(&mut self) { if let Some(active) = self.active_exec_cell.take() { self.last_history_was_exec = true; diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index edc6f7d0..758d2877 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -164,6 +164,7 @@ fn make_chatwidget_manual() -> ( has_input_focus: true, enhanced_keys_supported: false, placeholder_text: "Ask Codex to do anything".to_string(), + disable_paste_burst: false, }); let widget = ChatWidget { app_event_tx,