TUI: Esc cannot interrupt while final-answer text is streaming after status indicator is hidden

Open 💬 2 comments Opened Jul 25, 2026 by donggyun112

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: Esc emits Op::Interrupt while the status indicator is visible.
  • streaming_final_answer_keeps_task_running_state: after text streaming hides the status indicator, Ctrl+C emits Op::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.rs intentionally calls hide_status_indicator() when streamed assistant output is committed.
  • tui/src/bottom_pane/mod.rs::should_interrupt_running_task() requires both is_task_running and self.status.is_some().
  • After the text commit, is_task_running == true but status == None, so the Esc path is rejected before it can emit Op::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.

View original on GitHub ↗

2 Comments

afjal1 · 1 month ago

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 in should_interrupt_running_task (codex-rs/tui/src/bottom_pane/mod.rs:1353) was introduced in commit 07298a948c (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.status at the callsite (line 662) to guard the status.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 sets self.status = None while self.is_task_running remains true. After that point:

  • Esc: goes through should_interrupt_running_task()status.is_some() is false → returns false → interrupt never sent
  • Ctrl+C: goes through on_ctrl_c()submit_op(AppCommand::interrupt()) (interaction.rs:397,415) → sends interrupt directly, bypassing the status widget entirely

This asymmetry is the bug. Both keys should interrupt; only Ctrl+C does.

Why status.is_some() was added

The StatusIndicatorWidget::interrupt() method (status_indicator_widget.rs:103-105) calls self.app_event_tx.interrupt(). The guard was intended to ensure the status widget exists before calling it. However, BottomPane already holds app_event_tx directly (mod.rs:227), so the interrupt can be sent without the status widget.

Proposed fix

Option A (minimal, recommended): Remove status.is_some() from should_interrupt_running_task and handle the None case at the callsite:

// mod.rs:1340-1354 — remove the last line
pub(crate) fn should_interrupt_running_task(&self, key_event: KeyEvent) -> bool {
    let is_agent_command = self
        .composer_text()
        .lines()
        .next()
        .and_then(parse_slash_name)
        .is_some_and(|(name, _, _)| name == "agent");

    self.keymap.chat.interrupt_turn.is_pressed(key_event)
        && self.is_task_running
        && !(is_agent_command && key_event.code == KeyCode::Esc)
        && self.no_modal_or_popup_active()
        && !self.composer_should_handle_vim_insert_escape(key_event)
        // Removed: && self.status.is_some()
}

// mod.rs:661-668 — use app_event_tx directly when status is None
if self.should_interrupt_running_task(key_event) {
    if let Some(status) = &self.status {
        status.interrupt();
    } else {
        self.app_event_tx.interrupt();
    }
    self.request_redraw();
    return InputResult::None;
}

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:

#[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));
}

Scope

The change is ~5 lines across one file (bottom_pane/mod.rs). The interaction.rs call site at line 181 uses should_interrupt_running_task only for the should_pause_active_goal pre-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.

bbingz · 1 month ago

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_running but also on status.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 of interrupt_turn.

Verified at HEAD 4c43465133:

1. Load-bearing gate.
codex-rs/tui/src/bottom_pane/mod.rs should_interrupt_running_task:

self.keymap.chat.interrupt_turn.is_pressed(key_event)
  && self.is_task_running
  && !(is_agent_command && key_event.code == KeyCode::Esc)
  && self.no_modal_or_popup_active()
  && !self.composer_should_handle_vim_insert_escape(key_event)
  && self.status.is_some()   // ← key residual

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 == false are 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 in chatwidget — 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):

  1. Drop or relax status.is_some() when is_task_running is true (status UI should not own interrupt capability).
  2. Or: keep a sticky interrupt_armed / stream_active flag independent of the status widget lifetime.
  3. Ensure Esc still routes to app_event_tx.interrupt() for the final-answer stream window covered by this issue.
  4. Regression test: simulate task running + status = None + Esc → interrupt requested.

Fail-first tests: (1) is_task_running=true, status=None, Esc → should_interrupt_running_task true 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.