Files
codex/docs/tui-chat-composer.md
Felipe Coury 0393a485ed feat(tui): add reverse history search to composer (#17550)
## Problem

The TUI had shell-style Up/Down history recall, but `Ctrl+R` did not
provide the reverse incremental search workflow users expect from
shells. Users needed a way to search older prompts without immediately
replacing the current draft, and the interaction needed to handle async
persistent history, repeated navigation keys, duplicate prompt text,
footer hints, and preview highlighting without making the main composer
file even harder to review.


https://github.com/user-attachments/assets/5165affd-4c9a-46e9-adbd-89088f5f7b6b

<img width="1227" height="722" alt="image"
src="https://github.com/user-attachments/assets/8bc83289-eeca-47c7-b0c3-8975101901af"
/>

## Mental model

`Ctrl+R` opens a temporary search session owned by the composer. The
footer line becomes the search input, the composer body previews the
current match only after the query has text, and `Enter` accepts that
preview as an editable draft while `Esc` restores the draft that existed
before search started. The history layer provides a combined offset
space over persistent and local history, but search navigation exposes
unique prompt text rather than every physical history row.

## Non-goals

This change does not rewrite stored history, change normal Up/Down
browsing semantics, add fuzzy matching, or add persistent metadata for
attachments in cross-session history. Search deduplication is
deliberately scoped to the active Ctrl+R search session and uses exact
prompt text, so case, whitespace, punctuation, and attachment-only
differences are not normalized.

## Tradeoffs

The implementation keeps search state in the existing composer and
history state machines instead of adding a new cross-module controller.
That keeps ownership local and testable, but it means the composer still
coordinates visible search status, draft restoration, footer rendering,
cursor placement, and match highlighting while `ChatComposerHistory`
owns traversal, async fetch continuation, boundary clamping, and
unique-result caching. Unique-result caching stores cloned
`HistoryEntry` values so known matches can be revisited without cache
lookups; this is simple and robust for interactive search sizes, but it
is not a global history index.

## Architecture

`ChatComposer` detects `Ctrl+R`, snapshots the current draft, switches
the footer to `FooterMode::HistorySearch`, and routes search-mode keys
before normal editing. Query edits call `ChatComposerHistory::search`
with `restart = true`, which starts from the newest combined-history
offset. Repeated `Ctrl+R` or Up searches older; Down searches newer
through already discovered unique matches or continues the scan.
Persistent history entries still arrive asynchronously through
`on_entry_response`, where a pending search either accepts the response,
skips a duplicate, or requests the next offset.

The composer-facing pieces now live in
`codex-rs/tui/src/bottom_pane/chat_composer/history_search.rs`, leaving
`chat_composer.rs` responsible for routing and rendering integration
instead of owning every search helper inline.
`codex-rs/tui/src/bottom_pane/chat_composer_history.rs` remains the
owner of stored history, combined offsets, async fetch state, boundary
semantics, and duplicate suppression. Match highlighting is computed
from the current composer text while search is active and disappears
when the match is accepted.

## Observability

There are no new logs or telemetry. The practical debug path is state
inspection: `ChatComposer.history_search` tells whether the footer query
is idle, searching, matched, or unmatched; `ChatComposerHistory.search`
tracks selected raw offsets, pending persistent fetches, exhausted
directions, and unique match cache state. If a user reports skipped or
repeated results, first inspect the exact stored prompt text, the
selected offset, whether an async persistent response is still pending,
and whether a query edit restarted the search session.

## Tests

The change is covered by focused `codex-tui` unit tests for opening
search without previewing the latest entry, accepting and canceling
search, no-match restoration, boundary clamping, footer hints,
case-insensitive highlighting, local duplicate skipping, and persistent
duplicate skipping through async responses. Snapshot coverage captures
the footer-mode visual changes. Local verification used `just fmt`,
`cargo test -p codex-tui history_search`, `cargo test -p codex-tui`, and
`just fix -p codex-tui`.
2026-04-12 19:32:19 -03:00

17 KiB
Raw Permalink Blame History

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 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.
  • When a slash command name is completed and the user types a space, the /command token 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.

Reverse history search (Ctrl+R)

Ctrl+R enters an incremental reverse search mode without immediately previewing the latest history entry. While search is active, the footer line becomes the editable query field and the composer body is only a preview of the currently matched entry. Enter accepts the preview as a normal editable draft, and Esc or Ctrl+C restores the exact draft that existed before search started.

The composer owns the search session because it controls draft snapshots, footer rendering, cursor placement, and preview highlighting. ChatComposerHistory owns traversal: it scans persistent and local entries in one offset space, skips duplicate prompt text within a search session, keeps boundary hits on the current match, and resumes scans after asynchronous persistent history responses.

The search query and composer text intentionally remain separate. A no-match result restores the original draft while leaving the footer query open for more typing, and accepting a match clears the search session so highlight styling disappears from the now-editable composer text.

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_enabled
  • slash_commands_enabled
  • image_paste_enabled

Key effects when disabled:

  • When popups_enabled is false, sync_popups() forces ActivePopup::None.
  • When slash_commands_enabled is false, the composer does not treat /... input as commands.
  • When slash_commands_enabled is false, slash-context paste-burst exceptions are disabled.
  • When 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.

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:

  1. Expands any pending paste placeholders so element ranges align with the final text.
  2. Trims whitespace and rebases element ranges to the trimmed buffer.
  3. Prunes attachments so only placeholders that survive trimming are sent.
  4. 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.

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 Up at textarea cursor position 0 to select the last remote image row.
  • While selected, Up/Down moves selection across remote image rows.
  • Pressing 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:

  • 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),
  • TextElement ranges 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 messages 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 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 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. Its 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 doesnt 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 havent 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.
  • Mention popup selection has two payloads: visible $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.

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

This document calls out some additional contracts (like “flush before clearing”) that are not yet fully pinned by dedicated PasteBurst unit tests.