TUI: input buffered during a fork replays against the new thread, chain-forking once per queued Esc-Esc-Enter gesture
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
- Open a large session in codex-tui (bigger = wider window; anything where a fork takes >200 ms works).
- Send
Esc Esc Enterrepeatedly and rapidly (macro pad, key repeat, or paste-spam) so gestures queue while a fork is in flight. - Observe
~/.codex/sessions/…gain onerollout-…jsonlper gesture, eachforked_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
1 Comment
Updated proposal after reconsidering the original post-switch queue drain:
Problem
ForkSessionForPromptEditis 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 queuedEsc Esc Enteris replayed against it and starts another prompt-edit fork. The first replayedEscalso 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:
handle_eventfuture remains inline and isBox::pinned before either path, preserving app-server event ordering without putting its large future on the stack.await_while_discarding_tui_inputselects between that operation andtui_events.next(). Key and paste events delivered while the operation is pending are consumed and counted, so they never replay against the replacement thread.ForkSessionForPromptEditfor 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
BufferedInputPolicyplumbing through unrelated thread transitions./fork, resume, safety retry, agent selection,/new, and/clearkeep 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_inputqueuesEsc Esc Enter, verifies all three are discarded, then verifies fresh input is preserved after completion.prompt_edit_discards_queued_input_forks_once_and_preserves_sourceruns a real embedded app-server fork withEsc Esc Enterqueued, 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-tuiandjust fmtpass.