From 503a4f0f32229a01dba15eadcc7af6acb986b812 Mon Sep 17 00:00:00 2001
From: Josh McKinney <joshka@openai.com>
Date: Mon, 12 Jan 2026 13:08:53 -0800
Subject: [PATCH] docs(tui): document paste-burst state machine

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.
---
 .markdownlint-cli2.yaml                       |   6 +
 codex-rs/tui/src/bottom_pane/AGENTS.md        |  15 ++
 codex-rs/tui/src/bottom_pane/chat_composer.rs | 156 ++++++++++++-
 codex-rs/tui/src/bottom_pane/paste_burst.rs   | 161 +++++++++++++-
 codex-rs/tui/src/bottom_pane/textarea.rs      |   4 +-
 codex-rs/tui2/src/bottom_pane/AGENTS.md       |  15 ++
 .../tui2/src/bottom_pane/chat_composer.rs     | 155 ++++++++++++-
 codex-rs/tui2/src/bottom_pane/paste_burst.rs  | 161 +++++++++++++-
 codex-rs/tui2/src/bottom_pane/textarea.rs     |   4 +-
 docs/tui-chat-composer.md                     | 205 ++++++++++++++++++
 10 files changed, 854 insertions(+), 28 deletions(-)
 create mode 100644 .markdownlint-cli2.yaml
 create mode 100644 codex-rs/tui/src/bottom_pane/AGENTS.md
 create mode 100644 codex-rs/tui2/src/bottom_pane/AGENTS.md
 create mode 100644 docs/tui-chat-composer.md

diff --git a/.markdownlint-cli2.yaml b/.markdownlint-cli2.yaml
new file mode 100644
index 000000000000..15b472c61cb7
--- /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 000000000000..32d2037c13ba
--- /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 bb50ac0af956..6b086c591456 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<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,
@@ -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 96ed095b8f30..ae0234f1a73e 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 f2ed40758dab..903ebe9f82b5 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 000000000000..44fac8e50af8
--- /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 118dc8cd3da4..91c311ce600a 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<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,
@@ -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 96ed095b8f30..ae0234f1a73e 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 f2ed40758dab..903ebe9f82b5 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 000000000000..cc3beb17fe1f
--- /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.
