mirror of
https://github.com/openai/codex.git
synced 2026-04-29 02:41:12 +03:00
2529 lines
95 KiB
Markdown
2529 lines
95 KiB
Markdown
# 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? |