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

2529 lines
95 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# PR #2683: burst paste edge cases
- URL: https://github.com/openai/codex/pull/2683
- Author: aibrahim-oai
- Created: 2025-08-25 20:50:36 UTC
- Updated: 2025-08-28 19:54:24 UTC
- Changes: +632/-158, Files changed: 8, Commits: 33
## 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
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
- Created: 2025-08-28 17:10:56 UTC | Link: https://github.com/openai/codex/pull/2683#discussion_r2308029419
```diff
@@ -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?
- Created: 2025-08-28 17:11:46 UTC | Link: https://github.com/openai/codex/pull/2683#discussion_r2308031153
```diff
@@ -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.
- Created: 2025-08-28 17:16:42 UTC | Link: https://github.com/openai/codex/pull/2683#discussion_r2308041400
```diff
@@ -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`?
- Created: 2025-08-28 17:17:40 UTC | Link: https://github.com/openai/codex/pull/2683#discussion_r2308043322
```diff
@@ -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?
>
> ```suggestion
> let count = 32;
> ```
- Created: 2025-08-28 17:18:29 UTC | Link: https://github.com/openai/codex/pull/2683#discussion_r2308044917
```diff
@@ -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
- Created: 2025-08-28 17:20:28 UTC | Link: https://github.com/openai/codex/pull/2683#discussion_r2308049073
```diff
@@ -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?
- Created: 2025-08-28 17:21:03 UTC | Link: https://github.com/openai/codex/pull/2683#discussion_r2308050357
```diff
@@ -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(
```
> ```suggestion
> ChatComposer::recommended_paste_flush_delay(
> ```
- Created: 2025-08-28 17:21:30 UTC | Link: https://github.com/openai/codex/pull/2683#discussion_r2308051225
```diff
@@ -382,12 +393,24 @@ impl BottomPane {
self.frame_requester.schedule_frame();
}
+ pub(crate) fn request_redraw_in(&self, dur: std::time::Duration) {
```
> ```suggestion
> pub(crate) fn request_redraw_in(&self, dur: Duration) {
> ```
### codex-rs/tui/src/bottom_pane/paste_burst.rs
- Created: 2025-08-26 00:29:50 UTC | Link: https://github.com/openai/codex/pull/2683#discussion_r2299423837
```diff
@@ -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.
- Created: 2025-08-26 00:33:30 UTC | Link: https://github.com/openai/codex/pull/2683#discussion_r2299427109
```diff
@@ -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?
- Created: 2025-08-26 00:34:07 UTC | Link: https://github.com/openai/codex/pull/2683#discussion_r2299427612
```diff
@@ -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.
- Created: 2025-08-26 00:35:20 UTC | Link: https://github.com/openai/codex/pull/2683#discussion_r2299428668
```diff
@@ -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.
- Created: 2025-08-26 00:35:52 UTC | Link: https://github.com/openai/codex/pull/2683#discussion_r2299429194
```diff
@@ -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:
>
> ```suggestion
> // flush it as normal typed input.
> if let Some((ch, _at)) = self.pending_first_char.take() {
> Some(ch.to_string())
> } else {
> None
> }
> ```
- Created: 2025-08-26 00:36:21 UTC | Link: https://github.com/openai/codex/pull/2683#discussion_r2299429590
```diff
@@ -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.
- Created: 2025-08-26 00:38:37 UTC | Link: https://github.com/openai/codex/pull/2683#discussion_r2299431740
```diff
@@ -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?
- Created: 2025-08-26 00:39:06 UTC | Link: https://github.com/openai/codex/pull/2683#discussion_r2299432189
```diff
@@ -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?
- Created: 2025-08-26 00:39:33 UTC | Link: https://github.com/openai/codex/pull/2683#discussion_r2299432735
```diff
@@ -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()`.
- Created: 2025-08-28 17:22:21 UTC | Link: https://github.com/openai/codex/pull/2683#discussion_r2308053010
```diff
@@ -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
- Created: 2025-08-26 00:40:08 UTC | Link: https://github.com/openai/codex/pull/2683#discussion_r2299433356
```diff
@@ -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?
- Created: 2025-08-26 00:40:30 UTC | Link: https://github.com/openai/codex/pull/2683#discussion_r2299434022
```diff
@@ -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`?
- Created: 2025-08-28 17:25:33 UTC | Link: https://github.com/openai/codex/pull/2683#discussion_r2308062055
```diff
@@ -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:
>
> ```suggestion
> 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
- Created: 2025-08-26 00:40:51 UTC | Link: https://github.com/openai/codex/pull/2683#discussion_r2299434664
```diff
@@ -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?