## Problem Before this change, composer paths that cleared the textarea after submit or slash-command dispatch also cleared the textarea kill buffer. That meant a user could `Ctrl+K` part of a draft, trigger a composer action that cleared the visible draft, and then lose the ability to `Ctrl+Y` the killed text back. This was especially awkward for workflows where the user wants to temporarily remove text, run a composer action such as changing reasoning level or dispatching a slash command, and then restore the killed text into the now-empty draft. ## Mental model This change separates visible draft state from editing-history state. The visible draft includes the current textarea contents and text elements that should be cleared when the composer submits or dispatches a command. The kill buffer is different: it represents the most recent killed text and should survive those composer-driven clears so the user can still yank it back afterward. After this change, submit and slash-command dispatch still clear the visible textarea contents, but they no longer erase the most recent kill. ## Non-goals This does not implement a multi-entry kill ring or change the semantics of `Ctrl+K` and `Ctrl+Y` beyond preserving the existing yank target across these clears. It also does not change how submit, slash-command parsing, prompt expansion, or attachment handling work, except that those flows no longer discard the textarea kill buffer as a side effect of clearing the draft. ## Tradeoffs The main tradeoff is that clearing the visible textarea is no longer equivalent to fully resetting all editing state. That is intentional here, because submit and slash-command dispatch are composer actions, not requests to forget the user's most recent kill. The benefit is better editing continuity. The cost is that callers must understand that full-buffer replacement resets visible draft state but not the kill buffer. ## Architecture The behavioral change is in `TextArea`: full-buffer replacement now rebuilds text and elements without clearing `kill_buffer`. `ChatComposer` already clears the textarea after successful submit and slash-command dispatch by calling into those textarea replacement paths. With this change, those existing composer flows inherit the new behavior automatically: the visible draft is cleared, but the last killed text remains available for `Ctrl+Y`. The tests cover both layers: - `TextArea` verifies that the kill buffer survives full-buffer replacement. - `ChatComposer` verifies that it survives submit. - `ChatComposer` also verifies that it survives slash-command dispatch. ## Observability There is no dedicated logging for kill-buffer preservation. The most direct way to reason about the behavior is to inspect textarea-wide replacement paths and confirm whether they treat the kill buffer as visible-buffer state or as editing-history state. If this regresses in the future, the likely failure mode is simple and user-visible: `Ctrl+Y` stops restoring text after submit or slash-command clears even though ordinary kill/yank still works within a single uninterrupted draft. ## Tests Added focused regression coverage for the new contract: - `kill_buffer_persists_across_set_text` - `kill_buffer_persists_after_submit` - `kill_buffer_persists_after_slash_command_dispatch` Local verification: - `just fmt` - `cargo test -p codex-tui` --------- Co-authored-by: Josh McKinney <joshka@openai.com>
17 KiB
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
Paste-burst detector:
codex-rs/tui/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 textKeyCode::Enterfor 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
Enterarrives, - 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:
- UI mode: which popup (if any) is active.
ActivePopup::None | Command | File | Skill
- Paste burst: transient detection state for non-bracketed paste.
- implemented by
PasteBurst
- implemented by
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_popuphandles 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. - When a slash command name is completed and the user types a space, the
/commandtoken is promoted into a text element so it renders distinctly and edits atomically.
History navigation (↑/↓)
Up/Down recall is handled by ChatComposerHistory and merges two sources:
- Persistent history (cross-session, fetched from
~/.codex/history.jsonl): text-only. It does not carry text element ranges or image attachments, so recalling one of these entries only restores the text. - Local history (current session): stores the full submission payload, including text elements, local image paths, and remote image URLs. Recalling a local entry rehydrates placeholders and attachments.
This distinction keeps the on-disk history backward compatible and avoids persisting attachments, while still providing a richer recall experience for in-session edits.
Config gating for reuse
ChatComposer now supports feature gating via ChatComposerConfig
(codex-rs/tui/src/bottom_pane/chat_composer.rs). The default config preserves current chat
behavior.
Flags:
popups_enabledslash_commands_enabledimage_paste_enabled
Key effects when disabled:
- When
popups_enabledisfalse,sync_popups()forcesActivePopup::None. - When
slash_commands_enabledisfalse, the composer does not treat/...input as commands. - When
slash_commands_enabledisfalse, the composer does not expand custom prompts inprepare_submission_text. - When
slash_commands_enabledisfalse, slash-context paste-burst exceptions are disabled. - When
image_paste_enabledisfalse, file-path paste image attachment is skipped. ChatWidgetmay toggleimage_paste_enabledat runtime based on the selected model'sinput_modalities; attach and submit paths also re-check support and emit a warning instead of dropping the draft.
Built-in slash command availability is centralized in
codex-rs/tui/src/bottom_pane/slash_commands.rs and reused by both the composer and the command
popup so gating stays in sync.
Submission flow (Enter/Tab)
There are multiple submission paths, but they share the same core rules:
When steer mode is enabled, Tab requests queuing if a task is already running; otherwise it
submits immediately. Enter always submits immediately in this mode. Tab does not submit when
the input starts with ! (shell command).
Normal submit/queue path
handle_submission calls prepare_submission_text for both submit and queue. That method:
- Expands any pending paste placeholders so element ranges align with the final text.
- Trims whitespace and rebases element ranges to the trimmed buffer.
- Expands
/prompts:custom prompts:- Named args use key=value parsing.
- Numeric args use positional parsing for
$1..$9and$ARGUMENTS. The expansion preserves text elements and yields the final submission payload.
- Prunes attachments so only placeholders that survive expansion are sent.
- Clears pending pastes on success and suppresses submission if the final text is empty and there are no attachments.
The same preparation path is reused for slash commands with arguments (for example /plan and
/review) so pasted content and text elements are preserved when extracting args.
The composer also treats the textarea kill buffer as separate editing state from the visible draft.
After submit or slash-command dispatch clears the textarea, the most recent Ctrl+K payload is
still available for Ctrl+Y. This supports flows where a user kills part of a draft, runs a
composer action such as changing reasoning level, and then yanks that text back into the cleared
draft.
Numeric auto-submit path
When the slash popup is open and the first line matches a numeric-only custom prompt with
positional args, Enter auto-submits without calling prepare_submission_text. That path still:
- Expands pending pastes before parsing positional args.
- Uses expanded text elements for prompt expansion.
- Prunes attachments based on expanded placeholders.
- Clears pending pastes after a successful auto-submit.
Remote image rows (selection/deletion flow)
Remote image URLs are shown as [Image #N] rows above the textarea, inside the same composer box.
They are attachment rows, not editable textarea content.
- TUI can remove these rows, but cannot type before/between them.
- Press
Upat textarea cursor position0to select the last remote image row. - While selected,
Up/Downmoves selection across remote image rows. - Pressing
Downon the last row exits remote-row selection and returns to textarea editing. DeleteorBackspaceremoves the selected remote image row.
Image numbering is unified:
- Remote image rows always occupy
[Image #1]..[Image #M]. - Local attached image placeholders start after that offset (
[Image #M+1]..). - Removing remote rows relabels local placeholders so numbering stays contiguous.
History navigation (Up/Down) and backtrack prefill
ChatComposerHistory merges two kinds of history:
- Persistent history (cross-session, fetched from core on demand): text-only.
- Local history (this UI session): full draft state.
Local history entries capture:
- raw text (including placeholders),
TextElementranges for placeholders,- local image paths,
- remote image URLs,
- pending large-paste payloads (for drafts).
Persistent history entries only restore text. They intentionally do not rehydrate attachments or pending paste payloads.
For non-empty drafts, Up/Down navigation is only treated as history recall when the current text matches the last recalled history entry and the cursor is at a boundary (start or end of the line). This keeps multiline cursor movement intact while preserving shell-like history traversal.
Draft recovery (Ctrl+C)
Ctrl+C clears the composer but stashes the full draft state (text elements, local image paths, remote image URLs, and pending paste payloads) into local history. Pressing Up immediately restores that draft, including image placeholders and large-paste placeholders with their payloads.
Submitted message recall
After a successful submission, the local history entry stores the submitted text, element ranges, local image paths, and remote image URLs. Pending paste payloads are cleared during submission, so large-paste placeholders are expanded into their full text before being recorded. This means:
- Up/Down recall of a submitted message restores remote image rows plus local image placeholders.
- Recalled entries place the cursor at end-of-line to match typical shell history editing.
- Large-paste placeholders are not expected in recalled submitted history; the text is the expanded paste content.
Backtrack prefill
Backtrack selections read UserHistoryCell data from the transcript. The composer prefill now
reuses the selected message’s text elements, local image paths, and remote image URLs, so image
placeholders and attachments rehydrate when rolling back to a prior user message.
External editor edits
When the composer content is replaced from an external editor, the composer rebuilds text elements
and keeps only attachments whose placeholders still appear in the new text. Image placeholders are
then normalized to [Image #M]..[Image #N], where M starts after the number of remote image
rows, to keep attachment mapping consistent after edits.
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
Stringbuffer. - Enter suppression window: keep treating
Enteras “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 flushes any held/buffered burst text through the normal paste path
(
ChatComposer::handle_paste) and then clears the burst timing and Enter-suppression windows so transient burst state cannot leak into subsequent input.
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 insertcinto the textarea yet. A UI tick later may flush it as a normal typed char viaPasteBurst::flush_if_due.BeginBufferFromPending: the first ASCII char is already held/buffered; appendcviaPasteBurst::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), deletegrab.start_byte..cursorfrom the textarea and then appendcto the buffer; - if it returns
None, fall back to normal insertion.
- call
BufferAppend: appendcto 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)appendscdirectly. - Otherwise it calls
PasteBurst::on_plain_char_no_hold(now):BufferAppend: appendcto the active buffer.BeginBuffer { retro_chars }: rundecide_begin_buffer(..)and, if it starts buffering, delete the retro-grabbed prefix from the textarea and appendc.None: insertcinto 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\ninto 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\ninto the textarea and callsextend_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 viaPasteBurst::flush_before_modified_input. PasteBurst::clear_window_after_non_charclears 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_charclearslast_plain_char_time. If you call it whilebufferis 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. Treatclear_window_after_non_charas “drop classification context after flush”, not “flush”.PasteBurst::flush_if_dueuses a strict>comparison, so tests and UI ticks should cross the threshold by at least 1ms (seePasteBurst::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. - Mention popup selection has two payloads: visible
$nametext and hiddenmention_paths[name] -> canonical targetlinkage. The genericset_text_contentpath intentionally clears linkage for fresh drafts; restore paths that rehydrate blocked/interrupted submissions must use the mention-preserving setter so retry keeps the originally selected target.
Tests that pin behavior
The PasteBurst logic is currently exercised through ChatComposer integration tests.
codex-rs/tui/src/bottom_pane/chat_composer.rsnon_ascii_burst_handles_newlineascii_burst_treats_enter_as_newlinequestion_mark_does_not_toggle_during_paste_burstburst_paste_fast_small_buffers_and_flushes_on_stopburst_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.