diff --git a/.markdownlint-cli2.yaml b/.markdownlint-cli2.yaml new file mode 100644 index 00000000000..15b472c61cb --- /dev/null +++ b/.markdownlint-cli2.yaml @@ -0,0 +1,6 @@ +config: + MD013: + line_length: 100 + +globs: + - "docs/tui-chat-composer.md" diff --git a/codex-rs/tui/src/bottom_pane/AGENTS.md b/codex-rs/tui/src/bottom_pane/AGENTS.md new file mode 100644 index 00000000000..32d2037c13b --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/AGENTS.md @@ -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). diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index bb50ac0af95..6b086c59145 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -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; @@ -121,7 +180,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, - // 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, @@ -250,6 +309,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 { @@ -290,6 +367,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; @@ -410,7 +497,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); @@ -426,14 +513,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() } @@ -672,6 +776,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 { @@ -698,12 +816,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) @@ -715,6 +834,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"), } @@ -1353,6 +1474,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) => { @@ -1370,7 +1499,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. @@ -1390,6 +1532,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, diff --git a/codex-rs/tui/src/bottom_pane/paste_burst.rs b/codex-rs/tui/src/bottom_pane/paste_burst.rs index 96ed095b8f3..ae0234f1a73 100644 --- a/codex-rs/tui/src/bottom_pane/paste_burst.rs +++ b/codex-rs/tui/src/bottom_pane/paste_burst.rs @@ -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 diff --git a/codex-rs/tui/src/bottom_pane/textarea.rs b/codex-rs/tui/src/bottom_pane/textarea.rs index f2ed40758da..903ebe9f82b 100644 --- a/codex-rs/tui/src/bottom_pane/textarea.rs +++ b/codex-rs/tui/src/bottom_pane/textarea.rs @@ -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 diff --git a/codex-rs/tui2/src/bottom_pane/AGENTS.md b/codex-rs/tui2/src/bottom_pane/AGENTS.md new file mode 100644 index 00000000000..44fac8e50af --- /dev/null +++ b/codex-rs/tui2/src/bottom_pane/AGENTS.md @@ -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). diff --git a/codex-rs/tui2/src/bottom_pane/chat_composer.rs b/codex-rs/tui2/src/bottom_pane/chat_composer.rs index 118dc8cd3da..91c311ce600 100644 --- a/codex-rs/tui2/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui2/src/bottom_pane/chat_composer.rs @@ -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; @@ -124,7 +182,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, - // 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, @@ -263,6 +321,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 { @@ -303,6 +379,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; @@ -344,7 +430,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); @@ -360,14 +446,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() } @@ -606,6 +709,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 { @@ -632,12 +749,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) @@ -649,6 +767,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"), } @@ -1287,6 +1407,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) => { @@ -1304,7 +1432,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. @@ -1324,6 +1465,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, diff --git a/codex-rs/tui2/src/bottom_pane/paste_burst.rs b/codex-rs/tui2/src/bottom_pane/paste_burst.rs index 96ed095b8f3..ae0234f1a73 100644 --- a/codex-rs/tui2/src/bottom_pane/paste_burst.rs +++ b/codex-rs/tui2/src/bottom_pane/paste_burst.rs @@ -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 diff --git a/codex-rs/tui2/src/bottom_pane/textarea.rs b/codex-rs/tui2/src/bottom_pane/textarea.rs index f2ed40758da..903ebe9f82b 100644 --- a/codex-rs/tui2/src/bottom_pane/textarea.rs +++ b/codex-rs/tui2/src/bottom_pane/textarea.rs @@ -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 diff --git a/docs/tui-chat-composer.md b/docs/tui-chat-composer.md new file mode 100644 index 00000000000..cc3beb17fe1 --- /dev/null +++ b/docs/tui-chat-composer.md @@ -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. It’s 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 + doesn’t 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 _haven’t 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.