docs/tui-chat-composer.md
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.rsPaste-burst detector:
codex-rs/tui/src/bottom_pane/paste_burst.rsOn 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::Enter for newlinesIf the composer treats those events as “normal typing”, it can:
?) while the paste is still streaming,Enter arrives,The solution is to detect paste-like bursts and buffer them into a single explicit
handle_paste(String) call.
ChatComposer effectively combines two small state machines:
ActivePopup::None | Command | File | SkillPasteBurstChatComposer::handle_key_event dispatches based on active_popup:
handle_key_event_without_popup handles higher-level semantics (Enter submit,
history navigation, etc).sync_popups() runs so popup visibility/filters stay consistent with the
latest text + cursor./command token is
promoted into a text element so it renders distinctly and edits atomically.Up/Down recall is handled by ChatComposerHistory and merges two sources:
~/.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.This distinction keeps the on-disk history backward compatible and avoids persisting attachments, while still providing a richer recall experience for in-session edits.
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_enabledKey effects when disabled:
popups_enabled is false, sync_popups() forces ActivePopup::None.slash_commands_enabled is false, the composer does not treat /... input as commands.slash_commands_enabled is false, the composer does not expand custom prompts in
prepare_submission_text.slash_commands_enabled is false, slash-context paste-burst exceptions are disabled.image_paste_enabled is false, file-path paste image attachment is skipped.ChatWidget may toggle image_paste_enabled at runtime based on the selected model's
input_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.
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).
handle_submission calls prepare_submission_text for both submit and queue. That method:
/prompts: custom prompts:
$1..$9 and $ARGUMENTS.
The expansion preserves text elements and yields the final submission payload.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.
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:
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.
Up at textarea cursor position 0 to select the last remote image row.Up/Down moves selection across remote image rows.Down on the last row exits remote-row selection and returns to textarea editing.Delete or Backspace removes the selected remote image row.Image numbering is unified:
[Image #1]..[Image #M].[Image #M+1]..).ChatComposerHistory merges two kinds of history:
Local history entries capture:
TextElement ranges for placeholders,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.
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.
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:
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.
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.
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.
PasteBurst statesString buffer.Enter as “newline” briefly after burst activity so
multiline pastes remain grouped even if there are tiny gaps.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:
PasteBurst::on_plain_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).
ChatComposer supports disable_paste_burst as an escape hatch.
When enabled:
ChatComposer::handle_paste) and then clears the burst timing and Enter-suppression windows so
transient burst state cannot leak into subsequent input.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.
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”.
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:
PasteBurst::decide_begin_buffer(now, before_cursor, retro_chars);Some(grab), delete grab.start_byte..cursor from the textarea and then append
c to the buffer;None, fall back to normal insertion.BufferAppend: append c to the active buffer.KeyCode::Char(c) (no Ctrl/Alt modifiers)ChatComposer::handle_non_ascii_char uses a slightly different flow:
PasteBurst::flush_before_modified_input
(which includes a single held ASCII char).PasteBurst::try_append_char_if_active(c, now) appends c directly.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 submitThere are two distinct “Enter becomes newline” mechanisms:
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.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 input must not leak burst state across unrelated actions:
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.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).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.? is gated on !is_in_paste_burst() so pastes cannot flip UI
modes while streaming.$name text and hidden
mention_paths[name] -> canonical target linkage. The generic
set_text_content path 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.The PasteBurst logic is currently exercised through ChatComposer integration tests.
codex-rs/tui/src/bottom_pane/chat_composer.rs
non_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_flushThis document calls out some additional contracts (like “flush before clearing”) that are not yet
fully pinned by dedicated PasteBurst unit tests.