TUI: input buffered during a fork replays against the new thread, chain-forking once per queued Esc-Esc-Enter gesture

Open 💬 1 comment Opened Jul 25, 2026 by gusholcomb

What happened

While working in a long-running codex-tui session (~3,500 turns, ~39 MB rollout), rapid backtrack input (a macro pad emitting Esc Esc Enter sequences) produced 13 forked threads in 11 seconds — one new thread per buffered gesture. Each fork wrote a complete rollout file with a fresh thread id and a full copy of the conversation history (~39.6 MB, ~16,170 records each), roughly 500 MB of duplicate rollouts on disk from one incident.

The forks form a chain via forked_from_id, which is the fingerprint of buffered gestures replaying against each successively attached thread:

019f763b-0134… (original)
└── 019f8ccb-ac29…
    ├── ba40…
    │   ├── c001…, c424…, c891…, cd00…, d0b5…, d484…, d89e…
    ├── bd8d…, c1af…, c5e4…, ca4a…

All 13 were written at ~1/second (fork copy time for a session this size — same underlying cost as #19278), each session_meta carrying forked_from_id pointing at the thread attached immediately before it. Observed on 0.145.0-alpha.30; the mechanism is unchanged at current main.

Why it happens

AppEvent::ForkSessionForPromptEdit is handled inline in the app run loop (tui/src/app/event_dispatch.rs): the handler awaits thread_read + fork_thread_at + widget attach, which takes ~1s on a large session. Key events struck during that window are buffered (OS typeahead + crossterm's queue) and replay once the handler returns.

The guard at the top of the handler only rejects events whose captured thread_id differs from the current widget. A replayed Esc after the fork attaches re-primes backtrack against the newly attached thread (app_backtrack.rs::prime_backtrack sets base_id from the current widget), the second Esc opens the preview, and the replayed Enter confirms — sending a fresh fork event that passes the guard. Each buffered gesture repeats the cycle. As a side effect, the first replayed Esc also discards the prompt that restore_user_message_to_composer just restored, so the edit the user was attempting is lost as well.

Note: the reference-based fork work (#35220) removes the full-history copy, but each buffered gesture still mints a new thread, pollutes the thread list, and (for external tooling reading rollouts) multiplies apparent session counts.

Repro

  1. Open a large session in codex-tui (bigger = wider window; anything where a fork takes >200 ms works).
  2. Send Esc Esc Enter repeatedly and rapidly (macro pad, key repeat, or paste-spam) so gestures queue while a fork is in flight.
  3. Observe ~/.codex/sessions/… gain one rollout-…jsonl per gesture, each forked_from_id-chained to the previous.

Suggested fix

Keep the live TUI event stream polled while an active prompt-edit fork is in flight, discarding key and paste events delivered during that operation instead of allowing them to queue and trying to flush multiple terminal layers afterward. The existing prompt-edit handler remains inline and heap-pinned, preserving app-server event ordering; after the discard window closes, the caller explicitly schedules a fresh frame.

This is deliberately scoped to ForkSessionForPromptEdit. Ordinary /fork, resume, safety retry, and agent selection keep their existing input behavior, avoiding a broad rule that can discard valid typeahead during unrelated thread transitions.

Fix implemented and explained in the comments:
https://github.com/openai/codex/compare/main...gusholcomb:codex:fix/discard-typeahead-during-prompt-edit

View original on GitHub ↗

1 Comment

gusholcomb · 1 month ago

Updated proposal after reconsidering the original post-switch queue drain:

Problem

ForkSessionForPromptEdit is handled inline. On a large thread, thread_read + fork + attach can take seconds, during which terminal input queues behind the handler. Once the new thread is attached, a queued Esc Esc Enter is replayed against it and starts another prompt-edit fork. The first replayed Esc also removes the prompt that was just restored to the composer.

Fix

Instead of letting input accumulate and trying to flush the OS, crossterm, broker, and TUI queues afterward, the top-level app loop keeps the live TUI event stream polled while the active prompt-edit handler is running:

  • The existing handle_event future remains inline and is Box::pinned before either path, preserving app-server event ordering without putting its large future on the stack.
  • await_while_discarding_tui_input selects between that operation and tui_events.next(). Key and paste events delivered while the operation is pending are consumed and counted, so they never replay against the replacement thread.
  • When the operation completes, the helper drains events already ready in the TUI stream. The caller then explicitly schedules a frame because draw/resize events may have been consumed during the discard window.
  • The special handling applies only when the event is ForkSessionForPromptEdit for the currently active thread. Stale prompt-edit events retain the existing thread-id rejection behavior.

This removes the crossterm pause/recreate cycle, the timing-based 20 ms drain, and BufferedInputPolicy plumbing through unrelated thread transitions. /fork, resume, safety retry, agent selection, /new, and /clear keep their existing input behavior.

The intentional boundary is narrow: input delivered while a prompt-edit fork is running is discarded even if that operation ultimately fails; the selected prompt is still restored by the existing error path. Input delivered after the operation is handled normally.

Testing

  • tui_input_tests::discards_input_until_operation_completes_and_preserves_fresh_input queues Esc Esc Enter, verifies all three are discarded, then verifies fresh input is preserved after completion.
  • prompt_edit_discards_queued_input_forks_once_and_preserves_source runs a real embedded app-server fork with Esc Esc Enter queued, verifies the selected prompt and images are restored, verifies the source rollout is unchanged, and asserts that exactly the source thread plus one fork are loaded.
  • just test -p codex-tui: 3,246 passed, 4 skipped.
  • just fix -p codex-tui and just fmt pass.