Codex Handbook
tui/src/bottom_pane/paste_burst.rs 580 lines
//! Paste-burst detection for terminals without bracketed paste.//!//! On some platforms (notably Windows), pastes often arrive as a rapid stream of//! `KeyCode::Char` and `KeyCode::Enter` key events rather than as a single "paste" event.//! In that mode, the composer needs to://!//! - Prevent transient UI side effects (e.g. toggles bound to `?`) from triggering on pasted text.//! - Ensure Enter is treated as a newline *inside the paste*, not as "submit the message".//! - Avoid flicker caused by inserting a typed prefix and then immediately reclassifying it as//!   paste once enough chars have arrived.//!//! This module provides the `PasteBurst` state machine. `ChatComposer` feeds it only "plain"//! character events (no Ctrl/Alt) and uses the full buffering decisions to either://!//! - briefly hold a first ASCII char (flicker suppression),//! - buffer a burst as a single pasted string, or//! - let input flow through as normal typing.//!//! # Call Pattern//!//! `PasteBurst` is a pure state machine: it never mutates the textarea directly. The caller feeds//! it events and then applies the chosen action://!//! - For each plain `KeyCode::Char`, call [`PasteBurst::on_plain_char`] (ASCII) or//!   [`PasteBurst::on_plain_char_no_hold`] (non-ASCII/IME).//! - If the decision indicates buffering, the caller appends to `PasteBurst.buffer` via//!   [`PasteBurst::append_char_to_buffer`].//! - On a UI tick, call [`PasteBurst::flush_if_due`]. If it returns [`FlushResult::Typed`], insert//!   that char as normal typing. If it returns [`FlushResult::Paste`], treat the returned string as//!   an explicit paste.//! - Before applying non-char input (arrow keys, Ctrl/Alt modifiers, etc.), use//!   [`PasteBurst::flush_before_modified_input`] to avoid leaving buffered text "stuck", and then//!   [`PasteBurst::clear_window_after_non_char`] so subsequent typing does not get grouped into a//!   previous burst.//! - Direct-insert callers can skip buffering, use//!   [`PasteBurst::direct_insert_newline_should_insert`] in their Enter handler, and call//!   [`PasteBurst::extend_window`] when Enter or [`PasteBurst::on_plain_char_no_hold`] reports a//!   burst-like stream.//!//! # State Variables//!//! This state machine is encoded in a few fields with slightly different meanings://!//! - `active`: true while we are still *actively* accepting characters into the current burst.//! - `buffer`: accumulated burst text that will eventually flush as a single `Paste(String)`.//!   A non-empty buffer is treated as "in burst context" even if `active` has been cleared.//! - `pending_first_char`: a single held ASCII char used for flicker suppression. The caller must//!   not render this char until it either becomes part of a burst (`BeginBufferFromPending`) or//!   flushes as a normal typed char (`FlushResult::Typed`).//! - `last_plain_char_time`/`consecutive_plain_char_burst`: the timing/count heuristic for//!   "paste-like" streams.//! - `burst_window_until`: the Enter suppression window ("Enter inserts newline") that outlives the//!   buffer itself.//!//! # Timing Model//!//! There are two timeouts://!//! - `PASTE_BURST_CHAR_INTERVAL`: maximum delay between consecutive "plain" chars for them to be//!   considered part of a single burst. It also bounds how long `pending_first_char` is held.//! - `PASTE_BURST_ACTIVE_IDLE_TIMEOUT`: once buffering is active, how long to wait after the last//!   char before flushing the accumulated buffer as a paste.//!//! `flush_if_due()` intentionally uses `>` (not `>=`) when comparing elapsed time, so tests and UI//! ticks should cross the threshold by at least 1ms (see `recommended_flush_delay()`).//!//! # Retro Capture Details//!//! Retro-capture exists to handle the case where we initially inserted characters as "normal//! typing", but later decide that the stream is paste-like. When that happens, we retroactively//! remove a prefix of already-inserted text from the textarea and move it into the burst buffer so//! the eventual `handle_paste(...)` sees a contiguous pasted string.//!//! Retro-capture mostly matters on paths that do *not* hold the first character (non-ASCII/IME//! input, and retro-grab scenarios). The ASCII path usually prefers//! `RetainFirstChar -> BeginBufferFromPending`, which avoids needing retro-capture at all.//!//! Retro-capture is expressed in terms of characters, not bytes://!//! - `CharDecision::BeginBuffer { retro_chars }` uses `retro_chars` as a character count.//! - `decide_begin_buffer(now, before_cursor, retro_chars)` turns that into a UTF-8 byte range by//!   calling `retro_start_index()`.//! - `RetroGrab.start_byte` is a byte index into the `before_cursor` slice; callers must clamp the//!   cursor to a char boundary before slicing so `start_byte..cursor` is always valid UTF-8.//!//! # Clearing vs Flushing//!//! There are two ways callers end burst handling, and they are not interchangeable://!//! - `flush_before_modified_input()` returns the buffered text (and/or a pending first ASCII char)//!   so the caller can apply it through the normal paste path before handling an unrelated input.//! - `clear_window_after_non_char()` clears the *classification window* so subsequent typing does//!   not get grouped into the previous burst. It assumes the caller has already flushed any buffer//!   because it clears `last_plain_char_time`, which means `flush_if_due()` will not flush a//!   non-empty buffer until another plain char updates the timestamp.//!//! # States (Conceptually)//!//! - **Idle**: no buffered text, no pending char.//! - **Pending first char**: `pending_first_char` holds one ASCII char for up to//!   `PASTE_BURST_CHAR_INTERVAL` while we wait to see if a burst follows.//! - **Active buffer**: `active`/`buffer` holds paste-like content until it times out and flushes.//! - **Enter suppress window**: `burst_window_until` keeps Enter treated as newline briefly after//!   burst activity so multiline pastes stay grouped.//!//! # ASCII vs Non-ASCII//!//! - [`PasteBurst::on_plain_char`] may return [`CharDecision::RetainFirstChar`] to hold the first//!   ASCII char and avoid flicker.//! - [`PasteBurst::on_plain_char_no_hold`] never holds (used for IME/non-ASCII paths), since//!   holding a non-ASCII character can feel like dropped input.//!//! # Contract With Callers//!//! `PasteBurst` does not mutate the UI text buffer on its own. Callers must interpret decisions//! and apply the corresponding UI edits. `ChatComposer` uses the full buffering contract://!//! - For each plain ASCII `KeyCode::Char`, call [`PasteBurst::on_plain_char`].//!   - [`CharDecision::RetainFirstChar`]: do **not** insert the char into the textarea yet.//!   - [`CharDecision::BeginBufferFromPending`]: call [`PasteBurst::append_char_to_buffer`] for the//!     current char (the previously-held char is already in the burst buffer).//!   - [`CharDecision::BeginBuffer { retro_chars }`]: consider retro-capturing the already-inserted//!     prefix by calling [`PasteBurst::decide_begin_buffer`]. If it returns `Some`, remove the//!     returned `start_byte..cursor` range from the textarea and then call//!     [`PasteBurst::append_char_to_buffer`] for the current char. If it returns `None`, fall back//!     to normal insertion.//!   - [`CharDecision::BufferAppend`]: call [`PasteBurst::append_char_to_buffer`].//!//! - For each plain non-ASCII `KeyCode::Char`, call [`PasteBurst::on_plain_char_no_hold`] and then://!   - If it returns `Some(CharDecision::BufferAppend)`, call//!     [`PasteBurst::append_char_to_buffer`].//!   - If it returns `Some(CharDecision::BeginBuffer { retro_chars })`, call//!     [`PasteBurst::decide_begin_buffer`] as above (and if buffering starts, remove the grabbed//!     prefix from the textarea and then append the current char to the buffer).//!   - If it returns `None`, insert normally.//!//! - Before applying non-char input (or any input that should not join a burst), call//!   [`PasteBurst::flush_before_modified_input`] and pass the returned string (if any) through the//!   normal paste path.//!//! - Periodically (e.g. on a UI tick), call [`PasteBurst::flush_if_due`].//!   - [`FlushResult::Typed`]: insert that single char as normal typing.//!   - [`FlushResult::Paste`]: treat the returned string as an explicit paste.//!//! - When a non-plain key is pressed (Ctrl/Alt-modified input, arrows, etc.), callers should use//!   [`PasteBurst::clear_window_after_non_char`] to prevent the next keystroke from being//!   incorrectly grouped into a previous burst.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 recognizedconst PASTE_BURST_MIN_CHARS: u16 = 3;const PASTE_ENTER_SUPPRESS_WINDOW: Duration = Duration::from_millis(120);// Maximum delay between consecutive chars to be considered part of a paste burst.const PASTE_BURST_CHAR_INTERVAL: Duration = Duration::from_millis(8);// Idle timeout before flushing buffered paste content.// Slower paste bursts have been observed in Windows environments.#[cfg(not(windows))]const PASTE_BURST_ACTIVE_IDLE_TIMEOUT: Duration = Duration::from_millis(8);#[cfg(windows)]const PASTE_BURST_ACTIVE_IDLE_TIMEOUT: Duration = Duration::from_millis(60);#[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,}pub(crate) enum FlushResult {    Paste(String),    Typed(char),    None,}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)    }    #[cfg(test)]    pub(crate) fn recommended_active_flush_delay() -> Duration {        PASTE_BURST_ACTIVE_IDLE_TIMEOUT + 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 {        self.note_plain_char(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    }    /// Like on_plain_char(), but never holds the first char.    ///    /// Used for non-ASCII input paths (e.g., IMEs) where holding a character can    /// feel like dropped input, while still allowing burst-based paste detection.    ///    /// Note: This method will only ever return BufferAppend or BeginBuffer.    pub fn on_plain_char_no_hold(&mut self, now: Instant) -> Option<CharDecision> {        self.note_plain_char(now);        if self.active {            self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);            return Some(CharDecision::BufferAppend);        }        if self.consecutive_plain_char_burst >= PASTE_BURST_MIN_CHARS {            return Some(CharDecision::BeginBuffer {                retro_chars: self.consecutive_plain_char_burst.saturating_sub(1),            });        }        None    }    fn note_plain_char(&mut self, now: Instant) {        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);    }    /// Flushes any buffered burst if the inter-key timeout has elapsed.    ///    /// Returns:    ///    /// - [`FlushResult::Paste`] when a paste burst was active and buffered text is emitted as one    ///   pasted string.    /// - [`FlushResult::Typed`] when a single fast first ASCII char was being held (flicker    ///   suppression) and no burst followed before the timeout elapsed.    /// - [`FlushResult::None`] when the timeout has not elapsed, or there is nothing to flush.    pub fn flush_if_due(&mut self, now: Instant) -> FlushResult {        let timeout = if self.is_active_internal() {            PASTE_BURST_ACTIVE_IDLE_TIMEOUT        } else {            PASTE_BURST_CHAR_INTERVAL        };        let timed_out = self            .last_plain_char_time            .is_some_and(|t| now.duration_since(t) > timeout);        if timed_out && self.is_active_internal() {            self.active = false;            let out = std::mem::take(&mut self.buffer);            FlushResult::Paste(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() {                FlushResult::Typed(ch)            } else {                FlushResult::None            }        } else {            FlushResult::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    }    /// Decide if Enter should insert a newline for callers that insert chars immediately.    pub fn direct_insert_newline_should_insert(&self, now: Instant) -> bool {        self.newline_should_insert_instead_of_submit(now)            || self                .last_plain_char_time                .is_some_and(|t| now.duration_since(t) <= PASTE_BURST_CHAR_INTERVAL)    }    /// 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);    }    /// Try to append a char into the burst buffer only if a burst is already active.    ///    /// Returns true when the char was captured into the existing burst, false otherwise.    pub fn try_append_char_if_active(&mut self, ch: char, now: Instant) -> bool {        if self.active || !self.buffer.is_empty() {            self.append_char_to_buffer(ch, now);            true        } else {            false        }    }    /// 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(char::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() {            return None;        }        self.active = false;        let mut out = std::mem::take(&mut self.buffer);        if let Some((ch, _at)) = self.pending_first_char.take() {            out.push(ch);        }        Some(out)    }    /// 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)}#[cfg(test)]mod tests {    use super::*;    use pretty_assertions::assert_eq;    /// Behavior: for ASCII input we "hold" the first fast char briefly. If no burst follows,    /// that held char should eventually flush as normal typed input (not as a paste).    #[test]    fn ascii_first_char_is_held_then_flushes_as_typed() {        let mut burst = PasteBurst::default();        let t0 = Instant::now();        assert!(matches!(            burst.on_plain_char('a', t0),            CharDecision::RetainFirstChar        ));        let t1 = t0 + PasteBurst::recommended_flush_delay() + Duration::from_millis(1);        assert!(matches!(burst.flush_if_due(t1), FlushResult::Typed('a')));        assert!(!burst.is_active());    }    /// Behavior: if two ASCII chars arrive quickly, we should start buffering without ever    /// rendering the first one, then flush the whole buffered payload as a paste.    #[test]    fn ascii_two_fast_chars_start_buffer_from_pending_and_flush_as_paste() {        let mut burst = PasteBurst::default();        let t0 = Instant::now();        assert!(matches!(            burst.on_plain_char('a', t0),            CharDecision::RetainFirstChar        ));        let t1 = t0 + Duration::from_millis(1);        assert!(matches!(            burst.on_plain_char('b', t1),            CharDecision::BeginBufferFromPending        ));        burst.append_char_to_buffer('b', t1);        let t2 = t1 + PasteBurst::recommended_active_flush_delay() + Duration::from_millis(1);        assert!(matches!(            burst.flush_if_due(t2),            FlushResult::Paste(ref s) if s == "ab"        ));    }    /// Behavior: when non-char input is about to be applied, we flush any transient burst state    /// immediately (including a single pending ASCII char) so state doesn't leak across inputs.    #[test]    fn flush_before_modified_input_includes_pending_first_char() {        let mut burst = PasteBurst::default();        let t0 = Instant::now();        assert!(matches!(            burst.on_plain_char('a', t0),            CharDecision::RetainFirstChar        ));        assert_eq!(burst.flush_before_modified_input(), Some("a".to_string()));        assert!(!burst.is_active());    }    /// Behavior: retro-grab buffering is only enabled when the already-inserted prefix looks    /// paste-like (whitespace or "long enough") so short IME bursts don't get misclassified.    #[test]    fn decide_begin_buffer_only_triggers_for_pastey_prefixes() {        let mut burst = PasteBurst::default();        let now = Instant::now();        assert!(            burst                .decide_begin_buffer(now, "ab", /*retro_chars*/ 2)                .is_none()        );        assert!(!burst.is_active());        let grab = burst            .decide_begin_buffer(now, "a b", /*retro_chars*/ 2)            .expect("whitespace should be considered paste-like");        assert_eq!(grab.start_byte, 1);        assert_eq!(grab.grabbed, " b");        assert!(burst.is_active());    }    /// Behavior: after a paste-like burst, we keep an "enter suppression window" alive briefly so    /// a slightly-late Enter still inserts a newline instead of submitting.    #[test]    fn newline_suppression_window_outlives_buffer_flush() {        let mut burst = PasteBurst::default();        let t0 = Instant::now();        assert!(matches!(            burst.on_plain_char('a', t0),            CharDecision::RetainFirstChar        ));        let t1 = t0 + Duration::from_millis(1);        assert!(matches!(            burst.on_plain_char('b', t1),            CharDecision::BeginBufferFromPending        ));        burst.append_char_to_buffer('b', t1);        let t2 = t1 + PasteBurst::recommended_active_flush_delay() + Duration::from_millis(1);        assert!(matches!(burst.flush_if_due(t2), FlushResult::Paste(ref s) if s == "ab"));        assert!(!burst.is_active());        assert!(burst.newline_should_insert_instead_of_submit(t2));        let t3 = t1 + PASTE_ENTER_SUPPRESS_WINDOW + Duration::from_millis(1);        assert!(!burst.newline_should_insert_instead_of_submit(t3));    }}