CLI /apps picker remains on “Loading apps…” after app/list returns an error
Nobody has claimed this yet.
- Dominant language
- Rust
- Stars
- 125k
- Forks
- 19.4k
- PR merge metrics
- PR metrics pending
Description
What version of Codex CLI is running?
codex-cli 0.153.2
I also reproduced the state-machine defect in source at current main commit 0ae02915bd1bfdd8207631f65aa31987368c7c8b (2026-09-04).
What subscription do you have?
Pro
Which model were you using?
N/A. /apps fails before a model turn starts.
What platform is your computer?
Darwin 25.6.0 x86_64 i386 (macOS 26.6.2, build 25G83)
What terminal emulator and version are you using (if applicable)?
Ghostty 1.3.1. No terminal multiplexer.
Codex doctor report
Privacy-reviewed summary from codex doctor --json:
{
"schemaVersion": 1,
"overallStatus": "ok",
"codexVersion": "0.153.2",
"checks": {
"auth.credentials": {
"status": "ok",
"summary": "auth is configured",
"authMode": "chatgpt"
},
"config.load": {
"status": "ok",
"summary": "config loaded",
"appsFeatureEnabled": true
},
"installation": {
"status": "ok",
"summary": "installation looks consistent",
"installMethod": "npm",
"platform": "macos-x86_64"
},
"network.env": {
"status": "ok",
"summary": "network-related environment looks readable",
"proxy": "direct"
},
"network.provider_reachability": {
"status": "ok",
"summary": "active provider endpoints are reachable over HTTP"
},
"network.websocket_reachability": {
"status": "ok",
"summary": "Responses WebSocket handshake succeeded"
},
"state.paths": {
"status": "ok",
"summary": "state paths and databases are inspectable"
},
"terminal.env": {
"status": "ok",
"summary": "terminal metadata was detected",
"terminal": "Ghostty 1.3.1"
},
"updates.status": {
"status": "ok",
"summary": "current version is not older",
"latestVersion": "0.153.2"
}
}
}
The summary omits local paths, state inventory counts, and other unrelated machine metadata. I can provide the complete redacted report if it is useful.
What issue are you seeing?
When the initial app/list request fails, the /apps picker remains permanently in its loading view:
Apps
Loading installed and available apps...
› Loading apps... This updates when the full list is ready.
The picker does not show the terminal error and does not offer a retry action. Esc can close it, but the UI gives no indication that loading has already failed.
In my production trace, the directory request intermittently returned HTTP 403 with an HTML challenge page. That backend response is a trigger, but it is not required for the UI bug. Any final app/list error with no usable cache or partial snapshot produces the stale loading view.
The failure is not a general login or connectivity failure:
codex doctor --jsonreportsoverallStatus: ok, including ChatGPT auth, HTTP reachability, and the Responses WebSocket handshake.app/installedsucceeds and reports the installed apps.app/readsucceeds for a directory app and returns its browser installation URL.- Concurrent
app/listrequests can differ: one returned-32603with a 403 challenge response while another succeeded with the complete directory.
In the final measured run, two concurrent forced app/list requests produced different results. One returned error -32603 with a 10,890-byte 403 challenge error. The other emitted progressive app/list/updated snapshots with 3,441 and 3,449 entries, then returned all 3,449 entries successfully. This makes the transport failure look intermittent, but the TUI's incorrect terminal state is deterministic once its final request returns Err without a usable cache snapshot.
What steps can reproduce the bug?
Interactive reproduction:
- Sign in to Codex CLI with ChatGPT auth.
- Start a fresh Codex TUI session with no ready app-directory cache.
- Enter
/apps. - Cause the initial
app/listcall to return an error. An upstream 403 response reproduces it, but a mocked error is sufficient. - Observe the picker after the final response arrives.
Deterministic source-level reproduction at rust-v0.153.2 and current main:
chat.add_connectors_output();
assert!(render_bottom_popup(&chat, 80).contains("Loading apps..."));
chat.on_connectors_loaded(
Err("app/list failed: 403 Forbidden".to_string()),
true,
);
let popup = render_bottom_popup(&chat, 80);
assert!(!popup.contains("Loading apps...")); // fails on clean source
assert!(popup.contains("Failed to load apps.")); // fails on clean source
I added this as a local regression test named apps_popup_replaces_loading_state_after_initial_refresh_failure. It fails on clean source because the loading popup remains visible. It passes after the proof fix described below.
What is the expected behavior?
After a final load error, the active picker must leave the loading state. It should show a bounded, actionable error and allow retry or dismissal. For example:
Apps
Failed to load apps.
App directory unavailable The app directory request failed. Retry, or
press Esc to continue.
› 1. Retry Reload installed and available apps.
Press enter to confirm or esc to go back
If app/list succeeds, the existing app selection flow can continue to the browser installation/sign-in URL. This issue does not require an OAuth form inside the TUI.
Additional information
Root cause
The initial picker is created as a selection view with ID CONNECTORS_SELECTION_VIEW_ID in connectors_loading_popup_params.
On a final error, on_connectors_loaded retains a ready cache or falls back to a partial snapshot when either exists. In the remaining branch, it only assigns ConnectorsCacheState::Failed(err) at line 438. It does not replace or dismiss the active loading selection view. The already-rendered picker therefore has no transition out of Loading.
An app/list/updated notification does not necessarily avoid the bug. While a prefetch is active, refresh_connector_directory_after_notification stores the notification as pending_notification; it does not populate partial_snapshot. A subsequent final Err can still enter the no-cache/no-partial branch.
The plugin picker already implements the expected pattern. Its error branch calls replace_selection_view_if_active with an error view.
Proof fix
My local proof fix does two things:
- Add a
connectors_error_popup_params()view with a generic error,Retry, and the standard escape hint. - In the no-cache/no-partial error branch, replace the active
CONNECTORS_SELECTION_VIEW_IDview before storingConnectorsCacheState::Failed(err).
The Retry action sends AppEvent::RefreshConnectors { force_refetch: true }.
Verification:
- The regression test fails on the unmodified implementation and passes after the proof fix.
- The test verifies the loading-to-error transition and the retry event.
- All 21 tests selected by the
apps_filter pass. One thread-scoped MCP test needed a host rerun because the restricted test sandbox denied a local operation; it passed on the host. just fmtpasses.git diff --checkpasses.- The complete
codex-tuisuite ran 4,233 tests. The app-related tests passed. Ten unrelated timeout/color-environment failures passed when rerun individually. Two pre-existing locale-sensitive assertions still expect the US thousands separator8,000, while this en-CH system correctly renders8'000.
Secondary diagnostic hardening
The 403 response exposed a separate error-amplification problem. chatgpt_get_request_with_timeout and the POST equivalent embed the complete response body in the error. HTML challenge pages can therefore propagate large, noisy, and potentially sensitive response bodies through app-server and TUI diagnostics.
I suggest truncating and sanitizing non-success response bodies at this shared boundary. The user-facing picker should use a generic error. Full diagnostic detail should remain bounded and should not include challenge tokens.
Related reports and duplicate analysis
- #10290 reports an app-list 403 response.
- #19284 reports intermittent
codex_appsCloudflare 403 responses in long-lived CLI sessions. - #21899 reports Cloudflare challenges on app/plugin discovery endpoints and asks for graceful handling.
- #19070 reports connector discovery 403 responses on macOS.
Those reports describe backend or edge failures that can trigger this defect. This report is narrower and not a duplicate: it identifies a deterministic TUI state-transition bug for every terminal app/list error, provides the exact source branch, points to an existing analogous implementation, and includes a failing regression test plus a verified proof fix.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in codex-rs/tui/src/chatwidget/connectors.rs at connectors_loading_popup_params and on_connectors_loaded, then compare the error handling in codex-rs/tui/src/chatwidget/plugins.rs. Use the apps_popup_replaces_loading_state_after_initial_refresh_failure regression test as the starting point; done means a final load error replaces the loading view with an actionable error and Retry behavior, with the apps_ tests passing.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- rust
- Domain
- cli, testing-qa
- Issue type
- Bug
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 85/100