TUI: Esc cannot interrupt while final-answer text is streaming after status indicator is hidden
What version of Codex CLI is running?
codex-cli 0.145.0
What subscription do you have?
ChatGPT Pro
Which model were you using?
gpt-5.6-sol (high)
What platform is your computer?
Darwin 25.5.0 arm64 arm
What terminal emulator and version are you using (if applicable)?
Ghostty 1.3.1. The focused reproduction is terminal-independent.
Codex doctor report
{
"schemaVersion": 1,
"generatedAt": "1784986044s since unix epoch",
"overallStatus": "warning",
"codexVersion": "0.145.0",
"checks": {
"app_server.status": {
"id": "app_server.status",
"category": "app-server",
"status": "ok",
"summary": "background server is not running",
"details": {
"control socket": "/Users/dongkseo/.codex/app-server-control/app-server-control.sock",
"daemon state dir": "/Users/dongkseo/.codex/app-server-daemon",
"mode": "ephemeral",
"pid file": "/Users/dongkseo/.codex/app-server-daemon/app-server.pid (missing)",
"settings": "/Users/dongkseo/.codex/app-server-daemon/settings.json (missing)",
"status": "not running",
"update-loop pid file": "/Users/dongkseo/.codex/app-server-daemon/app-server-updater.pid (missing)"
},
"remediation": null,
"durationMs": 0
},
"auth.credentials": {
"id": "auth.credentials",
"category": "auth",
"status": "ok",
"summary": "auth is configured",
"details": {
"auth file": "/Users/dongkseo/.codex/auth.json",
"auth storage mode": "File",
"stored API key": "false",
"stored ChatGPT tokens": "true",
"stored agent identity": "false",
"stored auth mode": "chatgpt"
},
"remediation": null,
"durationMs": 0
},
"config.load": {
"id": "config.load",
"category": "config",
"status": "ok",
"summary": "config loaded",
"details": {
"CODEX_HOME": "/Users/dongkseo/.codex",
"config.toml": "/Users/dongkseo/.codex/config.toml",
"config.toml parse": "ok",
"cwd": "/Users/dongkseo/project/contribution/codex",
"enabled feature flags": "shell_tool, unified_exec, shell_snapshot, code_mode_host, terminal_resize_reflow, sqlite, hooks, enable_request_compression, multi_agent, apps, tool_search_always_defer_mcp_tools, tool_suggest, plugins, in_app_browser, browser_use, browser_use_full_cdp_access, browser_use_external, computer_use, remote_plugin, plugin_sharing, image_generation, resize_all_images, skill_mcp_dependency_install, skill_search, mentions_v2, steer, guardian_approval, goals, collaboration_modes, tool_call_mcp_elicitation, auth_elicitation, personality, fast_mode, tui_app_server, remote_compaction_v2, workspace_dependencies",
"feature flag overrides": "none",
"feature flags enabled": "36",
"log dir": "/Users/dongkseo/.codex/log",
"mcp servers": "4",
"model": "gpt-5.6-sol",
"model provider": "openai",
"sqlite home": "/Users/dongkseo/.codex"
},
"remediation": null,
"durationMs": 0
},
"git.environment": {
"id": "git.environment",
"category": "git",
"status": "ok",
"summary": "git version 2.50.1 (Apple Git-155)",
"details": {
".git entry": "directory",
"PATH git #1": "/usr/bin/git",
"PATH git entries": "1",
"git branch": "main",
"git build options": "git version 2.50.1 (Apple Git-155); cpu: arm64; no commit associated with this build; sizeof-long: 8; sizeof-size_t: 8; shell-path: /bin/sh; feature: fsmonitor--daemon; libcurl: 8.7.1; zlib: 1.2.12; SHA-1: SHA1_DC; SHA-256: SHA256_BLK",
"git exec path": "/Library/Developer/CommandLineTools/usr/libexec/git-core",
"git version": "git version 2.50.1 (Apple Git-155)",
"repo detected": "true",
"repo root": "/Users/dongkseo/project/contribution/codex",
"selected git": "/usr/bin/git"
},
"remediation": null,
"durationMs": 88
},
"installation": {
"id": "installation",
"category": "install",
"status": "ok",
"summary": "installation looks consistent",
"details": {
"PATH codex #1": "/opt/homebrew/bin/codex",
"PATH codex #2": "/opt/homebrew/bin/codex",
"PATH codex entries": "2",
"current executable": "/opt/homebrew/bin/codex",
"install context": "brew",
"managed by bun": "false",
"managed by npm": "false",
"managed by pnpm": "false",
"managed package root": "not set"
},
"remediation": null,
"durationMs": 1
},
"mcp.config": {
"id": "mcp.config",
"category": "mcp",
"status": "ok",
"summary": "MCP configuration is locally consistent",
"details": {
"configured servers": "4",
"disabled servers": "0",
"stdio servers": "4"
},
"remediation": null,
"durationMs": 0
},
"network.env": {
"id": "network.env",
"category": "network",
"status": "ok",
"summary": "network-related environment looks readable",
"details": {
"proxy env vars": "none"
},
"remediation": null,
"durationMs": 0
},
"network.provider_reachability": {
"id": "network.provider_reachability",
"category": "reachability",
"status": "ok",
"summary": "active provider endpoints are reachable over HTTP",
"details": {
"ChatGPT base URL": "https://chatgpt.com/backend-api/ reachable (HTTP 403)",
"reachability mode": "ChatGPT auth"
},
"remediation": null,
"durationMs": 133
},
"network.websocket_reachability": {
"id": "network.websocket_reachability",
"category": "websocket",
"status": "ok",
"summary": "Responses WebSocket handshake succeeded",
"details": {
"DNS": "2 IPv4, 0 IPv6, first IPv4",
"auth mode": "chatgpt",
"connect timeout": "15000 ms",
"endpoint": "wss://chatgpt.com/backend-api/<redacted>",
"handshake result": "HTTP 101 Switching Protocols",
"model provider": "openai",
"models etag present": "true",
"provider name": "OpenAI",
"proxy env vars": "none",
"reasoning header": "false",
"server model present": "false",
"supports websockets": "true",
"wire API": "responses"
},
"remediation": null,
"durationMs": 738
},
"runtime.provenance": {
"id": "runtime.provenance",
"category": "runtime",
"status": "ok",
"summary": "running brew on macos-aarch64",
"details": {
"commit": "unknown",
"current executable": "/opt/homebrew/bin/codex",
"install method": "brew",
"platform": "macos-aarch64",
"version": "0.145.0"
},
"remediation": null,
"durationMs": 0
},
"runtime.search": {
"id": "runtime.search",
"category": "search",
"status": "ok",
"summary": "search is OK (system)",
"details": {
"search command": "rg",
"search command readiness": "ripgrep 15.1.0",
"search provider": "system"
},
"remediation": null,
"durationMs": 2
},
"sandbox.helpers": {
"id": "sandbox.helpers",
"category": "sandbox",
"status": "ok",
"summary": "sandbox configuration is readable",
"details": {
"approval policy": "OnRequest",
"codex-linux-sandbox helper": "none",
"execve wrapper helper": "/Users/dongkseo/.codex/tmp/arg0/codex-arg0BmP2Bw/codex-execve-wrapper",
"filesystem sandbox": "restricted",
"network sandbox": "restricted"
},
"remediation": null,
"durationMs": 0
},
"state.paths": {
"id": "state.paths",
"category": "state",
"status": "ok",
"summary": "state paths and databases are inspectable",
"details": {
"CODEX_HOME": "/Users/dongkseo/.codex (dir)",
"active rollout files": "641 files, 724379029 total bytes, 1130076 average bytes",
"archived rollout files": "0 files, 0 total bytes, 0 average bytes",
"goals DB": "/Users/dongkseo/.codex/goals_1.sqlite (file)",
"goals DB integrity": "ok",
"log DB": "/Users/dongkseo/.codex/logs_2.sqlite (file)",
"log DB integrity": "ok",
"log dir": "/Users/dongkseo/.codex/log (dir)",
"memories DB": "/Users/dongkseo/.codex/memories_1.sqlite (file)",
"memories DB integrity": "ok",
"sqlite home": "/Users/dongkseo/.codex (dir)",
"state DB": "/Users/dongkseo/.codex/state_5.sqlite (file)",
"state DB integrity": "ok",
"thread history DB": "/Users/dongkseo/.codex/thread_history_1.sqlite (missing)",
"thread history DB integrity": "skipped (missing)"
},
"remediation": null,
"durationMs": 172
},
"state.rollout_db_parity": {
"id": "state.rollout_db_parity",
"category": "threads",
"status": "warning",
"summary": "rollout files and state DB thread inventory differ",
"details": {
"default model provider": "openai",
"rollout DB active files": "641",
"rollout DB active rows": "639",
"rollout DB archive mismatches": "0",
"rollout DB archived files": "0",
"rollout DB archived rows": "0",
"rollout DB duplicate DB paths": "0",
"rollout DB duplicate rollout thread ids": "0",
"rollout DB malformed file names": "0",
"rollout DB missing active rows": "2",
"rollout DB missing active sample": [
"/Users/dongkseo/.codex/sessions/2026/04/12/rollout-2026-04-12T01-54-28-019d7d77-5f5d-7801-a793-70812622c8e6.jsonl",
"/Users/dongkseo/.codex/sessions/2026/04/12/rollout-2026-04-12T01-54-28-019d7d77-5f4c-73a0-ae2e-ab7ac413ba8b.jsonl"
],
"rollout DB missing archived rows": "0",
"rollout DB model providers": "openai=639",
"rollout DB rows": "639",
"rollout DB scan cap reached": "false",
"rollout DB scan errors": "0",
"rollout DB sources": "cli=349, mcp=215, exec=48, subagent:review=13, vscode=9, subagent:other=5",
"rollout DB stale rows": "0"
},
"issues": [
{
"severity": "warning",
"cause": "rollout files are missing from the state DB",
"measured": "2 active, 0 archived",
"expected": "every rollout file has a matching threads row",
"remedy": null,
"fields": []
}
],
"remediation": null,
"durationMs": 184
},
"system.environment": {
"id": "system.environment",
"category": "system",
"status": "ok",
"summary": "OS language ko-KR",
"details": {
"EDITOR": "not set",
"GH_PAGER": "set",
"GIT_PAGER": "set",
"LANG": "C.UTF-8",
"LC_ALL": "C.UTF-8",
"LC_CTYPE": "C.UTF-8",
"LESS": "set",
"PAGER": "set",
"VISUAL": "not set",
"os": "Mac OS 26.5.2 [64-bit]",
"os language": "ko-KR",
"os type": "Mac OS",
"os version": "26.5.2"
},
"remediation": null,
"durationMs": 2
},
"terminal.env": {
"id": "terminal.env",
"category": "terminal",
"status": "ok",
"summary": "terminal metadata was detected",
"details": {
"COLORTERM": "present",
"NO_COLOR": "1",
"TERMINFO": "/Applications/Ghostty.app/Contents/Resources/terminfo (dir)",
"TERM_PROGRAM": "ghostty",
"color output": "disabled (NO_COLOR)",
"effective locale": "C.UTF-8",
"stderr is terminal": "false",
"stdin is terminal": "false",
"stdout is terminal": "false",
"terminal": "Ghostty",
"terminal size": "80x24",
"terminal version": "1.3.1"
},
"remediation": null,
"durationMs": 3
},
"terminal.title": {
"id": "terminal.title",
"category": "title",
"status": "ok",
"summary": "terminal title default",
"details": {
"terminal title activity": "true",
"terminal title items": "activity, project-name",
"terminal title project source": "git repo root",
"terminal title project value": "codex",
"terminal title source": "default"
},
"remediation": null,
"durationMs": 0
},
"updates.status": {
"id": "updates.status",
"category": "updates",
"status": "ok",
"summary": "update configuration is locally consistent",
"details": {
"cached latest version": "0.145.0",
"check for update on startup": "true",
"last checked at": "2026-07-25T08:25:03.663231Z",
"latest version": "0.145.0",
"latest version status": "current version is not older",
"update action": "brew upgrade --cask codex",
"version cache": "/Users/dongkseo/.codex/version.json"
},
"remediation": null,
"durationMs": 31
}
}
}
What issue are you seeing?
In the TUI, bare Esc stops interrupting a running turn after final-answer assistant text is committed to the transcript.
The task remains running and the TUI remains responsive, but pressing Esc emits no Op::Interrupt. Ctrl+C still interrupts the same turn.
This specifically affects assistant text streaming after a commit tick. It does not require a tool call, queued steer, stalled event loop, or terminal input failure.
What steps can reproduce the bug?
The issue is deterministic in a focused codex-tui test:
#[tokio::test]
async fn esc_interrupts_while_final_answer_stream_is_active() {
let (mut chat, mut rx, mut op_rx) =
make_chatwidget_manual(/*model_override*/ None).await;
chat.thread_id = Some(ThreadId::new());
chat.on_task_started();
chat.on_agent_message_delta("Final answer line\n".to_string());
chat.on_commit_tick();
drain_insert_history(&mut rx);
assert!(chat.bottom_pane.is_task_running());
assert!(!chat.bottom_pane.status_indicator_visible());
chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert_matches!(op_rx.try_recv(), Ok(Op::Interrupt));
}
Run:
just test -p codex-tui esc_interrupts_while_final_answer_stream_is_active
Actual result:
Starting 1 test across 6 binaries (3249 tests skipped)
FAIL codex-tui chatwidget::tests::status_and_layout::esc_interrupts_while_final_answer_stream_is_active
assertion failed:
Err(Empty) does not match Ok(Op::Interrupt)
The test failed on both nextest attempts.
Control cases pass:
output_free_esc_interrupt_keeps_prompt_and_opens_blank_composer:EscemitsOp::Interruptwhile the status indicator is visible.streaming_final_answer_keeps_task_running_state: after text streaming hides the status indicator,Ctrl+CemitsOp::Interrupt.
What is the expected behavior?
Bare Esc should interrupt any interruptible running agent turn regardless of whether the transient status indicator is currently rendered.
Hiding the status row while assistant text is streaming should only change presentation; it should not disable the interrupt shortcut.
Additional information
The failure was also reproduced against current source main at 4c4346513342 (codex-zsh-v0.1.0-745-g4c43465133).
The failure is caused by coupling interrupt eligibility to status-row visibility:
tui/src/chatwidget/streaming.rsintentionally callshide_status_indicator()when streamed assistant output is committed.tui/src/bottom_pane/mod.rs::should_interrupt_running_task()requires bothis_task_runningandself.status.is_some().- After the text commit,
is_task_running == truebutstatus == None, so the Esc path is rejected before it can emitOp::Interrupt.
Relevant predicate:
self.keymap.chat.interrupt_turn.is_pressed(key_event)
&& self.is_task_running
// ...
&& self.status.is_some()
Related but not duplicate:
- #17095 concerns pressing Esc to submit a queued steer during a long-running tool call. This report requires neither a tool call nor queued input and reproduces specifically after final-answer text commit.
- #21198 concerns a missing Working indicator after compaction and does not report deterministic Esc interrupt failure.
- #29368 concerns a fully frozen event loop where rendering and all keyboard input stop. Here the TUI remains responsive and only the Esc interrupt route is rejected.
No repository changes are required to reproduce this other than adding the focused regression test above.
2 Comments
Thank you for the excellent, well-structured bug report. I've done a deep dive into the code and can confirm the root cause, trace its origin, and propose a concrete fix.
Root cause confirmed
The
status.is_some()guard inshould_interrupt_running_task(codex-rs/tui/src/bottom_pane/mod.rs:1353) was introduced in commit07298a948c(Eric Traut, 2026-06-18) as part of "Pause active goals before TUI interrupts (#28813)". The original inline code already had&& let Some(status) = &self.statusat the callsite (line 662) to guard thestatus.interrupt()call. When the logic was extracted into a named method,status.is_some()was added inside the method as well—creating a redundant double-check.The problem is that
hide_status_indicator()is called during active streaming (streaming.rs:47,391,505,534) to replace the status row with committed transcript content. This setsself.status = Nonewhileself.is_task_runningremainstrue. After that point:should_interrupt_running_task()→status.is_some()isfalse→ returnsfalse→ interrupt never senton_ctrl_c()→submit_op(AppCommand::interrupt())(interaction.rs:397,415) → sends interrupt directly, bypassing the status widget entirelyThis asymmetry is the bug. Both keys should interrupt; only Ctrl+C does.
Why
status.is_some()was addedThe
StatusIndicatorWidget::interrupt()method (status_indicator_widget.rs:103-105) callsself.app_event_tx.interrupt(). The guard was intended to ensure the status widget exists before calling it. However,BottomPanealready holdsapp_event_txdirectly (mod.rs:227), so the interrupt can be sent without the status widget.Proposed fix
Option A (minimal, recommended): Remove
status.is_some()fromshould_interrupt_running_taskand handle theNonecase at the callsite:Option B (alternative): Restore the status indicator before interrupting. This is more invasive and changes the visual behavior.
Regression test
The existing test
streaming_final_answer_keeps_task_running_state(status_and_layout.rs:1933) already demonstrates the state (is_task_running = true,status_indicator_visible = false) and proves Ctrl+C works. The missing test is the Esc variant:Scope
The change is ~5 lines across one file (
bottom_pane/mod.rs). Theinteraction.rscall site at line 181 usesshould_interrupt_running_taskonly for theshould_pause_active_goalpre-check, which is correct behavior (pause the goal if we're about to interrupt).---
Happy to submit a PR if invited, or the team can apply the fix directly.
Analysis (community)
Residual analysis for #35348 (TUI: Esc cannot interrupt while final-answer text is streaming after the status indicator is hidden) under docs/contributing.md — invitation-only. No unsolicited PR.
Root-cause hypothesis
Esc→interrupt is gated not only on
is_task_runningbut also onstatus.is_some(). During final-answer streaming the status view can be cleared while the turn/task is still running, so Esc falls through to composer/history handling instead ofinterrupt_turn.Verified at HEAD
4c43465133:1. Load-bearing gate.
codex-rs/tui/src/bottom_pane/mod.rsshould_interrupt_running_task:So all of the following must hold: interrupt key mapping, task running, not the special
/agent+Esc case, no modal, not vim-insert Esc, and status present.2. Why the issue title matches this pin.
Earlier readouts that blamed only
is_task_running == falseare incomplete. The stronger residual is: task still running + status already None during late stream / commit windows. That is exactly “Esc does nothing useful after status hides while text is still streaming.”3. Layering (for maintainers navigating the code).
Interrupt delivery also interacts with
InterruptManager/ deferred flush during write cycles inchatwidget— write-cycle deferral can delay delivery even when the gate is true. The gate itself is still the primary, testable OSS bug for the status-hidden case; deferral is secondary latency.4. Fully OSS TUI residual — no Desktop-only dependency for the Esc path above.
Ranked fix outline (all OSS):
status.is_some()whenis_task_runningis true (status UI should not own interrupt capability).interrupt_armed/stream_activeflag independent of the status widget lifetime.app_event_tx.interrupt()for the final-answer stream window covered by this issue.status = None+ Esc → interrupt requested.Fail-first tests: (1)
is_task_running=true,status=None, Esc →should_interrupt_running_tasktrue after fix; (2) modal open → still false; (3) vim insert Esc exception preserved; (4)/agent+ Esc exception preserved.Happy to help land the gate change + unit test. No unsolicited PR.