Files
codex/prs/bolinfest/PR-2683.md
2025-09-02 15:17:45 -07:00

95 KiB
Raw Blame History

PR #2683: burst paste edge cases

Description

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.

Full Diff

diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs
index 4d623c3e5b..b47f717c57 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<ToolsToml>,
+
+    /// 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<bool>,
 }
 
 #[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 6c978e277f..6d107d67a0 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 518d9d0351..e204051d28 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 26b9a571af..c21d5a4b5d 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<AttachedImage>,
     placeholder_text: String,
-    // Heuristic state to detect non-bracketed paste bursts.
-    last_plain_char_time: Option<Instant>,
-    consecutive_plain_char_burst: u16,
-    paste_burst_until: Option<Instant>,
-    // 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<FileMatch>) {
         // 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);
+
+                match self.paste_burst.on_plain_char(ch, now) {
+                    CharDecision::BufferAppend => {
+                        self.paste_burst.append_char_to_buffer(ch, now);
+                        return (InputResult::None, true);
                     }
-                    _ => {
-                        self.consecutive_plain_char_burst = 1;
+                    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.
                     }
-                }
-                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));
+                    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::<AppEvent>();
         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::<AppEvent>();
         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::<AppEvent>();
         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::<AppEvent>();
         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::<AppEvent>();
         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::<AppEvent>();
         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::<AppEvent>();
         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::<AppEvent>();
         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::<AppEvent>();
         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::<AppEvent>();
         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::<AppEvent>();
         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::<AppEvent>();
         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::<AppEvent>();
         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::<AppEvent>();
         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::<AppEvent>();
         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::<AppEvent>();
+        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::<AppEvent>();
+        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::<AppEvent>();
+        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<char> = 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 949283f61a..c1e84beeef 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 0000000000..b353d8677d
--- /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<Instant>,
+    consecutive_plain_char_burst: u16,
+    burst_window_until: Option<Instant>,
+    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<String> {
+        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<RetroGrab> {
+        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<String> {
+        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 e687fc038f..5e1fd45fc6 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 edc6f7d030..758d28773a 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,

Review Comments

codex-rs/tui/src/bottom_pane/chat_composer.rs

@@ -256,6 +251,13 @@ impl ChatComposer {
         }
     }
 
+    pub(crate) fn set_disable_paste_burst(&mut self, disabled: bool) {
+        self.disable_paste_burst = disabled;
+        if disabled {
+            self.paste_burst.clear_window_after_non_char();

Should this happen if self.disable_paste_burst was already true before this call?

@@ -270,6 +272,58 @@ impl ChatComposer {
         self.textarea.text().to_string()
     }
 
+    // Attempt to start a burst by retro-capturing recent chars before the cursor.

Prefer /// for docstrings throughout.

@@ -1558,6 +1563,19 @@ mod tests {
         }
     }
 
+    // Test helper: simulate human typing with a brief delay and flush the paste-burst buffer
+    #[cfg(test)]

Do we need #[cfg(test)]? Are we not in mod tests?

@@ -1963,4 +1973,89 @@ 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::<AppEvent>();
+        let sender = AppEventSender::new(tx);
+        let mut composer =
+            ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string());
+
+        let count = 32usize;

Just this?

        let count = 32;
@@ -1963,4 +1973,89 @@ mod tests {
         let imgs = composer.take_recent_submission_images();
         assert_eq!(imgs, vec![tmp_path.clone()]);
     }
+
+    #[test]

Nice tests!

codex-rs/tui/src/bottom_pane/mod.rs

@@ -91,7 +93,10 @@ impl BottomPane {
             status: None,
             queued_user_messages: Vec::new(),
             esc_backtrack_hint: false,
-        }
+        };
+        this.composer

I assume that disable_paste_burst is not a param of ChatComposer::new() because you want the side effects of set_disable_paste_burst()? It seems a little weird to me, though: should this not happen within the ChatComposer::new() constructor instead?

@@ -182,6 +187,12 @@ impl BottomPane {
             if needs_redraw {
                 self.request_redraw();
             }
+            if self.composer.is_in_paste_burst() {
+                self.request_redraw_in(
+                    crate::bottom_pane::chat_composer::ChatComposer::recommended_paste_flush_delay(
                    ChatComposer::recommended_paste_flush_delay(
@@ -382,12 +393,24 @@ impl BottomPane {
         self.frame_requester.schedule_frame();
     }
 
+    pub(crate) fn request_redraw_in(&self, dur: std::time::Duration) {
    pub(crate) fn request_redraw_in(&self, dur: Duration) {

codex-rs/tui/src/bottom_pane/paste_burst.rs

@@ -0,0 +1,213 @@
+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<Instant>,
+    consecutive_plain_char_burst: u16,
+    burst_window_until: Option<Instant>,
+    buffer: String,
+    active: bool,
+    // Hold first fast char briefly to avoid rendering flicker
+    pending_first_char: Option<(char, Instant)>,
+}
+
+pub(crate) enum CharDecision {
+    BeginBuffer { retro_chars: u16 },
+    BufferAppend,
+    // Do not insert/render this char yet; we are holding briefly.
+    HoldFirstChar,
+    // Begin buffering using the previously held first char (no retro grab needed).
+    BeginBufferFromPending,
+}
+
+pub(crate) struct RetroGrab {
+    pub start_byte: usize,
+    pub grabbed: String,
+}
+
+impl PasteBurst {
+    pub fn recommended_flush_delay() -> Duration {

Docstring? I'm not sure what this means from the name.

@@ -0,0 +1,213 @@
+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<Instant>,
+    consecutive_plain_char_burst: u16,
+    burst_window_until: Option<Instant>,
+    buffer: String,
+    active: bool,
+    // Hold first fast char briefly to avoid rendering flicker
+    pending_first_char: Option<(char, Instant)>,
+}
+
+pub(crate) enum CharDecision {
+    BeginBuffer { retro_chars: u16 },
+    BufferAppend,
+    // Do not insert/render this char yet; we are holding briefly.
+    HoldFirstChar,

RetainFirstChar or SaveFirstChar might be better names?

@@ -0,0 +1,213 @@
+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<Instant>,
+    consecutive_plain_char_burst: u16,
+    burst_window_until: Option<Instant>,
+    buffer: String,
+    active: bool,
+    // Hold first fast char briefly to avoid rendering flicker
+    pending_first_char: Option<(char, Instant)>,
+}
+
+pub(crate) enum CharDecision {
+    BeginBuffer { retro_chars: u16 },
+    BufferAppend,
+    // Do not insert/render this char yet; we are holding briefly.

Please use /// for docstrings throughout.

@@ -0,0 +1,213 @@
+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<Instant>,
+    consecutive_plain_char_burst: u16,
+    burst_window_until: Option<Instant>,
+    buffer: String,
+    active: bool,
+    // Hold first fast char briefly to avoid rendering flicker
+    pending_first_char: Option<(char, Instant)>,
+}
+
+pub(crate) enum CharDecision {
+    BeginBuffer { retro_chars: u16 },
+    BufferAppend,
+    // Do not insert/render this char yet; we are holding briefly.
+    HoldFirstChar,
+    // Begin buffering using the previously held first char (no retro grab needed).
+    BeginBufferFromPending,
+}
+
+pub(crate) struct RetroGrab {
+    pub start_byte: usize,
+    pub grabbed: String,
+}
+
+impl PasteBurst {
+    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),
+            };
+        }
+
+        // Hold the first fast char very briefly to see if a burst follows.
+        self.pending_first_char = Some((ch, now));
+        CharDecision::HoldFirstChar
+    }
+
+    // Timer: flush buffered burst if timeout elapsed.

Please update the docstring to comment on the return value.

@@ -0,0 +1,213 @@
+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<Instant>,
+    consecutive_plain_char_burst: u16,
+    burst_window_until: Option<Instant>,
+    buffer: String,
+    active: bool,
+    // Hold first fast char briefly to avoid rendering flicker
+    pending_first_char: Option<(char, Instant)>,
+}
+
+pub(crate) enum CharDecision {
+    BeginBuffer { retro_chars: u16 },
+    BufferAppend,
+    // Do not insert/render this char yet; we are holding briefly.
+    HoldFirstChar,
+    // Begin buffering using the previously held first char (no retro grab needed).
+    BeginBufferFromPending,
+}
+
+pub(crate) struct RetroGrab {
+    pub start_byte: usize,
+    pub grabbed: String,
+}
+
+impl PasteBurst {
+    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),
+            };
+        }
+
+        // Hold the first fast char very briefly to see if a burst follows.
+        self.pending_first_char = Some((ch, now));
+        CharDecision::HoldFirstChar
+    }
+
+    // Timer: flush buffered burst if timeout elapsed.
+    pub fn flush_if_due(&mut self, now: Instant) -> Option<String> {
+        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 holding a single fast char and no burst followed,
+            // flush it as normal typed input.
+            if let Some((ch, _at)) = self.pending_first_char.take() {
+                return Some(ch.to_string());
+            }
+            None

Consider:

            // flush it as normal typed input.
            if let Some((ch, _at)) = self.pending_first_char.take() {
                Some(ch.to_string())
            } else {
                None
            }
@@ -0,0 +1,213 @@
+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<Instant>,
+    consecutive_plain_char_burst: u16,
+    burst_window_until: Option<Instant>,
+    buffer: String,
+    active: bool,
+    // Hold first fast char briefly to avoid rendering flicker
+    pending_first_char: Option<(char, Instant)>,
+}
+
+pub(crate) enum CharDecision {
+    BeginBuffer { retro_chars: u16 },
+    BufferAppend,
+    // Do not insert/render this char yet; we are holding briefly.
+    HoldFirstChar,
+    // Begin buffering using the previously held first char (no retro grab needed).
+    BeginBufferFromPending,
+}
+
+pub(crate) struct RetroGrab {
+    pub start_byte: usize,
+    pub grabbed: String,
+}
+
+impl PasteBurst {
+    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),
+            };
+        }
+
+        // Hold the first fast char very briefly to see if a burst follows.
+        self.pending_first_char = Some((ch, now));
+        CharDecision::HoldFirstChar
+    }
+
+    // Timer: flush buffered burst if timeout elapsed.
+    pub fn flush_if_due(&mut self, now: Instant) -> Option<String> {
+        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 holding a single fast char and no burst followed,
+            // flush it as normal typed input.
+            if let Some((ch, _at)) = self.pending_first_char.take() {
+                return Some(ch.to_string());
+            }
+            None
+        } else {
+            None
+        }
+    }
+
+    // While bursting: accumulate newline instead of submitting.

Please comment on return value.

@@ -0,0 +1,213 @@
+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<Instant>,
+    consecutive_plain_char_burst: u16,
+    burst_window_until: Option<Instant>,
+    buffer: String,
+    active: bool,
+    // Hold first fast char briefly to avoid rendering flicker
+    pending_first_char: Option<(char, Instant)>,
+}
+
+pub(crate) enum CharDecision {
+    BeginBuffer { retro_chars: u16 },
+    BufferAppend,
+    // Do not insert/render this char yet; we are holding briefly.
+    HoldFirstChar,
+    // Begin buffering using the previously held first char (no retro grab needed).
+    BeginBufferFromPending,
+}
+
+pub(crate) struct RetroGrab {
+    pub start_byte: usize,
+    pub grabbed: String,
+}
+
+impl PasteBurst {
+    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),
+            };
+        }
+
+        // Hold the first fast char very briefly to see if a burst follows.
+        self.pending_first_char = Some((ch, now));
+        CharDecision::HoldFirstChar
+    }
+
+    // Timer: flush buffered burst if timeout elapsed.
+    pub fn flush_if_due(&mut self, now: Instant) -> Option<String> {
+        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 holding a single fast char and no burst followed,
+            // flush it as normal typed input.
+            if let Some((ch, _at)) = self.pending_first_char.take() {
+                return Some(ch.to_string());
+            }
+            None
+        } else {
+            None
+        }
+    }
+
+    // While bursting: accumulate newline instead of submitting.
+    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. Returns start byte and grabbed text
+    // if it looks like a paste (whitespace present or long segment).
+    pub fn decide_begin_buffer(
+        &mut self,
+        now: Instant,
+        before: &str,
+        retro_chars: usize,
+    ) -> Option<RetroGrab> {
+        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.len() >= 16;

Can you add more detail about the logic behind this heuristic?

@@ -0,0 +1,213 @@
+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<Instant>,
+    consecutive_plain_char_burst: u16,
+    burst_window_until: Option<Instant>,
+    buffer: String,
+    active: bool,
+    // Hold first fast char briefly to avoid rendering flicker
+    pending_first_char: Option<(char, Instant)>,
+}
+
+pub(crate) enum CharDecision {
+    BeginBuffer { retro_chars: u16 },
+    BufferAppend,
+    // Do not insert/render this char yet; we are holding briefly.
+    HoldFirstChar,
+    // Begin buffering using the previously held first char (no retro grab needed).
+    BeginBufferFromPending,
+}
+
+pub(crate) struct RetroGrab {
+    pub start_byte: usize,
+    pub grabbed: String,
+}
+
+impl PasteBurst {
+    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),
+            };
+        }
+
+        // Hold the first fast char very briefly to see if a burst follows.
+        self.pending_first_char = Some((ch, now));
+        CharDecision::HoldFirstChar
+    }
+
+    // Timer: flush buffered burst if timeout elapsed.
+    pub fn flush_if_due(&mut self, now: Instant) -> Option<String> {
+        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 holding a single fast char and no burst followed,
+            // flush it as normal typed input.
+            if let Some((ch, _at)) = self.pending_first_char.take() {
+                return Some(ch.to_string());
+            }
+            None
+        } else {
+            None
+        }
+    }
+
+    // While bursting: accumulate newline instead of submitting.
+    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. Returns start byte and grabbed text
+    // if it looks like a paste (whitespace present or long segment).
+    pub fn decide_begin_buffer(
+        &mut self,
+        now: Instant,
+        before: &str,
+        retro_chars: usize,
+    ) -> Option<RetroGrab> {
+        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.len() >= 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<String> {
+        if self.is_active() {
+            self.active = false;
+            Some(std::mem::take(&mut self.buffer))
+        } else {
+            None
+        }
+    }
+
+    // Clear only the timing window (keep buffer state untouched if already flushed).
+    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;
+    }
+
+    // Utility

More precise docstring?

@@ -0,0 +1,213 @@
+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<Instant>,
+    consecutive_plain_char_burst: u16,
+    burst_window_until: Option<Instant>,
+    buffer: String,
+    active: bool,
+    // Hold first fast char briefly to avoid rendering flicker
+    pending_first_char: Option<(char, Instant)>,
+}
+
+pub(crate) enum CharDecision {
+    BeginBuffer { retro_chars: u16 },
+    BufferAppend,
+    // Do not insert/render this char yet; we are holding briefly.
+    HoldFirstChar,
+    // Begin buffering using the previously held first char (no retro grab needed).
+    BeginBufferFromPending,
+}
+
+pub(crate) struct RetroGrab {
+    pub start_byte: usize,
+    pub grabbed: String,
+}
+
+impl PasteBurst {
+    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),
+            };
+        }
+
+        // Hold the first fast char very briefly to see if a burst follows.
+        self.pending_first_char = Some((ch, now));
+        CharDecision::HoldFirstChar
+    }
+
+    // Timer: flush buffered burst if timeout elapsed.
+    pub fn flush_if_due(&mut self, now: Instant) -> Option<String> {
+        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 holding a single fast char and no burst followed,
+            // flush it as normal typed input.
+            if let Some((ch, _at)) = self.pending_first_char.take() {
+                return Some(ch.to_string());
+            }
+            None
+        } else {
+            None
+        }
+    }
+
+    // While bursting: accumulate newline instead of submitting.
+    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. Returns start byte and grabbed text
+    // if it looks like a paste (whitespace present or long segment).
+    pub fn decide_begin_buffer(
+        &mut self,
+        now: Instant,
+        before: &str,
+        retro_chars: usize,
+    ) -> Option<RetroGrab> {
+        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.len() >= 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<String> {
+        if self.is_active() {
+            self.active = false;
+            Some(std::mem::take(&mut self.buffer))
+        } else {
+            None
+        }
+    }
+
+    // Clear only the timing window (keep buffer state untouched if already flushed).
+    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;
+    }
+
+    // Utility
+    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 new() -> Self {

We normally list constructors near the top? Also feel free to omit and have callers use ::default().

@@ -0,0 +1,244 @@
+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<Instant>,
+    consecutive_plain_char_burst: u16,
+    burst_window_until: Option<Instant>,
+    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.

newline?

codex-rs/tui/src/chatwidget.rs

@@ -817,6 +817,26 @@ impl ChatWidget {
         self.bottom_pane.handle_paste(text);
     }
 
+    // Returns true if caller should skip rendering this frame (a future tick is scheduled).
+    pub(crate) fn handle_paste_burst_tick(
+        &mut self,
+        frame_requester: &crate::tui::FrameRequester,

import above?

@@ -817,6 +817,26 @@ impl ChatWidget {
         self.bottom_pane.handle_paste(text);
     }
 
+    // Returns true if caller should skip rendering this frame (a future tick is scheduled).
+    pub(crate) fn handle_paste_burst_tick(
+        &mut self,
+        frame_requester: &crate::tui::FrameRequester,
+    ) -> bool {
+        let flushed = self.bottom_pane.flush_paste_burst_if_due();
+        if flushed {
+            self.request_redraw();
+            return false;

Does every code path return false?

@@ -850,6 +852,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();
+            return true;
+        }
+        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(),
+            );
+            return true;
+        }
+        false

Consider:

        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
        }

codex-rs/tui/tests/fixtures/binary-size-log.jsonl

@@ -6709,7 +6709,7 @@
 {"ts":"2025-08-09T15:58:15.433Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"}
 {"ts":"2025-08-09T15:58:15.433Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"}
 {"ts":"2025-08-09T15:58:15.433Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"}
-{"ts":"2025-08-09T15:58:15.433Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"}
+{"ts":"2025-08-09T15:58:15.4F33Z","fdir":"to_tui","kind":"app_event","variant":"RequestRedraw"}

fdir?