fix(tui): document paste-burst state machine (#9020)
Add a narrative doc and inline rustdoc explaining how `ChatComposer` and `PasteBurst` compose into a single state machine on terminals that lack reliable bracketed paste (notably Windows). This documents the key states, invariants, and integration points (`handle_input_basic`, `handle_non_ascii_char`, tick-driven flush) so future changes are easier to reason about.
This commit is contained in:
parent
2651980bdf
commit
58e8f75b27
10 changed files with 854 additions and 28 deletions
6
.markdownlint-cli2.yaml
Normal file
6
.markdownlint-cli2.yaml
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
config:
|
||||
MD013:
|
||||
line_length: 100
|
||||
|
||||
globs:
|
||||
- "docs/tui-chat-composer.md"
|
||||
15
codex-rs/tui/src/bottom_pane/AGENTS.md
Normal file
15
codex-rs/tui/src/bottom_pane/AGENTS.md
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# TUI bottom pane (state machines)
|
||||
|
||||
When changing the paste-burst or chat-composer state machines in this folder, keep the docs in sync:
|
||||
|
||||
- Update the relevant module docs (`chat_composer.rs` and/or `paste_burst.rs`) so they remain a
|
||||
readable, top-down explanation of the current behavior.
|
||||
- Update the narrative doc `docs/tui-chat-composer.md` whenever behavior/assumptions change (Enter
|
||||
handling, retro-capture, flush/clear rules, `disable_paste_burst`, non-ASCII/IME handling).
|
||||
- Keep `codex-rs/tui` and `codex-rs/tui2` implementations/docstrings aligned unless the divergence
|
||||
is intentional and documented.
|
||||
|
||||
Practical check:
|
||||
|
||||
- After edits, sanity-check that docs mention only APIs/behavior that exist in code (especially the
|
||||
Enter/newline paths and `disable_paste_burst` semantics).
|
||||
|
|
@ -1,3 +1,62 @@
|
|||
//! The chat composer is the bottom-pane text input state machine.
|
||||
//!
|
||||
//! It is responsible for:
|
||||
//!
|
||||
//! - Editing the input buffer (a [`TextArea`]), including placeholder "elements" for attachments.
|
||||
//! - Routing keys to the active popup (slash commands, file search, skill mentions).
|
||||
//! - Handling submit vs newline on Enter.
|
||||
//! - Turning raw key streams into explicit paste operations on platforms where terminals
|
||||
//! don't provide reliable bracketed paste (notably Windows).
|
||||
//!
|
||||
//! # Key Event Routing
|
||||
//!
|
||||
//! Most key handling goes through [`ChatComposer::handle_key_event`], which dispatches to a
|
||||
//! popup-specific handler if a popup is visible and otherwise to
|
||||
//! [`ChatComposer::handle_key_event_without_popup`]. After every handled key, we call
|
||||
//! [`ChatComposer::sync_popups`] so UI state follows the latest buffer/cursor.
|
||||
//!
|
||||
//! # Non-bracketed Paste Bursts
|
||||
//!
|
||||
//! On some terminals (especially on Windows), pastes arrive as a rapid sequence of
|
||||
//! `KeyCode::Char` and `KeyCode::Enter` key events instead of a single paste event.
|
||||
//!
|
||||
//! To avoid misinterpreting these bursts as real typing (and to prevent transient UI effects like
|
||||
//! shortcut overlays toggling on a pasted `?`), we feed "plain" character events into
|
||||
//! [`PasteBurst`](super::paste_burst::PasteBurst), which buffers bursts and later flushes them
|
||||
//! through [`ChatComposer::handle_paste`].
|
||||
//!
|
||||
//! The burst detector intentionally treats ASCII and non-ASCII differently:
|
||||
//!
|
||||
//! - ASCII: we briefly hold the first fast char (flicker suppression) until we know whether the
|
||||
//! stream is paste-like.
|
||||
//! - non-ASCII: we do not hold the first char (IME input would feel dropped), but we still allow
|
||||
//! burst detection for actual paste streams.
|
||||
//!
|
||||
//! The burst detector can also be disabled (`disable_paste_burst`), which bypasses the state
|
||||
//! machine and treats the key stream as normal typing.
|
||||
//!
|
||||
//! For the detailed burst state machine, see `codex-rs/tui/src/bottom_pane/paste_burst.rs`.
|
||||
//! For a narrative overview of the combined state machine, see `docs/tui-chat-composer.md`.
|
||||
//!
|
||||
//! # PasteBurst Integration Points
|
||||
//!
|
||||
//! The burst detector is consulted in a few specific places:
|
||||
//!
|
||||
//! - [`ChatComposer::handle_input_basic`]: flushes any due burst first, then intercepts plain char
|
||||
//! input to either buffer it or insert normally.
|
||||
//! - [`ChatComposer::handle_non_ascii_char`]: handles the non-ASCII/IME path without holding the
|
||||
//! first char, while still allowing paste detection via retro-capture.
|
||||
//! - [`ChatComposer::flush_paste_burst_if_due`]/[`ChatComposer::handle_paste_burst_flush`]: called
|
||||
//! from UI ticks to turn a pending burst into either an explicit paste (`handle_paste`) or a
|
||||
//! normal typed character.
|
||||
//!
|
||||
//! # Input Disabled Mode
|
||||
//!
|
||||
//! The composer can be temporarily read-only (`input_enabled = false`). In that mode it ignores
|
||||
//! edits and renders a placeholder prompt instead of the editable textarea. This is part of the
|
||||
//! overall state machine, since it affects which transitions are even possible from a given UI
|
||||
//! state.
|
||||
|
||||
use crate::key_hint::has_ctrl_or_alt;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
|
|
@ -122,7 +181,7 @@ pub(crate) struct ChatComposer {
|
|||
/// When false, the composer is temporarily read-only (e.g. during sandbox setup).
|
||||
input_enabled: bool,
|
||||
input_disabled_placeholder: Option<String>,
|
||||
// Non-bracketed paste burst tracker.
|
||||
/// Non-bracketed paste burst tracker (see `bottom_pane/paste_burst.rs`).
|
||||
paste_burst: PasteBurst,
|
||||
// When true, disables paste-burst logic and inserts characters immediately.
|
||||
disable_paste_burst: bool,
|
||||
|
|
@ -257,6 +316,24 @@ impl ChatComposer {
|
|||
true
|
||||
}
|
||||
|
||||
/// Integrate pasted text into the composer.
|
||||
///
|
||||
/// Acts as the only place where paste text is integrated, both for:
|
||||
///
|
||||
/// - Real/explicit paste events surfaced by the terminal, and
|
||||
/// - Non-bracketed "paste bursts" that [`PasteBurst`](super::paste_burst::PasteBurst) buffers
|
||||
/// and later flushes here.
|
||||
///
|
||||
/// Behavior:
|
||||
///
|
||||
/// - If the paste is larger than `LARGE_PASTE_CHAR_THRESHOLD` chars, inserts a placeholder
|
||||
/// element (expanded on submit) and stores the full text in `pending_pastes`.
|
||||
/// - Otherwise, if the paste looks like an image path, attaches the image and inserts a
|
||||
/// trailing space so the user can keep typing naturally.
|
||||
/// - Otherwise, inserts the pasted text directly into the textarea.
|
||||
///
|
||||
/// In all cases, clears any paste-burst Enter suppression state so a real paste cannot affect
|
||||
/// the next user Enter key, then syncs popup state.
|
||||
pub fn handle_paste(&mut self, pasted: String) -> bool {
|
||||
let char_count = pasted.chars().count();
|
||||
if char_count > LARGE_PASTE_CHAR_THRESHOLD {
|
||||
|
|
@ -297,6 +374,16 @@ impl ChatComposer {
|
|||
}
|
||||
}
|
||||
|
||||
/// Enable or disable paste-burst handling.
|
||||
///
|
||||
/// `disable_paste_burst` is an escape hatch for terminals/platforms where the burst heuristic
|
||||
/// is unwanted or has already been handled elsewhere.
|
||||
///
|
||||
/// When enabling the flag we clear the burst classification window so subsequent input cannot
|
||||
/// be incorrectly grouped into a previous burst.
|
||||
///
|
||||
/// This does not flush any in-progress buffer; callers should avoid toggling this mid-burst
|
||||
/// (or should flush first).
|
||||
pub(crate) fn set_disable_paste_burst(&mut self, disabled: bool) {
|
||||
let was_disabled = self.disable_paste_burst;
|
||||
self.disable_paste_burst = disabled;
|
||||
|
|
@ -417,7 +504,7 @@ impl ChatComposer {
|
|||
self.textarea.text().to_string()
|
||||
}
|
||||
|
||||
/// Attempt to start a burst by retro-capturing recent chars before the cursor.
|
||||
/// Insert an attachment placeholder and track it for the next submission.
|
||||
pub fn attach_image(&mut self, path: PathBuf) {
|
||||
let image_number = self.attached_images.len() + 1;
|
||||
let placeholder = local_image_label_text(image_number);
|
||||
|
|
@ -433,14 +520,31 @@ impl ChatComposer {
|
|||
images.into_iter().map(|img| img.path).collect()
|
||||
}
|
||||
|
||||
/// Flushes any due paste-burst state.
|
||||
///
|
||||
/// Call this from a UI tick to turn paste-burst transient state into explicit textarea edits:
|
||||
///
|
||||
/// - If a burst times out, flush it via `handle_paste(String)`.
|
||||
/// - If only the first ASCII char was held (flicker suppression) and no burst followed, emit it
|
||||
/// as normal typed input.
|
||||
///
|
||||
/// This also allows a single "held" ASCII char to render even when it turns out not to be part
|
||||
/// of a paste burst.
|
||||
pub(crate) fn flush_paste_burst_if_due(&mut self) -> bool {
|
||||
self.handle_paste_burst_flush(Instant::now())
|
||||
}
|
||||
|
||||
/// Returns whether the composer is currently in any paste-burst related transient state.
|
||||
///
|
||||
/// This includes actively buffering, having a non-empty burst buffer, or holding the first
|
||||
/// ASCII char for flicker suppression.
|
||||
pub(crate) fn is_in_paste_burst(&self) -> bool {
|
||||
self.paste_burst.is_active()
|
||||
}
|
||||
|
||||
/// Returns a delay that reliably exceeds the paste-burst timing threshold.
|
||||
///
|
||||
/// Use this in tests to avoid boundary flakiness around the `PasteBurst` timeout.
|
||||
pub(crate) fn recommended_paste_flush_delay() -> Duration {
|
||||
PasteBurst::recommended_flush_delay()
|
||||
}
|
||||
|
|
@ -679,6 +783,20 @@ impl ChatComposer {
|
|||
p
|
||||
}
|
||||
|
||||
/// Handle non-ASCII character input (often IME) while still supporting paste-burst detection.
|
||||
///
|
||||
/// This handler exists because non-ASCII input often comes from IMEs, where characters can
|
||||
/// legitimately arrive in short bursts that should **not** be treated as paste.
|
||||
///
|
||||
/// The key differences from the ASCII path:
|
||||
///
|
||||
/// - We never hold the first character (`PasteBurst::on_plain_char_no_hold`), because holding a
|
||||
/// non-ASCII char can feel like dropped input.
|
||||
/// - If a burst is detected, we may need to retroactively remove already-inserted text before
|
||||
/// the cursor and move it into the paste buffer (see `PasteBurst::decide_begin_buffer`).
|
||||
///
|
||||
/// Because this path mixes "insert immediately" with "maybe retro-grab later", it must clamp
|
||||
/// the cursor to a UTF-8 char boundary before slicing `textarea.text()`.
|
||||
#[inline]
|
||||
fn handle_non_ascii_char(&mut self, input: KeyEvent) -> (InputResult, bool) {
|
||||
if let KeyEvent {
|
||||
|
|
@ -705,12 +823,13 @@ impl ChatComposer {
|
|||
return (InputResult::None, true);
|
||||
}
|
||||
CharDecision::BeginBuffer { retro_chars } => {
|
||||
// For non-ASCII we inserted prior chars immediately, so if this turns out
|
||||
// to be paste-like we need to retroactively grab & remove the already-
|
||||
// inserted prefix from the textarea before buffering the burst.
|
||||
let cur = self.textarea.cursor();
|
||||
let txt = self.textarea.text();
|
||||
let safe_cur = Self::clamp_to_char_boundary(txt, cur);
|
||||
let before = &txt[..safe_cur];
|
||||
// If decision is to buffer, seed the paste burst buffer with the grabbed chars + new.
|
||||
// Otherwise, fall through to normal insertion below.
|
||||
if let Some(grab) =
|
||||
self.paste_burst
|
||||
.decide_begin_buffer(now, before, retro_chars as usize)
|
||||
|
|
@ -722,6 +841,8 @@ impl ChatComposer {
|
|||
self.paste_burst.append_char_to_buffer(ch, now);
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
// If decide_begin_buffer opted not to start buffering,
|
||||
// fall through to normal insertion below.
|
||||
}
|
||||
_ => unreachable!("on_plain_char_no_hold returned unexpected variant"),
|
||||
}
|
||||
|
|
@ -1416,6 +1537,14 @@ impl ChatComposer {
|
|||
}
|
||||
}
|
||||
|
||||
/// Applies any due `PasteBurst` flush at time `now`.
|
||||
///
|
||||
/// Converts [`PasteBurst::flush_if_due`] results into concrete textarea mutations.
|
||||
///
|
||||
/// Callers:
|
||||
///
|
||||
/// - UI ticks via [`ChatComposer::flush_paste_burst_if_due`], so held first-chars can render.
|
||||
/// - Input handling via [`ChatComposer::handle_input_basic`], so a due burst does not lag.
|
||||
fn handle_paste_burst_flush(&mut self, now: Instant) -> bool {
|
||||
match self.paste_burst.flush_if_due(now) {
|
||||
FlushResult::Paste(pasted) => {
|
||||
|
|
@ -1433,7 +1562,20 @@ impl ChatComposer {
|
|||
}
|
||||
}
|
||||
|
||||
/// Handle generic Input events that modify the textarea content.
|
||||
/// Handles keys that mutate the textarea, including paste-burst detection.
|
||||
///
|
||||
/// Acts as the lowest-level keypath for keys that mutate the textarea. It is also where plain
|
||||
/// character streams are converted into explicit paste operations on terminals that do not
|
||||
/// reliably provide bracketed paste.
|
||||
///
|
||||
/// Ordering is important:
|
||||
///
|
||||
/// - Always flush any *due* paste burst first so buffered text does not lag behind unrelated
|
||||
/// edits.
|
||||
/// - Then handle the incoming key, intercepting only "plain" (no Ctrl/Alt) char input.
|
||||
/// - For non-plain keys, flush via `flush_before_modified_input()` before applying the key;
|
||||
/// otherwise `clear_window_after_non_char()` can leave buffered text waiting without a
|
||||
/// timestamp to time out against.
|
||||
fn handle_input_basic(&mut self, input: KeyEvent) -> (InputResult, bool) {
|
||||
// If we have a buffered non-bracketed paste burst and enough time has
|
||||
// elapsed since the last char, flush it before handling a new input.
|
||||
|
|
@ -1453,6 +1595,10 @@ impl ChatComposer {
|
|||
}
|
||||
|
||||
// Intercept plain Char inputs to optionally accumulate into a burst buffer.
|
||||
//
|
||||
// This is intentionally limited to "plain" (no Ctrl/Alt) chars so shortcuts keep their
|
||||
// normal semantics, and so we can aggressively flush/clear any burst state when non-char
|
||||
// keys are pressed.
|
||||
if let KeyEvent {
|
||||
code: KeyCode::Char(ch),
|
||||
modifiers,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
15
codex-rs/tui2/src/bottom_pane/AGENTS.md
Normal file
15
codex-rs/tui2/src/bottom_pane/AGENTS.md
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# TUI2 bottom pane (state machines)
|
||||
|
||||
When changing the paste-burst or chat-composer state machines in this folder, keep the docs in sync:
|
||||
|
||||
- Update the relevant module docs (`chat_composer.rs` and/or `paste_burst.rs`) so they remain a
|
||||
readable, top-down explanation of the current behavior.
|
||||
- Update the narrative doc `docs/tui-chat-composer.md` whenever behavior/assumptions change (Enter
|
||||
handling, retro-capture, flush/clear rules, `disable_paste_burst`, non-ASCII/IME handling).
|
||||
- Keep `codex-rs/tui` and `codex-rs/tui2` implementations/docstrings aligned unless the divergence
|
||||
is intentional and documented.
|
||||
|
||||
Practical check:
|
||||
|
||||
- After edits, sanity-check that docs mention only APIs/behavior that exist in code (especially the
|
||||
Enter/newline paths and `disable_paste_burst` semantics).
|
||||
|
|
@ -1,3 +1,61 @@
|
|||
//! The chat composer is the bottom-pane text input state machine.
|
||||
//!
|
||||
//! It is responsible for:
|
||||
//!
|
||||
//! - Editing the input buffer (a `TextArea`), including placeholder "elements" for attachments.
|
||||
//! - Routing keys to the active popup (slash commands, file search, skill mentions).
|
||||
//! - Handling submit vs newline on Enter.
|
||||
//! - Turning raw key streams into explicit paste operations on platforms where terminals
|
||||
//! don't provide reliable bracketed paste (notably Windows).
|
||||
//!
|
||||
//! # Key Event Routing
|
||||
//!
|
||||
//! Most key handling goes through [`ChatComposer::handle_key_event`], which dispatches to a
|
||||
//! popup-specific handler if a popup is visible and otherwise to
|
||||
//! [`ChatComposer::handle_key_event_without_popup`]. After every handled key, we call
|
||||
//! [`ChatComposer::sync_popups`] so UI state follows the latest buffer/cursor.
|
||||
//!
|
||||
//! # Non-bracketed Paste Bursts
|
||||
//!
|
||||
//! On some terminals (especially on Windows), pastes arrive as a rapid sequence of
|
||||
//! `KeyCode::Char` and `KeyCode::Enter` key events instead of a single paste event.
|
||||
//!
|
||||
//! To avoid misinterpreting these bursts as real typing, we feed "plain" character events into
|
||||
//! [`PasteBurst`](super::paste_burst::PasteBurst), which buffers bursts and later flushes them
|
||||
//! through [`ChatComposer::handle_paste`].
|
||||
//!
|
||||
//! The burst detector intentionally treats ASCII and non-ASCII differently:
|
||||
//!
|
||||
//! - ASCII: we briefly hold the first fast char (flicker suppression) until we know whether the
|
||||
//! stream is paste-like.
|
||||
//! - non-ASCII: we do not hold the first char (IME input would feel dropped), but we still allow
|
||||
//! burst detection for actual paste streams.
|
||||
//!
|
||||
//! The burst detector can also be disabled (`disable_paste_burst`), which bypasses the state
|
||||
//! machine and treats the key stream as normal typing.
|
||||
//!
|
||||
//! For the detailed burst state machine, see `codex-rs/tui2/src/bottom_pane/paste_burst.rs`.
|
||||
//! For a narrative overview of the combined state machine, see `docs/tui-chat-composer.md`.
|
||||
//!
|
||||
//! # PasteBurst Integration Points
|
||||
//!
|
||||
//! The burst detector is consulted in a few specific places:
|
||||
//!
|
||||
//! - [`ChatComposer::handle_input_basic`]: flushes any due burst first, then intercepts plain char
|
||||
//! input to either buffer it or insert normally.
|
||||
//! - [`ChatComposer::handle_non_ascii_char`]: handles the non-ASCII/IME path without holding the
|
||||
//! first char, while still allowing paste detection via retro-capture.
|
||||
//! - [`ChatComposer::flush_paste_burst_if_due`]/[`ChatComposer::handle_paste_burst_flush`]: called
|
||||
//! from UI ticks to turn a pending burst into either an explicit paste (`handle_paste`) or a
|
||||
//! normal typed character.
|
||||
//!
|
||||
//! # Input Disabled Mode
|
||||
//!
|
||||
//! The composer can be temporarily read-only (`input_enabled = false`). In that mode it ignores
|
||||
//! edits and renders a placeholder prompt instead of the editable textarea. This is part of the
|
||||
//! overall state machine, since it affects which transitions are even possible from a given UI
|
||||
//! state.
|
||||
|
||||
use crate::key_hint;
|
||||
use crate::key_hint::KeyBinding;
|
||||
use crate::key_hint::has_ctrl_or_alt;
|
||||
|
|
@ -125,7 +183,7 @@ pub(crate) struct ChatComposer {
|
|||
/// When false, the composer is temporarily read-only (e.g. during sandbox setup).
|
||||
input_enabled: bool,
|
||||
input_disabled_placeholder: Option<String>,
|
||||
// Non-bracketed paste burst tracker.
|
||||
/// Non-bracketed paste burst tracker (see `bottom_pane/paste_burst.rs`).
|
||||
paste_burst: PasteBurst,
|
||||
// When true, disables paste-burst logic and inserts characters immediately.
|
||||
disable_paste_burst: bool,
|
||||
|
|
@ -270,6 +328,24 @@ impl ChatComposer {
|
|||
true
|
||||
}
|
||||
|
||||
/// Integrate pasted text into the composer.
|
||||
///
|
||||
/// Acts as the only place where paste text is integrated, both for:
|
||||
///
|
||||
/// - Real/explicit paste events surfaced by the terminal, and
|
||||
/// - Non-bracketed "paste bursts" that [`PasteBurst`](super::paste_burst::PasteBurst) buffers
|
||||
/// and later flushes here.
|
||||
///
|
||||
/// Behavior:
|
||||
///
|
||||
/// - If the paste is larger than `LARGE_PASTE_CHAR_THRESHOLD` chars, inserts a placeholder
|
||||
/// element (expanded on submit) and stores the full text in `pending_pastes`.
|
||||
/// - Otherwise, if the paste looks like an image path, attaches the image and inserts a
|
||||
/// trailing space so the user can keep typing naturally.
|
||||
/// - Otherwise, inserts the pasted text directly into the textarea.
|
||||
///
|
||||
/// In all cases, clears any paste-burst Enter suppression state so a real paste cannot affect
|
||||
/// the next user Enter key, then syncs popup state.
|
||||
pub fn handle_paste(&mut self, pasted: String) -> bool {
|
||||
let char_count = pasted.chars().count();
|
||||
if char_count > LARGE_PASTE_CHAR_THRESHOLD {
|
||||
|
|
@ -310,6 +386,16 @@ impl ChatComposer {
|
|||
}
|
||||
}
|
||||
|
||||
/// Enable or disable paste-burst handling.
|
||||
///
|
||||
/// `disable_paste_burst` is an escape hatch for terminals/platforms where the burst heuristic
|
||||
/// is unwanted or has already been handled elsewhere.
|
||||
///
|
||||
/// When enabling the flag we clear the burst classification window so subsequent input cannot
|
||||
/// be incorrectly grouped into a previous burst.
|
||||
///
|
||||
/// This does not flush any in-progress buffer; callers should avoid toggling this mid-burst
|
||||
/// (or should flush first).
|
||||
pub(crate) fn set_disable_paste_burst(&mut self, disabled: bool) {
|
||||
let was_disabled = self.disable_paste_burst;
|
||||
self.disable_paste_burst = disabled;
|
||||
|
|
@ -351,7 +437,7 @@ impl ChatComposer {
|
|||
self.textarea.text().to_string()
|
||||
}
|
||||
|
||||
/// Attempt to start a burst by retro-capturing recent chars before the cursor.
|
||||
/// Insert an attachment placeholder and track it for the next submission.
|
||||
pub fn attach_image(&mut self, path: PathBuf) {
|
||||
let image_number = self.attached_images.len() + 1;
|
||||
let placeholder = local_image_label_text(image_number);
|
||||
|
|
@ -367,14 +453,31 @@ impl ChatComposer {
|
|||
images.into_iter().map(|img| img.path).collect()
|
||||
}
|
||||
|
||||
/// Flushes any due paste-burst state.
|
||||
///
|
||||
/// Call this from a UI tick to turn paste-burst transient state into explicit textarea edits:
|
||||
///
|
||||
/// - If a burst times out, flush it via `handle_paste(String)`.
|
||||
/// - If only the first ASCII char was held (flicker suppression) and no burst followed, emit it
|
||||
/// as normal typed input.
|
||||
///
|
||||
/// This also allows a single "held" ASCII char to render even when it turns out not to be part
|
||||
/// of a paste burst.
|
||||
pub(crate) fn flush_paste_burst_if_due(&mut self) -> bool {
|
||||
self.handle_paste_burst_flush(Instant::now())
|
||||
}
|
||||
|
||||
/// Returns whether the composer is currently in any paste-burst related transient state.
|
||||
///
|
||||
/// This includes actively buffering, having a non-empty burst buffer, or holding the first
|
||||
/// ASCII char for flicker suppression.
|
||||
pub(crate) fn is_in_paste_burst(&self) -> bool {
|
||||
self.paste_burst.is_active()
|
||||
}
|
||||
|
||||
/// Returns a delay that reliably exceeds the paste-burst timing threshold.
|
||||
///
|
||||
/// Use this in tests to avoid boundary flakiness around the `PasteBurst` timeout.
|
||||
pub(crate) fn recommended_paste_flush_delay() -> Duration {
|
||||
PasteBurst::recommended_flush_delay()
|
||||
}
|
||||
|
|
@ -613,6 +716,20 @@ impl ChatComposer {
|
|||
p
|
||||
}
|
||||
|
||||
/// Handle non-ASCII character input (often IME) while still supporting paste-burst detection.
|
||||
///
|
||||
/// This handler exists because non-ASCII input often comes from IMEs, where characters can
|
||||
/// legitimately arrive in short bursts that should **not** be treated as paste.
|
||||
///
|
||||
/// The key differences from the ASCII path:
|
||||
///
|
||||
/// - We never hold the first character (`PasteBurst::on_plain_char_no_hold`), because holding a
|
||||
/// non-ASCII char can feel like dropped input.
|
||||
/// - If a burst is detected, we may need to retroactively remove already-inserted text before
|
||||
/// the cursor and move it into the paste buffer (see `PasteBurst::decide_begin_buffer`).
|
||||
///
|
||||
/// Because this path mixes "insert immediately" with "maybe retro-grab later", it must clamp
|
||||
/// the cursor to a UTF-8 char boundary before slicing `textarea.text()`.
|
||||
#[inline]
|
||||
fn handle_non_ascii_char(&mut self, input: KeyEvent) -> (InputResult, bool) {
|
||||
if let KeyEvent {
|
||||
|
|
@ -639,12 +756,13 @@ impl ChatComposer {
|
|||
return (InputResult::None, true);
|
||||
}
|
||||
CharDecision::BeginBuffer { retro_chars } => {
|
||||
// For non-ASCII we inserted prior chars immediately, so if this turns out
|
||||
// to be paste-like we need to retroactively grab & remove the already-
|
||||
// inserted prefix from the textarea before buffering the burst.
|
||||
let cur = self.textarea.cursor();
|
||||
let txt = self.textarea.text();
|
||||
let safe_cur = Self::clamp_to_char_boundary(txt, cur);
|
||||
let before = &txt[..safe_cur];
|
||||
// If decision is to buffer, seed the paste burst buffer with the grabbed chars + new.
|
||||
// Otherwise, fall through to normal insertion below.
|
||||
if let Some(grab) =
|
||||
self.paste_burst
|
||||
.decide_begin_buffer(now, before, retro_chars as usize)
|
||||
|
|
@ -656,6 +774,8 @@ impl ChatComposer {
|
|||
self.paste_burst.append_char_to_buffer(ch, now);
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
// If decide_begin_buffer opted not to start buffering,
|
||||
// fall through to normal insertion below.
|
||||
}
|
||||
_ => unreachable!("on_plain_char_no_hold returned unexpected variant"),
|
||||
}
|
||||
|
|
@ -1359,6 +1479,14 @@ impl ChatComposer {
|
|||
}
|
||||
}
|
||||
|
||||
/// Applies any due `PasteBurst` flush at time `now`.
|
||||
///
|
||||
/// Converts [`PasteBurst::flush_if_due`] results into concrete textarea mutations.
|
||||
///
|
||||
/// Callers:
|
||||
///
|
||||
/// - UI ticks via [`ChatComposer::flush_paste_burst_if_due`], so held first-chars can render.
|
||||
/// - Input handling via [`ChatComposer::handle_input_basic`], so a due burst does not lag.
|
||||
fn handle_paste_burst_flush(&mut self, now: Instant) -> bool {
|
||||
match self.paste_burst.flush_if_due(now) {
|
||||
FlushResult::Paste(pasted) => {
|
||||
|
|
@ -1376,7 +1504,20 @@ impl ChatComposer {
|
|||
}
|
||||
}
|
||||
|
||||
/// Handle generic Input events that modify the textarea content.
|
||||
/// Handles keys that mutate the textarea, including paste-burst detection.
|
||||
///
|
||||
/// Acts as the lowest-level keypath for keys that mutate the textarea. It is also where plain
|
||||
/// character streams are converted into explicit paste operations on terminals that do not
|
||||
/// reliably provide bracketed paste.
|
||||
///
|
||||
/// Ordering is important:
|
||||
///
|
||||
/// - Always flush any *due* paste burst first so buffered text does not lag behind unrelated
|
||||
/// edits.
|
||||
/// - Then handle the incoming key, intercepting only "plain" (no Ctrl/Alt) char input.
|
||||
/// - For non-plain keys, flush via `flush_before_modified_input()` before applying the key;
|
||||
/// otherwise `clear_window_after_non_char()` can leave buffered text waiting without a
|
||||
/// timestamp to time out against.
|
||||
fn handle_input_basic(&mut self, input: KeyEvent) -> (InputResult, bool) {
|
||||
// If we have a buffered non-bracketed paste burst and enough time has
|
||||
// elapsed since the last char, flush it before handling a new input.
|
||||
|
|
@ -1396,6 +1537,10 @@ impl ChatComposer {
|
|||
}
|
||||
|
||||
// Intercept plain Char inputs to optionally accumulate into a burst buffer.
|
||||
//
|
||||
// This is intentionally limited to "plain" (no Ctrl/Alt) chars so shortcuts keep their
|
||||
// normal semantics, and so we can aggressively flush/clear any burst state when non-char
|
||||
// keys are pressed.
|
||||
if let KeyEvent {
|
||||
code: KeyCode::Char(ch),
|
||||
modifiers,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -730,8 +730,8 @@ impl TextArea {
|
|||
|
||||
/// Renames a single text element in-place, keeping it atomic.
|
||||
///
|
||||
/// This is intended for cases where the element payload is an identifier (e.g. a placeholder)
|
||||
/// that must be updated without converting the element back into normal text.
|
||||
/// Use this when the element payload is an identifier (e.g. a placeholder) that must be
|
||||
/// updated without converting the element back into normal text.
|
||||
pub fn replace_element_payload(&mut self, old: &str, new: &str) -> bool {
|
||||
let Some(idx) = self
|
||||
.elements
|
||||
|
|
|
|||
205
docs/tui-chat-composer.md
Normal file
205
docs/tui-chat-composer.md
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
# Chat Composer state machine (TUI)
|
||||
|
||||
This note documents the `ChatComposer` input state machine and the paste-related behavior added
|
||||
for Windows terminals.
|
||||
|
||||
Primary implementations:
|
||||
|
||||
- `codex-rs/tui/src/bottom_pane/chat_composer.rs`
|
||||
- `codex-rs/tui2/src/bottom_pane/chat_composer.rs`
|
||||
|
||||
Paste-burst detector:
|
||||
|
||||
- `codex-rs/tui/src/bottom_pane/paste_burst.rs`
|
||||
- `codex-rs/tui2/src/bottom_pane/paste_burst.rs`
|
||||
|
||||
## What problem is being solved?
|
||||
|
||||
On some terminals (notably on Windows via `crossterm`), _bracketed paste_ is not reliably surfaced
|
||||
as a single paste event. Instead, pasting multi-line content can show up as a rapid sequence of
|
||||
key events:
|
||||
|
||||
- `KeyCode::Char(..)` for text
|
||||
- `KeyCode::Enter` for newlines
|
||||
|
||||
If the composer treats those events as “normal typing”, it can:
|
||||
|
||||
- accidentally trigger UI toggles (e.g. `?`) while the paste is still streaming,
|
||||
- submit the message mid-paste when an `Enter` arrives,
|
||||
- render a typed prefix, then “reclassify” it as paste once enough chars arrive (flicker).
|
||||
|
||||
The solution is to detect paste-like _bursts_ and buffer them into a single explicit
|
||||
`handle_paste(String)` call.
|
||||
|
||||
## High-level state machines
|
||||
|
||||
`ChatComposer` effectively combines two small state machines:
|
||||
|
||||
1. **UI mode**: which popup (if any) is active.
|
||||
- `ActivePopup::None | Command | File | Skill`
|
||||
2. **Paste burst**: transient detection state for non-bracketed paste.
|
||||
- implemented by `PasteBurst`
|
||||
|
||||
### Key event routing
|
||||
|
||||
`ChatComposer::handle_key_event` dispatches based on `active_popup`:
|
||||
|
||||
- If a popup is visible, a popup-specific handler processes the key first (navigation, selection,
|
||||
completion).
|
||||
- Otherwise, `handle_key_event_without_popup` handles higher-level semantics (Enter submit,
|
||||
history navigation, etc).
|
||||
- After handling the key, `sync_popups()` runs so popup visibility/filters stay consistent with the
|
||||
latest text + cursor.
|
||||
|
||||
## Paste burst: concepts and assumptions
|
||||
|
||||
The burst detector is intentionally conservative: it only processes “plain” character input
|
||||
(no Ctrl/Alt modifiers). Everything else flushes and/or clears the burst window so shortcuts keep
|
||||
their normal meaning.
|
||||
|
||||
### Conceptual `PasteBurst` states
|
||||
|
||||
- **Idle**: no buffer, no pending char.
|
||||
- **Pending first char** (ASCII only): hold one fast character very briefly to avoid rendering it
|
||||
and then immediately removing it if the stream turns out to be a paste.
|
||||
- **Active buffer**: once a burst is classified as paste-like, accumulate the content into a
|
||||
`String` buffer.
|
||||
- **Enter suppression window**: keep treating `Enter` as “newline” briefly after burst activity so
|
||||
multiline pastes remain grouped even if there are tiny gaps.
|
||||
|
||||
### ASCII vs non-ASCII (IME) input
|
||||
|
||||
Non-ASCII characters frequently come from IMEs and can legitimately arrive in quick bursts. Holding
|
||||
the first character in that case can feel like dropped input.
|
||||
|
||||
The composer therefore distinguishes:
|
||||
|
||||
- **ASCII path**: allow holding the first fast char (`PasteBurst::on_plain_char`).
|
||||
- **non-ASCII path**: never hold the first char (`PasteBurst::on_plain_char_no_hold`), but still
|
||||
allow burst detection. When a burst is detected on this path, the already-inserted prefix may be
|
||||
retroactively removed from the textarea and moved into the paste buffer.
|
||||
|
||||
To avoid misclassifying IME bursts as paste, the non-ASCII retro-capture path runs an additional
|
||||
heuristic (`PasteBurst::decide_begin_buffer`) to determine whether the retro-grabbed prefix “looks
|
||||
pastey” (e.g. contains whitespace or is long).
|
||||
|
||||
### Disabling burst detection
|
||||
|
||||
`ChatComposer` supports `disable_paste_burst` as an escape hatch.
|
||||
|
||||
When enabled:
|
||||
|
||||
- The burst detector is bypassed for new input (no flicker suppression hold and no burst buffering
|
||||
decisions for incoming characters).
|
||||
- The key stream is treated as normal typing (including normal slash command behavior).
|
||||
- Enabling the flag clears the burst classification window. In the current implementation it does
|
||||
**not** flush or clear an already-buffered burst, so callers should avoid toggling this flag
|
||||
mid-burst (or should flush first).
|
||||
|
||||
### Enter handling
|
||||
|
||||
When paste-burst buffering is active, Enter is treated as “append `\n` to the burst” rather than
|
||||
“submit the message”. This prevents mid-paste submission for multiline pastes that are emitted as
|
||||
`Enter` key events.
|
||||
|
||||
The composer also disables burst-based Enter suppression inside slash-command context (popup open
|
||||
or the first line begins with `/`) so command dispatch is predictable.
|
||||
|
||||
## PasteBurst: event-level behavior (cheat sheet)
|
||||
|
||||
This section spells out how `ChatComposer` interprets the `PasteBurst` decisions. 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.
|
||||
Loading…
Add table
Reference in a new issue