fix(tui): document paste-burst state machine (#9020)

Add a narrative doc and inline rustdoc explaining how `ChatComposer`
and `PasteBurst` compose into a single state machine on terminals that
lack reliable bracketed paste (notably Windows).

This documents the key states, invariants, and integration points
(`handle_input_basic`, `handle_non_ascii_char`, tick-driven flush) so
future changes are easier to reason about.
This commit is contained in:
Josh McKinney 2026-01-13 11:48:31 -08:00 committed by GitHub
parent 2651980bdf
commit 58e8f75b27
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 854 additions and 28 deletions

6
.markdownlint-cli2.yaml Normal file
View file

@ -0,0 +1,6 @@
config:
MD013:
line_length: 100
globs:
- "docs/tui-chat-composer.md"

View file

@ -0,0 +1,15 @@
# TUI bottom pane (state machines)
When changing the paste-burst or chat-composer state machines in this folder, keep the docs in sync:
- Update the relevant module docs (`chat_composer.rs` and/or `paste_burst.rs`) so they remain a
readable, top-down explanation of the current behavior.
- Update the narrative doc `docs/tui-chat-composer.md` whenever behavior/assumptions change (Enter
handling, retro-capture, flush/clear rules, `disable_paste_burst`, non-ASCII/IME handling).
- Keep `codex-rs/tui` and `codex-rs/tui2` implementations/docstrings aligned unless the divergence
is intentional and documented.
Practical check:
- After edits, sanity-check that docs mention only APIs/behavior that exist in code (especially the
Enter/newline paths and `disable_paste_burst` semantics).

View file

@ -1,3 +1,62 @@
//! The chat composer is the bottom-pane text input state machine.
//!
//! It is responsible for:
//!
//! - Editing the input buffer (a [`TextArea`]), including placeholder "elements" for attachments.
//! - Routing keys to the active popup (slash commands, file search, skill mentions).
//! - Handling submit vs newline on Enter.
//! - Turning raw key streams into explicit paste operations on platforms where terminals
//! don't provide reliable bracketed paste (notably Windows).
//!
//! # Key Event Routing
//!
//! Most key handling goes through [`ChatComposer::handle_key_event`], which dispatches to a
//! popup-specific handler if a popup is visible and otherwise to
//! [`ChatComposer::handle_key_event_without_popup`]. After every handled key, we call
//! [`ChatComposer::sync_popups`] so UI state follows the latest buffer/cursor.
//!
//! # Non-bracketed Paste Bursts
//!
//! On some terminals (especially on Windows), pastes arrive as a rapid sequence of
//! `KeyCode::Char` and `KeyCode::Enter` key events instead of a single paste event.
//!
//! To avoid misinterpreting these bursts as real typing (and to prevent transient UI effects like
//! shortcut overlays toggling on a pasted `?`), we feed "plain" character events into
//! [`PasteBurst`](super::paste_burst::PasteBurst), which buffers bursts and later flushes them
//! through [`ChatComposer::handle_paste`].
//!
//! The burst detector intentionally treats ASCII and non-ASCII differently:
//!
//! - ASCII: we briefly hold the first fast char (flicker suppression) until we know whether the
//! stream is paste-like.
//! - non-ASCII: we do not hold the first char (IME input would feel dropped), but we still allow
//! burst detection for actual paste streams.
//!
//! The burst detector can also be disabled (`disable_paste_burst`), which bypasses the state
//! machine and treats the key stream as normal typing.
//!
//! For the detailed burst state machine, see `codex-rs/tui/src/bottom_pane/paste_burst.rs`.
//! For a narrative overview of the combined state machine, see `docs/tui-chat-composer.md`.
//!
//! # PasteBurst Integration Points
//!
//! The burst detector is consulted in a few specific places:
//!
//! - [`ChatComposer::handle_input_basic`]: flushes any due burst first, then intercepts plain char
//! input to either buffer it or insert normally.
//! - [`ChatComposer::handle_non_ascii_char`]: handles the non-ASCII/IME path without holding the
//! first char, while still allowing paste detection via retro-capture.
//! - [`ChatComposer::flush_paste_burst_if_due`]/[`ChatComposer::handle_paste_burst_flush`]: called
//! from UI ticks to turn a pending burst into either an explicit paste (`handle_paste`) or a
//! normal typed character.
//!
//! # Input Disabled Mode
//!
//! The composer can be temporarily read-only (`input_enabled = false`). In that mode it ignores
//! edits and renders a placeholder prompt instead of the editable textarea. This is part of the
//! overall state machine, since it affects which transitions are even possible from a given UI
//! state.
use crate::key_hint::has_ctrl_or_alt;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
@ -122,7 +181,7 @@ pub(crate) struct ChatComposer {
/// When false, the composer is temporarily read-only (e.g. during sandbox setup).
input_enabled: bool,
input_disabled_placeholder: Option<String>,
// Non-bracketed paste burst tracker.
/// Non-bracketed paste burst tracker (see `bottom_pane/paste_burst.rs`).
paste_burst: PasteBurst,
// When true, disables paste-burst logic and inserts characters immediately.
disable_paste_burst: bool,
@ -257,6 +316,24 @@ impl ChatComposer {
true
}
/// Integrate pasted text into the composer.
///
/// Acts as the only place where paste text is integrated, both for:
///
/// - Real/explicit paste events surfaced by the terminal, and
/// - Non-bracketed "paste bursts" that [`PasteBurst`](super::paste_burst::PasteBurst) buffers
/// and later flushes here.
///
/// Behavior:
///
/// - If the paste is larger than `LARGE_PASTE_CHAR_THRESHOLD` chars, inserts a placeholder
/// element (expanded on submit) and stores the full text in `pending_pastes`.
/// - Otherwise, if the paste looks like an image path, attaches the image and inserts a
/// trailing space so the user can keep typing naturally.
/// - Otherwise, inserts the pasted text directly into the textarea.
///
/// In all cases, clears any paste-burst Enter suppression state so a real paste cannot affect
/// the next user Enter key, then syncs popup state.
pub fn handle_paste(&mut self, pasted: String) -> bool {
let char_count = pasted.chars().count();
if char_count > LARGE_PASTE_CHAR_THRESHOLD {
@ -297,6 +374,16 @@ impl ChatComposer {
}
}
/// Enable or disable paste-burst handling.
///
/// `disable_paste_burst` is an escape hatch for terminals/platforms where the burst heuristic
/// is unwanted or has already been handled elsewhere.
///
/// When enabling the flag we clear the burst classification window so subsequent input cannot
/// be incorrectly grouped into a previous burst.
///
/// This does not flush any in-progress buffer; callers should avoid toggling this mid-burst
/// (or should flush first).
pub(crate) fn set_disable_paste_burst(&mut self, disabled: bool) {
let was_disabled = self.disable_paste_burst;
self.disable_paste_burst = disabled;
@ -417,7 +504,7 @@ impl ChatComposer {
self.textarea.text().to_string()
}
/// Attempt to start a burst by retro-capturing recent chars before the cursor.
/// Insert an attachment placeholder and track it for the next submission.
pub fn attach_image(&mut self, path: PathBuf) {
let image_number = self.attached_images.len() + 1;
let placeholder = local_image_label_text(image_number);
@ -433,14 +520,31 @@ impl ChatComposer {
images.into_iter().map(|img| img.path).collect()
}
/// Flushes any due paste-burst state.
///
/// Call this from a UI tick to turn paste-burst transient state into explicit textarea edits:
///
/// - If a burst times out, flush it via `handle_paste(String)`.
/// - If only the first ASCII char was held (flicker suppression) and no burst followed, emit it
/// as normal typed input.
///
/// This also allows a single "held" ASCII char to render even when it turns out not to be part
/// of a paste burst.
pub(crate) fn flush_paste_burst_if_due(&mut self) -> bool {
self.handle_paste_burst_flush(Instant::now())
}
/// Returns whether the composer is currently in any paste-burst related transient state.
///
/// This includes actively buffering, having a non-empty burst buffer, or holding the first
/// ASCII char for flicker suppression.
pub(crate) fn is_in_paste_burst(&self) -> bool {
self.paste_burst.is_active()
}
/// Returns a delay that reliably exceeds the paste-burst timing threshold.
///
/// Use this in tests to avoid boundary flakiness around the `PasteBurst` timeout.
pub(crate) fn recommended_paste_flush_delay() -> Duration {
PasteBurst::recommended_flush_delay()
}
@ -679,6 +783,20 @@ impl ChatComposer {
p
}
/// Handle non-ASCII character input (often IME) while still supporting paste-burst detection.
///
/// This handler exists because non-ASCII input often comes from IMEs, where characters can
/// legitimately arrive in short bursts that should **not** be treated as paste.
///
/// The key differences from the ASCII path:
///
/// - We never hold the first character (`PasteBurst::on_plain_char_no_hold`), because holding a
/// non-ASCII char can feel like dropped input.
/// - If a burst is detected, we may need to retroactively remove already-inserted text before
/// the cursor and move it into the paste buffer (see `PasteBurst::decide_begin_buffer`).
///
/// Because this path mixes "insert immediately" with "maybe retro-grab later", it must clamp
/// the cursor to a UTF-8 char boundary before slicing `textarea.text()`.
#[inline]
fn handle_non_ascii_char(&mut self, input: KeyEvent) -> (InputResult, bool) {
if let KeyEvent {
@ -705,12 +823,13 @@ impl ChatComposer {
return (InputResult::None, true);
}
CharDecision::BeginBuffer { retro_chars } => {
// For non-ASCII we inserted prior chars immediately, so if this turns out
// to be paste-like we need to retroactively grab & remove the already-
// inserted prefix from the textarea before buffering the burst.
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 decision is to buffer, seed the paste burst buffer with the grabbed chars + new.
// Otherwise, fall through to normal insertion below.
if let Some(grab) =
self.paste_burst
.decide_begin_buffer(now, before, retro_chars as usize)
@ -722,6 +841,8 @@ impl ChatComposer {
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.
}
_ => unreachable!("on_plain_char_no_hold returned unexpected variant"),
}
@ -1416,6 +1537,14 @@ impl ChatComposer {
}
}
/// Applies any due `PasteBurst` flush at time `now`.
///
/// Converts [`PasteBurst::flush_if_due`] results into concrete textarea mutations.
///
/// Callers:
///
/// - UI ticks via [`ChatComposer::flush_paste_burst_if_due`], so held first-chars can render.
/// - Input handling via [`ChatComposer::handle_input_basic`], so a due burst does not lag.
fn handle_paste_burst_flush(&mut self, now: Instant) -> bool {
match self.paste_burst.flush_if_due(now) {
FlushResult::Paste(pasted) => {
@ -1433,7 +1562,20 @@ impl ChatComposer {
}
}
/// Handle generic Input events that modify the textarea content.
/// Handles keys that mutate the textarea, including paste-burst detection.
///
/// Acts as the lowest-level keypath for keys that mutate the textarea. It is also where plain
/// character streams are converted into explicit paste operations on terminals that do not
/// reliably provide bracketed paste.
///
/// Ordering is important:
///
/// - Always flush any *due* paste burst first so buffered text does not lag behind unrelated
/// edits.
/// - Then handle the incoming key, intercepting only "plain" (no Ctrl/Alt) char input.
/// - For non-plain keys, flush via `flush_before_modified_input()` before applying the key;
/// otherwise `clear_window_after_non_char()` can leave buffered text waiting without a
/// timestamp to time out against.
fn handle_input_basic(&mut self, input: KeyEvent) -> (InputResult, bool) {
// 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.
@ -1453,6 +1595,10 @@ impl ChatComposer {
}
// Intercept plain Char inputs to optionally accumulate into a burst buffer.
//
// This is intentionally limited to "plain" (no Ctrl/Alt) chars so shortcuts keep their
// normal semantics, and so we can aggressively flush/clear any burst state when non-char
// keys are pressed.
if let KeyEvent {
code: KeyCode::Char(ch),
modifiers,

View file

@ -1,3 +1,150 @@
//! 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 its 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.
//!
//! For the higher-level view of how `PasteBurst` integrates with `ChatComposer`, see
//! `docs/tui-chat-composer.md`.
//!
//! # 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.
//!
//! # 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 `ChatComposer`
//!
//! `PasteBurst` does not mutate the UI text buffer on its own. The caller (`ChatComposer`) must
//! interpret decisions and apply the corresponding UI edits:
//!
//! - 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;
@ -130,15 +277,15 @@ impl PasteBurst {
self.last_plain_char_time = Some(now);
}
/// Flush the buffered burst if the inter-key timeout has elapsed.
/// Flushes any 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:
///
/// Returns None if the timeout has not elapsed or there is nothing to flush.
/// - [`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

View file

@ -730,8 +730,8 @@ impl TextArea {
/// Renames a single text element in-place, keeping it atomic.
///
/// This is intended for cases where the element payload is an identifier (e.g. a placeholder)
/// that must be updated without converting the element back into normal text.
/// Use this when the element payload is an identifier (e.g. a placeholder) that must be
/// updated without converting the element back into normal text.
pub fn replace_element_payload(&mut self, old: &str, new: &str) -> bool {
let Some(idx) = self
.elements

View file

@ -0,0 +1,15 @@
# TUI2 bottom pane (state machines)
When changing the paste-burst or chat-composer state machines in this folder, keep the docs in sync:
- Update the relevant module docs (`chat_composer.rs` and/or `paste_burst.rs`) so they remain a
readable, top-down explanation of the current behavior.
- Update the narrative doc `docs/tui-chat-composer.md` whenever behavior/assumptions change (Enter
handling, retro-capture, flush/clear rules, `disable_paste_burst`, non-ASCII/IME handling).
- Keep `codex-rs/tui` and `codex-rs/tui2` implementations/docstrings aligned unless the divergence
is intentional and documented.
Practical check:
- After edits, sanity-check that docs mention only APIs/behavior that exist in code (especially the
Enter/newline paths and `disable_paste_burst` semantics).

View file

@ -1,3 +1,61 @@
//! The chat composer is the bottom-pane text input state machine.
//!
//! It is responsible for:
//!
//! - Editing the input buffer (a `TextArea`), including placeholder "elements" for attachments.
//! - Routing keys to the active popup (slash commands, file search, skill mentions).
//! - Handling submit vs newline on Enter.
//! - Turning raw key streams into explicit paste operations on platforms where terminals
//! don't provide reliable bracketed paste (notably Windows).
//!
//! # Key Event Routing
//!
//! Most key handling goes through [`ChatComposer::handle_key_event`], which dispatches to a
//! popup-specific handler if a popup is visible and otherwise to
//! [`ChatComposer::handle_key_event_without_popup`]. After every handled key, we call
//! [`ChatComposer::sync_popups`] so UI state follows the latest buffer/cursor.
//!
//! # Non-bracketed Paste Bursts
//!
//! On some terminals (especially on Windows), pastes arrive as a rapid sequence of
//! `KeyCode::Char` and `KeyCode::Enter` key events instead of a single paste event.
//!
//! To avoid misinterpreting these bursts as real typing, we feed "plain" character events into
//! [`PasteBurst`](super::paste_burst::PasteBurst), which buffers bursts and later flushes them
//! through [`ChatComposer::handle_paste`].
//!
//! The burst detector intentionally treats ASCII and non-ASCII differently:
//!
//! - ASCII: we briefly hold the first fast char (flicker suppression) until we know whether the
//! stream is paste-like.
//! - non-ASCII: we do not hold the first char (IME input would feel dropped), but we still allow
//! burst detection for actual paste streams.
//!
//! The burst detector can also be disabled (`disable_paste_burst`), which bypasses the state
//! machine and treats the key stream as normal typing.
//!
//! For the detailed burst state machine, see `codex-rs/tui2/src/bottom_pane/paste_burst.rs`.
//! For a narrative overview of the combined state machine, see `docs/tui-chat-composer.md`.
//!
//! # PasteBurst Integration Points
//!
//! The burst detector is consulted in a few specific places:
//!
//! - [`ChatComposer::handle_input_basic`]: flushes any due burst first, then intercepts plain char
//! input to either buffer it or insert normally.
//! - [`ChatComposer::handle_non_ascii_char`]: handles the non-ASCII/IME path without holding the
//! first char, while still allowing paste detection via retro-capture.
//! - [`ChatComposer::flush_paste_burst_if_due`]/[`ChatComposer::handle_paste_burst_flush`]: called
//! from UI ticks to turn a pending burst into either an explicit paste (`handle_paste`) or a
//! normal typed character.
//!
//! # Input Disabled Mode
//!
//! The composer can be temporarily read-only (`input_enabled = false`). In that mode it ignores
//! edits and renders a placeholder prompt instead of the editable textarea. This is part of the
//! overall state machine, since it affects which transitions are even possible from a given UI
//! state.
use crate::key_hint;
use crate::key_hint::KeyBinding;
use crate::key_hint::has_ctrl_or_alt;
@ -125,7 +183,7 @@ pub(crate) struct ChatComposer {
/// When false, the composer is temporarily read-only (e.g. during sandbox setup).
input_enabled: bool,
input_disabled_placeholder: Option<String>,
// Non-bracketed paste burst tracker.
/// Non-bracketed paste burst tracker (see `bottom_pane/paste_burst.rs`).
paste_burst: PasteBurst,
// When true, disables paste-burst logic and inserts characters immediately.
disable_paste_burst: bool,
@ -270,6 +328,24 @@ impl ChatComposer {
true
}
/// Integrate pasted text into the composer.
///
/// Acts as the only place where paste text is integrated, both for:
///
/// - Real/explicit paste events surfaced by the terminal, and
/// - Non-bracketed "paste bursts" that [`PasteBurst`](super::paste_burst::PasteBurst) buffers
/// and later flushes here.
///
/// Behavior:
///
/// - If the paste is larger than `LARGE_PASTE_CHAR_THRESHOLD` chars, inserts a placeholder
/// element (expanded on submit) and stores the full text in `pending_pastes`.
/// - Otherwise, if the paste looks like an image path, attaches the image and inserts a
/// trailing space so the user can keep typing naturally.
/// - Otherwise, inserts the pasted text directly into the textarea.
///
/// In all cases, clears any paste-burst Enter suppression state so a real paste cannot affect
/// the next user Enter key, then syncs popup state.
pub fn handle_paste(&mut self, pasted: String) -> bool {
let char_count = pasted.chars().count();
if char_count > LARGE_PASTE_CHAR_THRESHOLD {
@ -310,6 +386,16 @@ impl ChatComposer {
}
}
/// Enable or disable paste-burst handling.
///
/// `disable_paste_burst` is an escape hatch for terminals/platforms where the burst heuristic
/// is unwanted or has already been handled elsewhere.
///
/// When enabling the flag we clear the burst classification window so subsequent input cannot
/// be incorrectly grouped into a previous burst.
///
/// This does not flush any in-progress buffer; callers should avoid toggling this mid-burst
/// (or should flush first).
pub(crate) fn set_disable_paste_burst(&mut self, disabled: bool) {
let was_disabled = self.disable_paste_burst;
self.disable_paste_burst = disabled;
@ -351,7 +437,7 @@ impl ChatComposer {
self.textarea.text().to_string()
}
/// Attempt to start a burst by retro-capturing recent chars before the cursor.
/// Insert an attachment placeholder and track it for the next submission.
pub fn attach_image(&mut self, path: PathBuf) {
let image_number = self.attached_images.len() + 1;
let placeholder = local_image_label_text(image_number);
@ -367,14 +453,31 @@ impl ChatComposer {
images.into_iter().map(|img| img.path).collect()
}
/// Flushes any due paste-burst state.
///
/// Call this from a UI tick to turn paste-burst transient state into explicit textarea edits:
///
/// - If a burst times out, flush it via `handle_paste(String)`.
/// - If only the first ASCII char was held (flicker suppression) and no burst followed, emit it
/// as normal typed input.
///
/// This also allows a single "held" ASCII char to render even when it turns out not to be part
/// of a paste burst.
pub(crate) fn flush_paste_burst_if_due(&mut self) -> bool {
self.handle_paste_burst_flush(Instant::now())
}
/// Returns whether the composer is currently in any paste-burst related transient state.
///
/// This includes actively buffering, having a non-empty burst buffer, or holding the first
/// ASCII char for flicker suppression.
pub(crate) fn is_in_paste_burst(&self) -> bool {
self.paste_burst.is_active()
}
/// Returns a delay that reliably exceeds the paste-burst timing threshold.
///
/// Use this in tests to avoid boundary flakiness around the `PasteBurst` timeout.
pub(crate) fn recommended_paste_flush_delay() -> Duration {
PasteBurst::recommended_flush_delay()
}
@ -613,6 +716,20 @@ impl ChatComposer {
p
}
/// Handle non-ASCII character input (often IME) while still supporting paste-burst detection.
///
/// This handler exists because non-ASCII input often comes from IMEs, where characters can
/// legitimately arrive in short bursts that should **not** be treated as paste.
///
/// The key differences from the ASCII path:
///
/// - We never hold the first character (`PasteBurst::on_plain_char_no_hold`), because holding a
/// non-ASCII char can feel like dropped input.
/// - If a burst is detected, we may need to retroactively remove already-inserted text before
/// the cursor and move it into the paste buffer (see `PasteBurst::decide_begin_buffer`).
///
/// Because this path mixes "insert immediately" with "maybe retro-grab later", it must clamp
/// the cursor to a UTF-8 char boundary before slicing `textarea.text()`.
#[inline]
fn handle_non_ascii_char(&mut self, input: KeyEvent) -> (InputResult, bool) {
if let KeyEvent {
@ -639,12 +756,13 @@ impl ChatComposer {
return (InputResult::None, true);
}
CharDecision::BeginBuffer { retro_chars } => {
// For non-ASCII we inserted prior chars immediately, so if this turns out
// to be paste-like we need to retroactively grab & remove the already-
// inserted prefix from the textarea before buffering the burst.
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 decision is to buffer, seed the paste burst buffer with the grabbed chars + new.
// Otherwise, fall through to normal insertion below.
if let Some(grab) =
self.paste_burst
.decide_begin_buffer(now, before, retro_chars as usize)
@ -656,6 +774,8 @@ impl ChatComposer {
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.
}
_ => unreachable!("on_plain_char_no_hold returned unexpected variant"),
}
@ -1359,6 +1479,14 @@ impl ChatComposer {
}
}
/// Applies any due `PasteBurst` flush at time `now`.
///
/// Converts [`PasteBurst::flush_if_due`] results into concrete textarea mutations.
///
/// Callers:
///
/// - UI ticks via [`ChatComposer::flush_paste_burst_if_due`], so held first-chars can render.
/// - Input handling via [`ChatComposer::handle_input_basic`], so a due burst does not lag.
fn handle_paste_burst_flush(&mut self, now: Instant) -> bool {
match self.paste_burst.flush_if_due(now) {
FlushResult::Paste(pasted) => {
@ -1376,7 +1504,20 @@ impl ChatComposer {
}
}
/// Handle generic Input events that modify the textarea content.
/// Handles keys that mutate the textarea, including paste-burst detection.
///
/// Acts as the lowest-level keypath for keys that mutate the textarea. It is also where plain
/// character streams are converted into explicit paste operations on terminals that do not
/// reliably provide bracketed paste.
///
/// Ordering is important:
///
/// - Always flush any *due* paste burst first so buffered text does not lag behind unrelated
/// edits.
/// - Then handle the incoming key, intercepting only "plain" (no Ctrl/Alt) char input.
/// - For non-plain keys, flush via `flush_before_modified_input()` before applying the key;
/// otherwise `clear_window_after_non_char()` can leave buffered text waiting without a
/// timestamp to time out against.
fn handle_input_basic(&mut self, input: KeyEvent) -> (InputResult, bool) {
// 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.
@ -1396,6 +1537,10 @@ impl ChatComposer {
}
// Intercept plain Char inputs to optionally accumulate into a burst buffer.
//
// This is intentionally limited to "plain" (no Ctrl/Alt) chars so shortcuts keep their
// normal semantics, and so we can aggressively flush/clear any burst state when non-char
// keys are pressed.
if let KeyEvent {
code: KeyCode::Char(ch),
modifiers,

View file

@ -1,3 +1,150 @@
//! 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 its 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.
//!
//! For the higher-level view of how `PasteBurst` integrates with `ChatComposer`, see
//! `docs/tui-chat-composer.md`.
//!
//! # 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.
//!
//! # 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 `ChatComposer`
//!
//! `PasteBurst` does not mutate the UI text buffer on its own. The caller (`ChatComposer`) must
//! interpret decisions and apply the corresponding UI edits:
//!
//! - 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;
@ -130,15 +277,15 @@ impl PasteBurst {
self.last_plain_char_time = Some(now);
}
/// Flush the buffered burst if the inter-key timeout has elapsed.
/// Flushes any 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:
///
/// Returns None if the timeout has not elapsed or there is nothing to flush.
/// - [`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

View file

@ -730,8 +730,8 @@ impl TextArea {
/// Renames a single text element in-place, keeping it atomic.
///
/// This is intended for cases where the element payload is an identifier (e.g. a placeholder)
/// that must be updated without converting the element back into normal text.
/// Use this when the element payload is an identifier (e.g. a placeholder) that must be
/// updated without converting the element back into normal text.
pub fn replace_element_payload(&mut self, old: &str, new: &str) -> bool {
let Some(idx) = self
.elements

205
docs/tui-chat-composer.md Normal file
View file

@ -0,0 +1,205 @@
# Chat Composer state machine (TUI)
This note documents the `ChatComposer` input state machine and the paste-related behavior added
for Windows terminals.
Primary implementations:
- `codex-rs/tui/src/bottom_pane/chat_composer.rs`
- `codex-rs/tui2/src/bottom_pane/chat_composer.rs`
Paste-burst detector:
- `codex-rs/tui/src/bottom_pane/paste_burst.rs`
- `codex-rs/tui2/src/bottom_pane/paste_burst.rs`
## What problem is being solved?
On some terminals (notably on Windows via `crossterm`), _bracketed paste_ is not reliably surfaced
as a single paste event. Instead, pasting multi-line content can show up as a rapid sequence of
key events:
- `KeyCode::Char(..)` for text
- `KeyCode::Enter` for newlines
If the composer treats those events as “normal typing”, it can:
- accidentally trigger UI toggles (e.g. `?`) while the paste is still streaming,
- submit the message mid-paste when an `Enter` arrives,
- render a typed prefix, then “reclassify” it as paste once enough chars arrive (flicker).
The solution is to detect paste-like _bursts_ and buffer them into a single explicit
`handle_paste(String)` call.
## High-level state machines
`ChatComposer` effectively combines two small state machines:
1. **UI mode**: which popup (if any) is active.
- `ActivePopup::None | Command | File | Skill`
2. **Paste burst**: transient detection state for non-bracketed paste.
- implemented by `PasteBurst`
### Key event routing
`ChatComposer::handle_key_event` dispatches based on `active_popup`:
- If a popup is visible, a popup-specific handler processes the key first (navigation, selection,
completion).
- Otherwise, `handle_key_event_without_popup` handles higher-level semantics (Enter submit,
history navigation, etc).
- After handling the key, `sync_popups()` runs so popup visibility/filters stay consistent with the
latest text + cursor.
## Paste burst: concepts and assumptions
The burst detector is intentionally conservative: it only processes “plain” character input
(no Ctrl/Alt modifiers). Everything else flushes and/or clears the burst window so shortcuts keep
their normal meaning.
### Conceptual `PasteBurst` states
- **Idle**: no buffer, no pending char.
- **Pending first char** (ASCII only): hold one fast character very briefly to avoid rendering it
and then immediately removing it if the stream turns out to be a paste.
- **Active buffer**: once a burst is classified as paste-like, accumulate the content into a
`String` buffer.
- **Enter suppression window**: keep treating `Enter` as “newline” briefly after burst activity so
multiline pastes remain grouped even if there are tiny gaps.
### ASCII vs non-ASCII (IME) input
Non-ASCII characters frequently come from IMEs and can legitimately arrive in quick bursts. Holding
the first character in that case can feel like dropped input.
The composer therefore distinguishes:
- **ASCII path**: allow holding the first fast char (`PasteBurst::on_plain_char`).
- **non-ASCII path**: never hold the first char (`PasteBurst::on_plain_char_no_hold`), but still
allow burst detection. When a burst is detected on this path, the already-inserted prefix may be
retroactively removed from the textarea and moved into the paste buffer.
To avoid misclassifying IME bursts as paste, the non-ASCII retro-capture path runs an additional
heuristic (`PasteBurst::decide_begin_buffer`) to determine whether the retro-grabbed prefix “looks
pastey” (e.g. contains whitespace or is long).
### Disabling burst detection
`ChatComposer` supports `disable_paste_burst` as an escape hatch.
When enabled:
- The burst detector is bypassed for new input (no flicker suppression hold and no burst buffering
decisions for incoming characters).
- The key stream is treated as normal typing (including normal slash command behavior).
- Enabling the flag clears the burst classification window. In the current implementation it does
**not** flush or clear an already-buffered burst, so callers should avoid toggling this flag
mid-burst (or should flush first).
### Enter handling
When paste-burst buffering is active, Enter is treated as “append `\n` to the burst” rather than
“submit the message”. This prevents mid-paste submission for multiline pastes that are emitted as
`Enter` key events.
The composer also disables burst-based Enter suppression inside slash-command context (popup open
or the first line begins with `/`) so command dispatch is predictable.
## PasteBurst: event-level behavior (cheat sheet)
This section spells out how `ChatComposer` interprets the `PasteBurst` decisions. Its intended to
make the state transitions reviewable without having to “run the code in your head”.
### Plain ASCII `KeyCode::Char(c)` (no Ctrl/Alt modifiers)
`ChatComposer::handle_input_basic` calls `PasteBurst::on_plain_char(c, now)` and switches on the
returned `CharDecision`:
- `RetainFirstChar`: do **not** insert `c` into the textarea yet. A UI tick later may flush it as a
normal typed char via `PasteBurst::flush_if_due`.
- `BeginBufferFromPending`: the first ASCII char is already held/buffered; append `c` via
`PasteBurst::append_char_to_buffer`.
- `BeginBuffer { retro_chars }`: attempt a retro-capture of the already-inserted prefix:
- call `PasteBurst::decide_begin_buffer(now, before_cursor, retro_chars)`;
- if it returns `Some(grab)`, delete `grab.start_byte..cursor` from the textarea and then append
`c` to the buffer;
- if it returns `None`, fall back to normal insertion.
- `BufferAppend`: append `c` to the active buffer.
### Plain non-ASCII `KeyCode::Char(c)` (no Ctrl/Alt modifiers)
`ChatComposer::handle_non_ascii_char` uses a slightly different flow:
- It first flushes any pending transient ASCII state with `PasteBurst::flush_before_modified_input`
(which includes a single held ASCII char).
- If a burst is already active, `PasteBurst::try_append_char_if_active(c, now)` appends `c` directly.
- Otherwise it calls `PasteBurst::on_plain_char_no_hold(now)`:
- `BufferAppend`: append `c` to the active buffer.
- `BeginBuffer { retro_chars }`: run `decide_begin_buffer(..)` and, if it starts buffering, delete
the retro-grabbed prefix from the textarea and append `c`.
- `None`: insert `c` into the textarea normally.
The extra `decide_begin_buffer` heuristic on this path is intentional: IME input can arrive as
quick bursts, so the code only retro-grabs if the prefix “looks pastey” (whitespace, or a long
enough run) to avoid misclassifying IME composition as paste.
### `KeyCode::Enter`: newline vs submit
There are two distinct “Enter becomes newline” mechanisms:
- **While in a burst context** (`paste_burst.is_active()`): `append_newline_if_active(now)` appends
`\n` into the burst buffer so multi-line pastes stay buffered as one explicit paste.
- **Immediately after burst activity** (enter suppression window):
`newline_should_insert_instead_of_submit(now)` inserts `\n` into the textarea and calls
`extend_window(now)` so a slightly-late Enter keeps behaving like “newline” rather than “submit”.
Both are disabled inside slash-command context (command popup is active or the first line begins
with `/`) so Enter keeps its normal “submit/execute” semantics while composing commands.
### Non-char keys / Ctrl+modified input
Non-char input must not leak burst state across unrelated actions:
- If there is buffered burst text, callers should flush it before calling
`clear_window_after_non_char` (see “Pitfalls worth calling out”), typically via
`PasteBurst::flush_before_modified_input`.
- `PasteBurst::clear_window_after_non_char` clears the “recent burst” window so the next keystroke
doesnt get incorrectly grouped into a previous paste.
### Pitfalls worth calling out
- `PasteBurst::clear_window_after_non_char` clears `last_plain_char_time`. If you call it while
`buffer` is non-empty and _havent already flushed_, `flush_if_due()` no longer has a timestamp
to time out against, so the buffered text may never flush. Treat `clear_window_after_non_char` as
“drop classification context after flush”, not “flush”.
- `PasteBurst::flush_if_due` uses a strict `>` comparison, so tests and UI ticks should cross the
threshold by at least 1ms (see `PasteBurst::recommended_flush_delay`).
## Notable interactions / invariants
- The composer frequently slices `textarea.text()` using the cursor position; all code that
slices must clamp the cursor to a UTF-8 char boundary first.
- `sync_popups()` must run after any change that can affect popup visibility or filtering:
inserting, deleting, flushing a burst, applying a paste placeholder, etc.
- Shortcut overlay toggling via `?` is gated on `!is_in_paste_burst()` so pastes cannot flip UI
modes while streaming.
## Tests that pin behavior
The `PasteBurst` logic is currently exercised through `ChatComposer` integration tests.
- `codex-rs/tui/src/bottom_pane/chat_composer.rs`
- `non_ascii_burst_handles_newline`
- `ascii_burst_treats_enter_as_newline`
- `question_mark_does_not_toggle_during_paste_burst`
- `burst_paste_fast_small_buffers_and_flushes_on_stop`
- `burst_paste_fast_large_inserts_placeholder_on_flush`
- `codex-rs/tui2/src/bottom_pane/chat_composer.rs`
- `non_ascii_burst_handles_newline`
- `ascii_burst_treats_enter_as_newline`
- `question_mark_does_not_toggle_during_paste_burst`
- `burst_paste_fast_small_buffers_and_flushes_on_stop`
- `burst_paste_fast_large_inserts_placeholder_on_flush`
This document calls out some additional contracts (like “flush before clearing”) that are not yet
fully pinned by dedicated `PasteBurst` unit tests.