Skip to main content

supercode_frontend_model/
paste_burst.rs

1// Derived from OpenAI Codex: codex-rs/tui/src/bottom_pane/paste_burst.rs
2// Pinned source: 8604689ec5e3437eb79802d8d72249b7722fbf5b
3// Copyright 2025 OpenAI
4// Licensed under the Apache License, Version 2.0.
5// Modified by the Supercode contributors; see docs/legal/codex-frontend-extraction.toml.
6
7//! Paste-burst detection for terminals without bracketed paste.
8//!
9//! On some platforms (notably Windows), pastes often arrive as a rapid stream of
10//! `KeyCode::Char` and `KeyCode::Enter` key events rather than as a single "paste" event.
11//! In that mode, the composer needs to:
12//!
13//! - Prevent transient UI side effects (e.g. toggles bound to `?`) from triggering on pasted text.
14//! - Ensure Enter is treated as a newline *inside the paste*, not as "submit the message".
15//! - Avoid flicker caused by inserting a typed prefix and then immediately reclassifying it as
16//!   paste once enough chars have arrived.
17//!
18//! This module provides the `PasteBurst` state machine. `ChatComposer` feeds it only "plain"
19//! character events (no Ctrl/Alt) and uses the full buffering decisions to either:
20//!
21//! - briefly hold a first ASCII char (flicker suppression),
22//! - buffer a burst as a single pasted string, or
23//! - let input flow through as normal typing.
24//!
25//! # Call Pattern
26//!
27//! `PasteBurst` is a pure state machine: it never mutates the textarea directly. The caller feeds
28//! it events and then applies the chosen action:
29//!
30//! - For each plain `KeyCode::Char`, call [`PasteBurst::on_plain_char`] (ASCII) or
31//!   [`PasteBurst::on_plain_char_no_hold`] (non-ASCII/IME).
32//! - If the decision indicates buffering, the caller appends to `PasteBurst.buffer` via
33//!   [`PasteBurst::append_char_to_buffer`].
34//! - On a UI tick, call [`PasteBurst::flush_if_due`]. If it returns [`FlushResult::Typed`], insert
35//!   that char as normal typing. If it returns [`FlushResult::Paste`], treat the returned string as
36//!   an explicit paste.
37//! - Before applying non-char input (arrow keys, Ctrl/Alt modifiers, etc.), use
38//!   [`PasteBurst::flush_before_modified_input`] to avoid leaving buffered text "stuck", and then
39//!   [`PasteBurst::clear_window_after_non_char`] so subsequent typing does not get grouped into a
40//!   previous burst.
41//! - Direct-insert callers can skip buffering, use
42//!   [`PasteBurst::direct_insert_newline_should_insert`] in their Enter handler, and call
43//!   [`PasteBurst::extend_window`] when Enter or [`PasteBurst::on_plain_char_no_hold`] reports a
44//!   burst-like stream.
45//!
46//! # State Variables
47//!
48//! This state machine is encoded in a few fields with slightly different meanings:
49//!
50//! - `active`: true while we are still *actively* accepting characters into the current burst.
51//! - `buffer`: accumulated burst text that will eventually flush as a single `Paste(String)`.
52//!   A non-empty buffer is treated as "in burst context" even if `active` has been cleared.
53//! - `pending_first_char`: a single held ASCII char used for flicker suppression. The caller must
54//!   not render this char until it either becomes part of a burst (`BeginBufferFromPending`) or
55//!   flushes as a normal typed char (`FlushResult::Typed`).
56//! - `last_plain_char_time`/`consecutive_plain_char_burst`: the timing/count heuristic for
57//!   "paste-like" streams.
58//! - `burst_window_until`: the Enter suppression window ("Enter inserts newline") that outlives the
59//!   buffer itself.
60//!
61//! # Timing Model
62//!
63//! There are two timeouts:
64//!
65//! - `PASTE_BURST_CHAR_INTERVAL`: maximum delay between consecutive "plain" chars for them to be
66//!   considered part of a single burst. It also bounds how long `pending_first_char` is held.
67//! - `PASTE_BURST_ACTIVE_IDLE_TIMEOUT`: once buffering is active, how long to wait after the last
68//!   char before flushing the accumulated buffer as a paste.
69//!
70//! `flush_if_due()` intentionally uses `>` (not `>=`) when comparing elapsed time, so tests and UI
71//! ticks should cross the threshold by at least 1ms (see `recommended_flush_delay()`).
72//!
73//! # Retro Capture Details
74//!
75//! Retro-capture exists to handle the case where we initially inserted characters as "normal
76//! typing", but later decide that the stream is paste-like. When that happens, we retroactively
77//! remove a prefix of already-inserted text from the textarea and move it into the burst buffer so
78//! the eventual `handle_paste(...)` sees a contiguous pasted string.
79//!
80//! Retro-capture mostly matters on paths that do *not* hold the first character (non-ASCII/IME
81//! input, and retro-grab scenarios). The ASCII path usually prefers
82//! `RetainFirstChar -> BeginBufferFromPending`, which avoids needing retro-capture at all.
83//!
84//! Retro-capture is expressed in terms of characters, not bytes:
85//!
86//! - `CharDecision::BeginBuffer { retro_chars }` uses `retro_chars` as a character count.
87//! - `decide_begin_buffer(now, before_cursor, retro_chars)` turns that into a UTF-8 byte range by
88//!   calling `retro_start_index()`.
89//! - `RetroGrab.start_byte` is a byte index into the `before_cursor` slice; callers must clamp the
90//!   cursor to a char boundary before slicing so `start_byte..cursor` is always valid UTF-8.
91//!
92//! # Clearing vs Flushing
93//!
94//! There are two ways callers end burst handling, and they are not interchangeable:
95//!
96//! - `flush_before_modified_input()` returns the buffered text (and/or a pending first ASCII char)
97//!   so the caller can apply it through the normal paste path before handling an unrelated input.
98//! - `clear_window_after_non_char()` clears the *classification window* so subsequent typing does
99//!   not get grouped into the previous burst. It assumes the caller has already flushed any buffer
100//!   because it clears `last_plain_char_time`, which means `flush_if_due()` will not flush a
101//!   non-empty buffer until another plain char updates the timestamp.
102//!
103//! # States (Conceptually)
104//!
105//! - **Idle**: no buffered text, no pending char.
106//! - **Pending first char**: `pending_first_char` holds one ASCII char for up to
107//!   `PASTE_BURST_CHAR_INTERVAL` while we wait to see if a burst follows.
108//! - **Active buffer**: `active`/`buffer` holds paste-like content until it times out and flushes.
109//! - **Enter suppress window**: `burst_window_until` keeps Enter treated as newline briefly after
110//!   burst activity so multiline pastes stay grouped.
111//!
112//! # ASCII vs Non-ASCII
113//!
114//! - [`PasteBurst::on_plain_char`] may return [`CharDecision::RetainFirstChar`] to hold the first
115//!   ASCII char and avoid flicker.
116//! - [`PasteBurst::on_plain_char_no_hold`] never holds (used for IME/non-ASCII paths), since
117//!   holding a non-ASCII character can feel like dropped input.
118//!
119//! # Contract With Callers
120//!
121//! `PasteBurst` does not mutate the UI text buffer on its own. Callers must interpret decisions
122//! and apply the corresponding UI edits. `ChatComposer` uses the full buffering contract:
123//!
124//! - For each plain ASCII `KeyCode::Char`, call [`PasteBurst::on_plain_char`].
125//!   - [`CharDecision::RetainFirstChar`]: do **not** insert the char into the textarea yet.
126//!   - [`CharDecision::BeginBufferFromPending`]: call [`PasteBurst::append_char_to_buffer`] for the
127//!     current char (the previously-held char is already in the burst buffer).
128//!   - [`CharDecision::BeginBuffer { retro_chars }`]: consider retro-capturing the already-inserted
129//!     prefix by calling [`PasteBurst::decide_begin_buffer`]. If it returns `Some`, remove the
130//!     returned `start_byte..cursor` range from the textarea and then call
131//!     [`PasteBurst::append_char_to_buffer`] for the current char. If it returns `None`, fall back
132//!     to normal insertion.
133//!   - [`CharDecision::BufferAppend`]: call [`PasteBurst::append_char_to_buffer`].
134//!
135//! - For each plain non-ASCII `KeyCode::Char`, call [`PasteBurst::on_plain_char_no_hold`] and then:
136//!   - If it returns `Some(CharDecision::BufferAppend)`, call
137//!     [`PasteBurst::append_char_to_buffer`].
138//!   - If it returns `Some(CharDecision::BeginBuffer { retro_chars })`, call
139//!     [`PasteBurst::decide_begin_buffer`] as above (and if buffering starts, remove the grabbed
140//!     prefix from the textarea and then append the current char to the buffer).
141//!   - If it returns `None`, insert normally.
142//!
143//! - Before applying non-char input (or any input that should not join a burst), call
144//!   [`PasteBurst::flush_before_modified_input`] and pass the returned string (if any) through the
145//!   normal paste path.
146//!
147//! - Periodically (e.g. on a UI tick), call [`PasteBurst::flush_if_due`].
148//!   - [`FlushResult::Typed`]: insert that single char as normal typing.
149//!   - [`FlushResult::Paste`]: treat the returned string as an explicit paste.
150//!
151//! - When a non-plain key is pressed (Ctrl/Alt-modified input, arrows, etc.), callers should use
152//!   [`PasteBurst::clear_window_after_non_char`] to prevent the next keystroke from being
153//!   incorrectly grouped into a previous burst.
154
155use std::time::Duration;
156use std::time::Instant;
157
158// Heuristic thresholds for detecting paste-like input bursts.
159// Detect quickly to avoid showing typed prefix before paste is recognized
160const PASTE_BURST_MIN_CHARS: u16 = 3;
161const PASTE_ENTER_SUPPRESS_WINDOW: Duration = Duration::from_millis(120);
162
163// Maximum delay between consecutive chars to be considered part of a paste burst.
164const PASTE_BURST_CHAR_INTERVAL: Duration = Duration::from_millis(8);
165
166// Idle timeout before flushing buffered paste content.
167// Slower paste bursts have been observed in Windows environments.
168#[cfg(not(windows))]
169const PASTE_BURST_ACTIVE_IDLE_TIMEOUT: Duration = Duration::from_millis(8);
170#[cfg(windows)]
171const PASTE_BURST_ACTIVE_IDLE_TIMEOUT: Duration = Duration::from_millis(60);
172
173#[derive(Clone, Debug, Default)]
174pub struct PasteBurst {
175    last_plain_char_time: Option<Instant>,
176    consecutive_plain_char_burst: u16,
177    burst_window_until: Option<Instant>,
178    buffer: String,
179    active: bool,
180    // Hold first fast char briefly to avoid rendering flicker
181    pending_first_char: Option<(char, Instant)>,
182}
183
184pub enum CharDecision {
185    /// Start buffering and retroactively capture some already-inserted chars.
186    BeginBuffer { retro_chars: u16 },
187    /// We are currently buffering; append the current char into the buffer.
188    BufferAppend,
189    /// Do not insert/render this char yet; temporarily save the first fast
190    /// char while we wait to see if a paste-like burst follows.
191    RetainFirstChar,
192    /// Begin buffering using the previously saved first char (no retro grab needed).
193    BeginBufferFromPending,
194}
195
196pub struct RetroGrab {
197    pub start_byte: usize,
198    pub grabbed: String,
199}
200
201pub enum FlushResult {
202    Paste(String),
203    Typed(char),
204    None,
205}
206
207impl PasteBurst {
208    /// Recommended delay to wait between simulated keypresses (or before
209    /// scheduling a UI tick) so that a pending fast keystroke is flushed
210    /// out of the burst detector as normal typed input.
211    ///
212    /// Primarily used by tests and by the TUI to reliably cross the
213    /// paste-burst timing threshold.
214    pub fn recommended_flush_delay() -> Duration {
215        PASTE_BURST_CHAR_INTERVAL + Duration::from_millis(1)
216    }
217
218    #[cfg(test)]
219    pub fn recommended_active_flush_delay() -> Duration {
220        PASTE_BURST_ACTIVE_IDLE_TIMEOUT + Duration::from_millis(1)
221    }
222
223    /// Entry point: decide how to treat a plain char with current timing.
224    pub fn on_plain_char(&mut self, ch: char, now: Instant) -> CharDecision {
225        self.note_plain_char(now);
226
227        if self.active {
228            self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
229            return CharDecision::BufferAppend;
230        }
231
232        // If we already held a first char and receive a second fast char,
233        // start buffering without retro-grabbing (we never rendered the first).
234        if let Some((held, held_at)) = self.pending_first_char {
235            if now.duration_since(held_at) <= PASTE_BURST_CHAR_INTERVAL {
236                self.active = true;
237                // take() to clear pending; we already captured the held char above
238                let _ = self.pending_first_char.take();
239                self.buffer.push(held);
240                self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
241                return CharDecision::BeginBufferFromPending;
242            }
243        }
244
245        if self.consecutive_plain_char_burst >= PASTE_BURST_MIN_CHARS {
246            return CharDecision::BeginBuffer {
247                retro_chars: self.consecutive_plain_char_burst.saturating_sub(1),
248            };
249        }
250
251        // Save the first fast char very briefly to see if a burst follows.
252        self.pending_first_char = Some((ch, now));
253        CharDecision::RetainFirstChar
254    }
255
256    /// Like on_plain_char(), but never holds the first char.
257    ///
258    /// Used for non-ASCII input paths (e.g., IMEs) where holding a character can
259    /// feel like dropped input, while still allowing burst-based paste detection.
260    ///
261    /// Note: This method will only ever return BufferAppend or BeginBuffer.
262    pub fn on_plain_char_no_hold(&mut self, now: Instant) -> Option<CharDecision> {
263        self.note_plain_char(now);
264
265        if self.active {
266            self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
267            return Some(CharDecision::BufferAppend);
268        }
269
270        if self.consecutive_plain_char_burst >= PASTE_BURST_MIN_CHARS {
271            return Some(CharDecision::BeginBuffer {
272                retro_chars: self.consecutive_plain_char_burst.saturating_sub(1),
273            });
274        }
275
276        None
277    }
278
279    fn note_plain_char(&mut self, now: Instant) {
280        match self.last_plain_char_time {
281            Some(prev) if now.duration_since(prev) <= PASTE_BURST_CHAR_INTERVAL => {
282                self.consecutive_plain_char_burst =
283                    self.consecutive_plain_char_burst.saturating_add(1)
284            }
285            _ => self.consecutive_plain_char_burst = 1,
286        }
287        self.last_plain_char_time = Some(now);
288    }
289
290    /// Flushes any buffered burst if the inter-key timeout has elapsed.
291    ///
292    /// Returns:
293    ///
294    /// - [`FlushResult::Paste`] when a paste burst was active and buffered text is emitted as one
295    ///   pasted string.
296    /// - [`FlushResult::Typed`] when a single fast first ASCII char was being held (flicker
297    ///   suppression) and no burst followed before the timeout elapsed.
298    /// - [`FlushResult::None`] when the timeout has not elapsed, or there is nothing to flush.
299    pub fn flush_if_due(&mut self, now: Instant) -> FlushResult {
300        let timeout = if self.is_active_internal() {
301            PASTE_BURST_ACTIVE_IDLE_TIMEOUT
302        } else {
303            PASTE_BURST_CHAR_INTERVAL
304        };
305        let timed_out = self
306            .last_plain_char_time
307            .is_some_and(|t| now.duration_since(t) > timeout);
308        if timed_out && self.is_active_internal() {
309            self.active = false;
310            let out = std::mem::take(&mut self.buffer);
311            FlushResult::Paste(out)
312        } else if timed_out {
313            // If we were saving a single fast char and no burst followed,
314            // flush it as normal typed input.
315            if let Some((ch, _at)) = self.pending_first_char.take() {
316                FlushResult::Typed(ch)
317            } else {
318                FlushResult::None
319            }
320        } else {
321            FlushResult::None
322        }
323    }
324
325    /// While bursting: accumulate a newline into the buffer instead of
326    /// submitting the textarea.
327    ///
328    /// Returns true if a newline was appended (we are in a burst context),
329    /// false otherwise.
330    pub fn append_newline_if_active(&mut self, now: Instant) -> bool {
331        if self.is_active() {
332            self.buffer.push('\n');
333            self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
334            true
335        } else {
336            false
337        }
338    }
339
340    /// Decide if Enter should insert a newline (burst context) vs submit.
341    pub fn newline_should_insert_instead_of_submit(&self, now: Instant) -> bool {
342        let in_burst_window = self.burst_window_until.is_some_and(|until| now <= until);
343        self.is_active() || in_burst_window
344    }
345
346    /// Decide if Enter should insert a newline for callers that insert chars immediately.
347    pub fn direct_insert_newline_should_insert(&self, now: Instant) -> bool {
348        self.newline_should_insert_instead_of_submit(now)
349            || (self.consecutive_plain_char_burst >= PASTE_BURST_MIN_CHARS
350                && self
351                    .last_plain_char_time
352                    .is_some_and(|t| now.duration_since(t) <= PASTE_BURST_CHAR_INTERVAL))
353    }
354
355    /// Keep the burst window alive.
356    pub fn extend_window(&mut self, now: Instant) {
357        self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
358    }
359
360    /// Begin buffering with retroactively grabbed text.
361    pub fn begin_with_retro_grabbed(&mut self, grabbed: String, now: Instant) {
362        if !grabbed.is_empty() {
363            self.buffer.push_str(&grabbed);
364        }
365        self.active = true;
366        self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
367    }
368
369    /// Append a char into the burst buffer.
370    pub fn append_char_to_buffer(&mut self, ch: char, now: Instant) {
371        self.buffer.push(ch);
372        self.burst_window_until = Some(now + PASTE_ENTER_SUPPRESS_WINDOW);
373    }
374
375    /// Try to append a char into the burst buffer only if a burst is already active.
376    ///
377    /// Returns true when the char was captured into the existing burst, false otherwise.
378    pub fn try_append_char_if_active(&mut self, ch: char, now: Instant) -> bool {
379        if self.active || !self.buffer.is_empty() {
380            self.append_char_to_buffer(ch, now);
381            true
382        } else {
383            false
384        }
385    }
386
387    /// Decide whether to begin buffering by retroactively capturing recent
388    /// chars from the slice before the cursor.
389    ///
390    /// Heuristic: if the retro-grabbed slice contains any whitespace or is
391    /// sufficiently long (>= 16 characters), treat it as paste-like to avoid
392    /// rendering the typed prefix momentarily before the paste is recognized.
393    /// This favors responsiveness and prevents flicker for typical pastes
394    /// (URLs, file paths, multiline text) while not triggering on short words.
395    ///
396    /// Returns Some(RetroGrab) with the start byte and grabbed text when we
397    /// decide to buffer retroactively; otherwise None.
398    pub fn decide_begin_buffer(
399        &mut self,
400        now: Instant,
401        before: &str,
402        retro_chars: usize,
403    ) -> Option<RetroGrab> {
404        let start_byte = retro_start_index(before, retro_chars);
405        let grabbed = before[start_byte..].to_string();
406        let looks_pastey =
407            grabbed.chars().any(char::is_whitespace) || grabbed.chars().count() >= 16;
408        if looks_pastey {
409            // Note: caller is responsible for removing this slice from UI text.
410            self.begin_with_retro_grabbed(grabbed.clone(), now);
411            Some(RetroGrab {
412                start_byte,
413                grabbed,
414            })
415        } else {
416            None
417        }
418    }
419
420    /// Before applying modified/non-char input: flush buffered burst immediately.
421    pub fn flush_before_modified_input(&mut self) -> Option<String> {
422        if !self.is_active() {
423            return None;
424        }
425        self.active = false;
426        let mut out = std::mem::take(&mut self.buffer);
427        if let Some((ch, _at)) = self.pending_first_char.take() {
428            out.push(ch);
429        }
430        Some(out)
431    }
432
433    /// Clear only the timing window and any pending first-char.
434    ///
435    /// Does not emit or clear the buffered text itself; callers should have
436    /// already flushed (if needed) via one of the flush methods above.
437    pub fn clear_window_after_non_char(&mut self) {
438        self.consecutive_plain_char_burst = 0;
439        self.last_plain_char_time = None;
440        self.burst_window_until = None;
441        self.active = false;
442        self.pending_first_char = None;
443    }
444
445    /// Returns true if we are in any paste-burst related transient state
446    /// (actively buffering, have a non-empty buffer, or have saved the first
447    /// fast char while waiting for a potential burst).
448    pub fn is_active(&self) -> bool {
449        self.is_active_internal() || self.pending_first_char.is_some()
450    }
451
452    fn is_active_internal(&self) -> bool {
453        self.active || !self.buffer.is_empty()
454    }
455
456    pub fn clear_after_explicit_paste(&mut self) {
457        self.last_plain_char_time = None;
458        self.consecutive_plain_char_burst = 0;
459        self.burst_window_until = None;
460        self.active = false;
461        self.buffer.clear();
462        self.pending_first_char = None;
463    }
464}
465
466pub fn retro_start_index(before: &str, retro_chars: usize) -> usize {
467    if retro_chars == 0 {
468        return before.len();
469    }
470    before
471        .char_indices()
472        .rev()
473        .nth(retro_chars.saturating_sub(1))
474        .map(|(idx, _)| idx)
475        .unwrap_or(0)
476}
477
478#[cfg(test)]
479mod tests {
480    use super::*;
481    use pretty_assertions::assert_eq;
482
483    /// Behavior: for ASCII input we "hold" the first fast char briefly. If no burst follows,
484    /// that held char should eventually flush as normal typed input (not as a paste).
485    #[test]
486    fn ascii_first_char_is_held_then_flushes_as_typed() {
487        let mut burst = PasteBurst::default();
488        let t0 = Instant::now();
489        assert!(matches!(
490            burst.on_plain_char('a', t0),
491            CharDecision::RetainFirstChar
492        ));
493
494        let t1 = t0 + PasteBurst::recommended_flush_delay() + Duration::from_millis(1);
495        assert!(matches!(burst.flush_if_due(t1), FlushResult::Typed('a')));
496        assert!(!burst.is_active());
497    }
498
499    /// Behavior: if two ASCII chars arrive quickly, we should start buffering without ever
500    /// rendering the first one, then flush the whole buffered payload as a paste.
501    #[test]
502    fn ascii_two_fast_chars_start_buffer_from_pending_and_flush_as_paste() {
503        let mut burst = PasteBurst::default();
504        let t0 = Instant::now();
505        assert!(matches!(
506            burst.on_plain_char('a', t0),
507            CharDecision::RetainFirstChar
508        ));
509
510        let t1 = t0 + Duration::from_millis(1);
511        assert!(matches!(
512            burst.on_plain_char('b', t1),
513            CharDecision::BeginBufferFromPending
514        ));
515        burst.append_char_to_buffer('b', t1);
516
517        let t2 = t1 + PasteBurst::recommended_active_flush_delay() + Duration::from_millis(1);
518        assert!(matches!(
519            burst.flush_if_due(t2),
520            FlushResult::Paste(ref s) if s == "ab"
521        ));
522    }
523
524    /// Behavior: when non-char input is about to be applied, we flush any transient burst state
525    /// immediately (including a single pending ASCII char) so state doesn't leak across inputs.
526    #[test]
527    fn flush_before_modified_input_includes_pending_first_char() {
528        let mut burst = PasteBurst::default();
529        let t0 = Instant::now();
530        assert!(matches!(
531            burst.on_plain_char('a', t0),
532            CharDecision::RetainFirstChar
533        ));
534
535        assert_eq!(burst.flush_before_modified_input(), Some("a".to_string()));
536        assert!(!burst.is_active());
537    }
538
539    /// Behavior: retro-grab buffering is only enabled when the already-inserted prefix looks
540    /// paste-like (whitespace or "long enough") so short IME bursts don't get misclassified.
541    #[test]
542    fn decide_begin_buffer_only_triggers_for_pastey_prefixes() {
543        let mut burst = PasteBurst::default();
544        let now = Instant::now();
545
546        assert!(burst
547            .decide_begin_buffer(now, "ab", /*retro_chars*/ 2)
548            .is_none());
549        assert!(!burst.is_active());
550
551        let grab = burst
552            .decide_begin_buffer(now, "a b", /*retro_chars*/ 2)
553            .expect("whitespace should be considered paste-like");
554        assert_eq!(grab.start_byte, 1);
555        assert_eq!(grab.grabbed, " b");
556        assert!(burst.is_active());
557    }
558
559    /// Behavior: after a paste-like burst, we keep an "enter suppression window" alive briefly so
560    /// a slightly-late Enter still inserts a newline instead of submitting.
561    #[test]
562    fn newline_suppression_window_outlives_buffer_flush() {
563        let mut burst = PasteBurst::default();
564        let t0 = Instant::now();
565        assert!(matches!(
566            burst.on_plain_char('a', t0),
567            CharDecision::RetainFirstChar
568        ));
569
570        let t1 = t0 + Duration::from_millis(1);
571        assert!(matches!(
572            burst.on_plain_char('b', t1),
573            CharDecision::BeginBufferFromPending
574        ));
575        burst.append_char_to_buffer('b', t1);
576
577        let t2 = t1 + PasteBurst::recommended_active_flush_delay() + Duration::from_millis(1);
578        assert!(matches!(burst.flush_if_due(t2), FlushResult::Paste(ref s) if s == "ab"));
579        assert!(!burst.is_active());
580
581        assert!(burst.newline_should_insert_instead_of_submit(t2));
582        let t3 = t1 + PASTE_ENTER_SUPPRESS_WINDOW + Duration::from_millis(1);
583        assert!(!burst.newline_should_insert_instead_of_submit(t3));
584    }
585}