95 KiB
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 --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
@@ -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_burstwas alreadytruebefore this call?
- Created: 2025-08-28 17:11:46 UTC | Link: https://github.com/openai/codex/pull/2683#discussion_r2308031153
@@ -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
@@ -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 inmod tests?
- Created: 2025-08-28 17:17:40 UTC | Link: https://github.com/openai/codex/pull/2683#discussion_r2308043322
@@ -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;
- Created: 2025-08-28 17:18:29 UTC | Link: https://github.com/openai/codex/pull/2683#discussion_r2308044917
@@ -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
@@ -91,7 +93,10 @@ impl BottomPane {
status: None,
queued_user_messages: Vec::new(),
esc_backtrack_hint: false,
- }
+ };
+ this.composer
I assume that
disable_paste_burstis not a param ofChatComposer::new()because you want the side effects ofset_disable_paste_burst()? It seems a little weird to me, though: should this not happen within theChatComposer::new()constructor instead?
- Created: 2025-08-28 17:21:03 UTC | Link: https://github.com/openai/codex/pull/2683#discussion_r2308050357
@@ -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(
- Created: 2025-08-28 17:21:30 UTC | Link: https://github.com/openai/codex/pull/2683#discussion_r2308051225
@@ -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
- Created: 2025-08-26 00:29:50 UTC | Link: https://github.com/openai/codex/pull/2683#discussion_r2299423837
@@ -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
@@ -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,
RetainFirstCharorSaveFirstCharmight be better names?
- Created: 2025-08-26 00:34:07 UTC | Link: https://github.com/openai/codex/pull/2683#discussion_r2299427612
@@ -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
@@ -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
@@ -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 }
- Created: 2025-08-26 00:36:21 UTC | Link: https://github.com/openai/codex/pull/2683#discussion_r2299429590
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
- Created: 2025-08-26 00:40:51 UTC | Link: https://github.com/openai/codex/pull/2683#discussion_r2299434664
@@ -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?