TUI: Enter can be downgraded to "Queued follow-up inputs" during a long-running goal until restart

Open 💬 1 comment Opened Aug 17, 2026 by boristown

What version of Codex CLI is running?

codex-cli 0.147.0

What subscription do you have?

ChatGPT subscription (the exact tier is not exposed by the CLI in this environment)

Which model were you using?

gpt-5.6-sol, xhigh reasoning effort

What platform is your computer?

Linux 4.12.14-120-default x86_64 x86_64

What terminal emulator and version are you using (if applicable)?

xterm-256color over SSH, inside tmux 3.7b

Codex doctor report

Sanitized summary:

{
  "version": "0.147.0",
  "platform": "linux-x86_64",
  "runtime": "standalone",
  "install": "consistent",
  "config": "loaded",
  "auth": "configured",
  "state_databases": "healthy",
  "websocket": "connected (HTTP 101 Switching Protocols)",
  "notes": [
    "optional MCP configuration issues",
    "some rollout files missing from the state DB",
    "one HTTP reachability check failed, although the active goal continued streaming and running tools"
  ]
}

Paths, thread IDs, and host-specific details were removed.

What issue are you seeing?

During a long-running /goal turn, pressing Enter on a follow-up message can incorrectly place it under:

Queued follow-up inputs
  ↳ <message>
    shift + ← edit last queued message

The expected Enter behavior during an active turn is the pending-steer path:

Messages to be submitted after next tool call
(press esc to interrupt and send immediately)

Tab is supposed to create a queued follow-up. In the broken state, Enter behaves semantically like Tab.

The queued message remains local and is not added to the rollout or message history, even after multiple tool-call boundaries. Exiting the entire TUI with Ctrl+C and starting Codex again clears the problem.

This is not a terminal key translation issue:

  • a live strace at the tmux server's PTY read point captured the physical key packet as 0x0d (carriage return / Enter), not 0x09 (Tab)
  • there were no tmux Enter/Tab remappings
  • [tui.keymap] had no custom bindings
  • the built-in keymap is Enter = submit and Tab = queue

What steps can reproduce the bug?

This was observed in a long-lived TUI process and has not yet been reduced to a deterministic fresh-session reproducer:

  1. Start Codex CLI in tmux.
  2. Start a long-running /goal.
  3. Let the goal run through many model/tool boundaries.
  4. In the observed session, collaboration/settings selections had also changed (Plan → Default, then reasoning effort to xhigh) before the failure. This may be a contributing trigger.
  5. While the goal is actively running, type a normal follow-up such as report progress percentage.
  6. Press Enter.
  7. Observe that the message appears under Queued follow-up inputs instead of Messages to be submitted after next tool call.
  8. Let several tool calls complete. The message remains queued and does not enter the rollout.
  9. Exit the entire TUI with Ctrl+C and restart Codex. Enter resumes the expected behavior.

A useful diagnostic distinction is that the terminal really delivers Enter (0x0d); the conversion to a queued follow-up happens inside the TUI.

What is the expected behavior?

While an agent turn is active:

  • Enter should create a pending steer and display Messages to be submitted after next tool call.
  • Tab should create Queued follow-up inputs.
  • A stale TUI state flag should not silently downgrade Enter to a queued next-turn message.
  • If immediate steering is temporarily unavailable, the UI should explain why rather than presenting Enter and Tab as equivalent.
  • Restarting the TUI should not be required to restore Enter behavior.

Additional information

This looks distinct from:

  • #15842, where a message correctly enters the pending-steer UI but becomes stuck there
  • #37974, where queued follow-ups are stranded after a failed prompt-edit branch

In this case the initial classification itself is wrong: a real Enter submission is placed directly into Queued follow-up inputs.

Based on local code inspection, a possible path is a stale submission-suppression state (for example suppress_queue_autosend) after a settings/modal transition. InputResult::Submitted can then fail the should_submit_now check and fall through to queue_user_message, producing exactly the observed UI. This is an inference, not a confirmed internal-state dump.

The same TUI process had successfully handled an earlier Enter steer, and no code path should re-enable startup queue_submissions after the session is configured. The failure appeared later in the long-running goal and disappeared only after restarting the TUI.

View original on GitHub ↗

1 Comment

jdcodes1 · 10 days ago

Your suppress_queue_autosend inference checks out on main @ 1f41cc5d92 — and the flag's lifecycle has a structural hole that produces exactly a sticky, restart-only-recoverable downgrade.

The downgrade itself is your hypothesis, verbatim. Enter's InputResult::Submitted goes through should_submit_now, which requires !self.input_queue.suppress_queue_autosend; when that fails, the submission falls through to queue_user_message — i.e. Enter becomes semantically Tab:

https://github.com/openai/codex/blob/1f41cc5d92722748e45cae9cecc6d883a4e7cbb1/codex-rs/tui/src/chatwidget/input_flow.rs#L32-L53

And while the flag is set, maybe_send_next_queued_input returns early (#L138-L140), which is why the queued message never drains across tool-call boundaries either — both halves of your report from one bit of state.

How the flag gets stuck. It's set by defer_input_until_settings_applied whenever a settings modal/popup is active (#L84-L88). The only clear path is an event round-trip: on_modal_or_popup_closedSettingsSelectionClosedSettingsSelectionSettled, which resets the flag only if no modal/popup is active at settle time (app/event_dispatch.rs#L1377-L1386). Two weaknesses:

  1. on_modal_or_popup_closed is invoked only from the key-event handling paths (chatwidget/interaction.rs#L25-L35, #L415-L426 — both gated on a key event having just closed the popup). A modal dismissed by anything other than a key — a bottom-pane view replaced by turn/goal activity, a popup closed as a side effect of an AppEvent, a selection whose action swaps the view internally — never fires the close hook, so the settle event never happens and nothing else in the codebase ever clears the flag. That's the restart-only stickiness, and it fits your observed trigger: settings selections (Plan → Default, then reasoning effort) made while a goal was actively driving the bottom pane.
  2. Even on the key path, the settle handler's no_modal_or_popup_active() guard makes clearing dependent on event-loop timing when popups chain (close A → action opens B before A's settle lands). Chained closes normally re-send and recover, but it narrows to the same failure whenever the last close is a non-key dismissal.

Fix shape:

  1. Make the flag self-healing rather than event-driven: clear it at points where its precondition is observably false — e.g. at the top of key handling (or handle_composer_input_result) when no_modal_or_popup_active(), and on turn-lifecycle boundaries. The invariant "suppression never outlives the modal that set it" then can't be violated by any dismissal path, present or future.
  2. Alternatively/additionally, detect non-key dismissals: wherever the bottom pane transitions from modal-active to modal-free (a state observation, not a key event), run the same close hook.
  3. Per your expected-behavior list: when should_submit_now fails for suppression reasons, the queue header could say why ("waiting for settings to apply") — that would have made this diagnosable from the UI instead of requiring an strace to rule out key translation.

Regression test shape: set the flag via defer_input_until_settings_applied, dismiss the modal through a non-key path (replace the bottom-pane view programmatically), then feed an InputResult::Submitted and assert it submits as a steer rather than landing in queued_user_messages.