feat(tui): make settings MCP recovery first-class and clickable (#5655)

* feat(tui): make settings MCP recovery first-class and clickable

Settings Advanced now exposes Connect/Reconnect/Diagnose and plugin
rows instead of a path-only MCP section, and the category tabs are
mouse-clickable. The extensions MCP tab, plugin Problems group, and
/mcp manager name recovery commands that exist (/mcp login, /mcp
reload, /mcp validate, /plugin validate). Stale OAuth copy no longer
points at the non-existent /mcp auth. OpenRouter Hunyuan aliases
(hy3, hunyuan) resolve to tencent/hy3-preview; hy4 is not added.

Signed-off-by: CodeWhale Bot <[email protected]>

* docs: add TUI settings/MCP recovery lane handoff

Record the isolated worktree SHA, journeys, tests actually run, PR #5643
overlap, and what remains unverified.

Signed-off-by: CodeWhale Bot <[email protected]>

* test(tui): bump Extensions locale census for MCP recovery actions

Connect/Reconnect/Re-auth/Diagnose add four ExtensionsAction keys.
The complete-set census is 86, not 82.

Signed-off-by: CodeWhale Bot <[email protected]>

---------

Signed-off-by: CodeWhale Bot <[email protected]>
Co-authored-by: CodeWhale Bot <[email protected]>
This commit is contained in:
Hunter BownandCodeWhale Bot authored and GitHub committed 2026-08-27 13:12:26 -07:00
1 parent de63df50e2
commit 71253d5656
28 files changed
+855 -37

No files matched your search

+124
View File
@@ -0,0 +1,124 @@
# TUI settings / MCP recovery lane — 2026-08-27
## Branch and SHA
- Worktree: `/Volumes/VIXinSSD/CW/worktrees/cw-v0912-tui-settings-mcp-20260827`
- Branch: `grok/v0912-tui-settings-mcp-20260827`
- Base: freshly fetched `origin/main` at `a96ea6cb09cc464ea2e88f251c538c239d1fe9ad`
- Product commit: `8e15e51d43433f5e2e924611aed21ba4fa5b6709`
`feat(tui): make settings MCP recovery first-class and clickable`
No push, PR, merge, release, or deploy was performed. The machine `codewhale`
binary was not installed or overwritten. `/Volumes/VIXinSSD/CW/cwc` was not
edited. Occupied worktrees were not reused.
`docs/CURRENT.md` is not present on this `origin/main`. `AGENTS.md` and
`docs/SETTINGS_PICKER_FRAMEWORK.md` / `docs/MCP.md` were the live contracts.
## PR #5643 overlap
Open PR #5643 (`codex/v0912-tui-polish-20260827`, `92ef9aa4d`) already contains
the MCP login copy fix in `streamable_http.rs` **and** the welcome-shine timing
fix. It is **not** in `origin/main`. This lane built on `origin/main` and did
**not** copy the welcome-shine / composer / launch-menu work.
The `/mcp auth` recovery sentence still exists on current `main`. This slice
fixes that string (required: recovery copy must name commands that exist) and
then **deepens settings/plugin management** rather than duplicating 5643's
animation work.
If both land, the `streamable_http.rs` hint change will overlap with 5643 and
should be a trivial conflict: both replace `/mcp auth` with `/mcp login`.
## User journeys that changed
1. **Settings (`F2` / `/settings`) is clickable.** Category tabs
(General / Models / Permissions / Display / Advanced) have mouse hitboxes.
One click switches the tab. Rows keep the existing select-then-activate
pattern.
2. **Settings → Advanced → MCP is no longer a path-only wall.** Calm action
rows, each with an obvious next command:
- MCP manager → `/mcp`
- Reconnect MCP → `/mcp reload`
- Diagnose MCP → `/mcp validate`
- Plugins → `/plugin`
Enter (or the second click on a selected action row) runs that command.
3. **Extensions → MCP recovery is first-class.** Each server row's action is
Connect / Reconnect / Re-auth / Diagnose / Enable, mapped to real commands:
- disabled → `/mcp enable <name>`
- not inspected → `/mcp reload` (Connect)
- disconnected → `/mcp reload` (Reconnect)
- 401 / unauthorized / oauth-capable stale session → `/mcp login <name>`
- other errors or healthy connected → `/mcp validate` (Diagnose)
There is no `/mcp auth`.
4. **Plugin invalid manifests and duplicates are diagnosable.** Registry
diagnostics (including `manifest-invalid`, `duplicate-root`, `name-conflict`)
appear in a Problems group. Error-bearing plugins run `/plugin validate
<name>` instead of a mute Inspect.
5. **`/mcp` manager pager names a next action per server** (`next: Re-auth
/mcp login github`) instead of a command-prose wall. Footer is
`Next: Connect /mcp reload · Diagnose /mcp validate`.
6. **Stale Streamable HTTP OAuth recovery** now says `/mcp login <name>`
(CLI remains `codewhale mcp login`). Bearer-only sessions still point at
the configured token, not a login command.
7. **Hunyuan / Hy in model selection.** There is no native Tencent Hunyuan
`ApiProvider` and no models.dev bundled row. The public OpenRouter model
`tencent/hy3-preview` already exists. Search/canonical aliases now include
`hy3`, `hunyuan`, `tencent-hunyuan`, `hunyuan-hy3`. **hy4 is not invented.**
## Files changed
- `crates/tui/src/tui/views/mod.rs` — clickable tabs; MCP/plugin action rows
- `crates/tui/src/tui/views/extensions.rs` — MCP/plugin recovery actions + Problems
- `crates/tui/src/tui/mcp_routing.rs` — per-server next actions in `/mcp` pager
- `crates/tui/src/mcp.rs` — `McpRecoveryKind` + `mcp_recovery_kind`
- `crates/tui/src/mcp/oauth.rs` — `tui_reauth_hint` / text classifier
- `crates/tui/src/mcp/streamable_http.rs` — `/mcp login` recovery copy
- `crates/tui/src/localization.rs` + 15 locale packs
- `crates/tui/src/config.rs`, `crates/config/src/lib.rs`, `crates/agent/src/lib.rs`
— Hunyuan aliases for hy3 only
- tests next to those modules
## Tests actually run
Isolated build-dir via `scripts/dev-cache.sh`. Command:
```
cargo nextest run -p codewhale-tui --lib --locked -E 'test(/mcp_recovery_kind|tui_reauth_hints|manager_text_names_login|config_view_tabs_are_clickable|config_view_mcp_action_rows|openrouter_hunyuan|mcp_item_action_for|message_id_list_english|shipped_complete_packs_have_raw|settings_registry_types_every|config_view_includes_expected_editable|config_view_can_edit_filtered|config_view_mouse_click_selects|auth_required_login_hint_names|manager_text_shows_failed/)'
```
Result: **16 passed**, 11198 skipped. Also `cargo fmt --all -- --check` and
`git diff --check`.
Not run: full `codewhale-tui --lib` suite, clippy `-D warnings`, live TUI/PTY
at multiple widths, live MCP OAuth, live Hunyuan/OpenRouter calls (no provider
money spent).
## What remains
- Welcome shine vs launch-menu regression is still on `main`; 5643 owns that
fix. This lane did not duplicate it.
- `/mcp` manager is still a pager, not a clickable modal. Recovery is named
on each row; clicking the pager text does not run the command.
- Settings action rows still use select-then-activate (same as provider/model),
not single-click-to-run. Tabs are single-click.
- No native Hunyuan provider adapter. If Tencent ships a public API distinct
from OpenRouter `tencent/hy3-preview`, that is a later provider lane. Do not
add hy4 until a public id exists.
- Plugin Problems rows always run `/plugin validate` (global); they do not yet
deep-link a specific diagnostic path into a dedicated inspector.
- Locale packs have the new keys; only English was used in the focused tests
besides the completeness parity gate.
## Safest next integration step
Review `8e15e51d4` on this branch. Do not merge over 5643's welcome-shine
files. If 5643 merges first, rebase this branch; expect a small
`streamable_http.rs` overlap on the login hint only. Then PTY-check Settings
tabs and an MCP 401 row at ~80 and ~40 columns.
+8 -1
View File
@@ -575,7 +575,14 @@ impl Default for ModelRegistry {
ModelInfo {
id: "tencent/hy3-preview".to_string(),
provider: ProviderKind::Openrouter,
aliases: vec!["hy3-preview".to_string(), "tencent-hy3-preview".to_string()],
aliases: vec![
"hy3-preview".to_string(),
"tencent-hy3-preview".to_string(),
"hy3".to_string(),
"hunyuan".to_string(),
"tencent-hunyuan".to_string(),
"hunyuan-hy3".to_string(),
],
supports_tools: true,
supports_reasoning: true,
},
+7 -3
View File
@@ -4442,9 +4442,13 @@ fn canonical_openrouter_recent_model_id(model: &str) -> Option<&'static str> {
OPENROUTER_QWEN_3_8_FLASH_MODEL | "qwen3.8-flash" | "qwen-3.8-flash" => {
Some(OPENROUTER_QWEN_3_8_FLASH_MODEL)
}
OPENROUTER_TENCENT_HY3_PREVIEW_MODEL | "hy3-preview" | "tencent-hy3-preview" => {
Some(OPENROUTER_TENCENT_HY3_PREVIEW_MODEL)
}
OPENROUTER_TENCENT_HY3_PREVIEW_MODEL
| "hy3-preview"
| "tencent-hy3-preview"
| "hy3"
| "hunyuan"
| "tencent-hunyuan"
| "hunyuan-hy3" => Some(OPENROUTER_TENCENT_HY3_PREVIEW_MODEL),
OPENROUTER_XIAOMI_MIMO_V2_5_PRO_MODEL
| "mimo-v2.5-pro"
| "mimo-v2-5-pro"
+12
View File
@@ -2,6 +2,10 @@
"ExtensionsActionAdd": "afegeix",
"ExtensionsActionEnable": "activa",
"ExtensionsActionReload": "torna a carregar",
"ExtensionsActionConnect": "connecta",
"ExtensionsActionReconnect": "torna a connectar",
"ExtensionsActionReauth": "reautentica",
"ExtensionsActionDiagnose": "diagnostica",
"ExtensionsActionFocus": "focus",
"ExtensionsActionFold": "plega",
"ExtensionsActionTabs": "pestanyes",
@@ -291,6 +295,10 @@
"ConfigLabelAutoCompactThreshold": "Llindar de compactació",
"ConfigLabelMaxHistory": "Historial d'entrada",
"ConfigLabelMcpConfigPath": "Ruta de configuració MCP",
"ConfigLabelMcpOpen": "Gestor MCP",
"ConfigLabelMcpReconnect": "Torna a connectar MCP",
"ConfigLabelMcpDiagnose": "Diagnostica MCP",
"ConfigLabelPluginsOpen": "Extensions",
"ConfigLabelFleetSpawnDepth": "Profunditat recursiva de Fleet",
"ConfigLabelGoalCommand": "Comanda d'objectiu",
"ConfigLabelWorkflow": "Flux de treball",
@@ -300,6 +308,10 @@
"ConfigColumnScope": "Abast",
"ConfigActionOpenProvider": "Enter obre el selector de proveïdor",
"ConfigActionOpenModel": "Enter obre el selector de model",
"ConfigActionOpenMcp": "Enter obre el gestor MCP",
"ConfigActionMcpReconnect": "Enter torna a connectar MCP",
"ConfigActionMcpDiagnose": "Enter diagnostica MCP",
"ConfigActionOpenPlugins": "Enter obre les extensions",
"ConfigActionToggle": "Enter/Space commuta",
"ConfigActionChoose": "Enter obre les opcions",
"ConfigActionEdit": "Enter edita",
+12
View File
@@ -2,6 +2,10 @@
"ExtensionsActionAdd": "hinzufügen",
"ExtensionsActionEnable": "aktivieren",
"ExtensionsActionReload": "neu laden",
"ExtensionsActionConnect": "verbinden",
"ExtensionsActionReconnect": "neu verbinden",
"ExtensionsActionReauth": "neu anmelden",
"ExtensionsActionDiagnose": "diagnostizieren",
"ExtensionsActionFocus": "Fokus",
"ExtensionsActionFold": "einklappen",
"ExtensionsActionTabs": "Tabs",
@@ -291,6 +295,10 @@
"ConfigLabelAutoCompactThreshold": "Komprimierungs-Schwelle",
"ConfigLabelMaxHistory": "Eingabeverlauf",
"ConfigLabelMcpConfigPath": "MCP-Konfigpfad",
"ConfigLabelMcpOpen": "MCP-Manager",
"ConfigLabelMcpReconnect": "MCP neu verbinden",
"ConfigLabelMcpDiagnose": "MCP diagnostizieren",
"ConfigLabelPluginsOpen": "Plugins",
"ConfigLabelFleetSpawnDepth": "Fleet-Rekursionstiefe",
"ConfigLabelGoalCommand": "Goal-Befehl",
"ConfigLabelWorkflow": "Workflow",
@@ -300,6 +308,10 @@
"ConfigColumnScope": "Bereich",
"ConfigActionOpenProvider": "Enter öffnet Provider-Auswahl",
"ConfigActionOpenModel": "Enter öffnet Modellauswahl",
"ConfigActionOpenMcp": "Enter öffnet den MCP-Manager",
"ConfigActionMcpReconnect": "Enter verbindet MCP neu",
"ConfigActionMcpDiagnose": "Enter diagnostiziert MCP",
"ConfigActionOpenPlugins": "Enter öffnet Plugins",
"ConfigActionToggle": "Enter/Space schaltet um",
"ConfigActionChoose": "Enter öffnet Auswahl",
"ConfigActionEdit": "Enter bearbeitet",
+12
View File
@@ -2,6 +2,10 @@
"ExtensionsActionAdd": "add",
"ExtensionsActionEnable": "enable",
"ExtensionsActionReload": "reload",
"ExtensionsActionConnect": "connect",
"ExtensionsActionReconnect": "reconnect",
"ExtensionsActionReauth": "re-auth",
"ExtensionsActionDiagnose": "diagnose",
"ExtensionsActionFocus": "focus",
"ExtensionsActionFold": "fold",
"ExtensionsActionTabs": "tabs",
@@ -293,6 +297,10 @@
"ConfigLabelAutoCompactThreshold": "Compact threshold",
"ConfigLabelMaxHistory": "Input history",
"ConfigLabelMcpConfigPath": "MCP config path",
"ConfigLabelMcpOpen": "MCP manager",
"ConfigLabelMcpReconnect": "Reconnect MCP",
"ConfigLabelMcpDiagnose": "Diagnose MCP",
"ConfigLabelPluginsOpen": "Plugins",
"ConfigLabelFleetSpawnDepth": "Fleet recursion depth",
"ConfigLabelGoalCommand": "Goal command",
"ConfigLabelWorkflow": "Workflow",
@@ -302,6 +310,10 @@
"ConfigColumnScope": "Scope",
"ConfigActionOpenProvider": "Enter opens provider picker",
"ConfigActionOpenModel": "Enter opens model picker",
"ConfigActionOpenMcp": "Enter opens MCP manager",
"ConfigActionMcpReconnect": "Enter reconnects MCP",
"ConfigActionMcpDiagnose": "Enter diagnoses MCP",
"ConfigActionOpenPlugins": "Enter opens plugins",
"ConfigActionToggle": "Enter/Space toggles",
"ConfigActionChoose": "Enter opens choices",
"ConfigActionEdit": "Enter edits",
+12
View File
@@ -2,6 +2,10 @@
"ExtensionsActionAdd": "agregar",
"ExtensionsActionEnable": "habilitar",
"ExtensionsActionReload": "recargar",
"ExtensionsActionConnect": "conectar",
"ExtensionsActionReconnect": "reconectar",
"ExtensionsActionReauth": "reautenticar",
"ExtensionsActionDiagnose": "diagnosticar",
"ExtensionsActionFocus": "foco",
"ExtensionsActionFold": "plegar",
"ExtensionsActionTabs": "pestañas",
@@ -293,6 +297,10 @@
"ConfigLabelAutoCompactThreshold": "Umbral de compactación",
"ConfigLabelMaxHistory": "Historial de entrada",
"ConfigLabelMcpConfigPath": "Ruta de configuración MCP",
"ConfigLabelMcpOpen": "Administrador MCP",
"ConfigLabelMcpReconnect": "Reconectar MCP",
"ConfigLabelMcpDiagnose": "Diagnosticar MCP",
"ConfigLabelPluginsOpen": "Complementos",
"ConfigLabelFleetSpawnDepth": "Profundidad recursiva de Fleet",
"ConfigLabelGoalCommand": "Comando de objetivo",
"ConfigLabelWorkflow": "Flujo de trabajo",
@@ -302,6 +310,10 @@
"ConfigColumnScope": "Alcance",
"ConfigActionOpenProvider": "Enter abre el selector de proveedor",
"ConfigActionOpenModel": "Enter abre el selector de modelo",
"ConfigActionOpenMcp": "Enter abre el administrador MCP",
"ConfigActionMcpReconnect": "Enter reconecta MCP",
"ConfigActionMcpDiagnose": "Enter diagnostica MCP",
"ConfigActionOpenPlugins": "Enter abre complementos",
"ConfigActionToggle": "Enter/Espacio alterna",
"ConfigActionChoose": "Enter abre las opciones",
"ConfigActionEdit": "Enter edita",
+12
View File
@@ -2,6 +2,10 @@
"ExtensionsActionAdd": "ajouter",
"ExtensionsActionEnable": "activer",
"ExtensionsActionReload": "recharger",
"ExtensionsActionConnect": "connecter",
"ExtensionsActionReconnect": "reconnecter",
"ExtensionsActionReauth": "réauthentifier",
"ExtensionsActionDiagnose": "diagnostiquer",
"ExtensionsActionFocus": "focus",
"ExtensionsActionFold": "replier",
"ExtensionsActionTabs": "onglets",
@@ -291,6 +295,10 @@
"ConfigLabelAutoCompactThreshold": "Seuil de compaction",
"ConfigLabelMaxHistory": "Historique de saisie",
"ConfigLabelMcpConfigPath": "Chemin de la config MCP",
"ConfigLabelMcpOpen": "Gestionnaire MCP",
"ConfigLabelMcpReconnect": "Reconnecter MCP",
"ConfigLabelMcpDiagnose": "Diagnostiquer MCP",
"ConfigLabelPluginsOpen": "Extensions",
"ConfigLabelFleetSpawnDepth": "Profondeur de récursion Fleet",
"ConfigLabelGoalCommand": "Commande d'objectif",
"ConfigLabelWorkflow": "Workflow",
@@ -300,6 +308,10 @@
"ConfigColumnScope": "Portée",
"ConfigActionOpenProvider": "Enter ouvre le sélecteur de fournisseur",
"ConfigActionOpenModel": "Enter ouvre le sélecteur de modèle",
"ConfigActionOpenMcp": "Entrée ouvre le gestionnaire MCP",
"ConfigActionMcpReconnect": "Entrée reconnecte MCP",
"ConfigActionMcpDiagnose": "Entrée diagnostique MCP",
"ConfigActionOpenPlugins": "Entrée ouvre les extensions",
"ConfigActionToggle": "Enter/Espace bascule",
"ConfigActionChoose": "Enter ouvre les choix",
"ConfigActionEdit": "Enter modifie",
+12
View File
@@ -2,6 +2,10 @@
"ExtensionsActionAdd": "जोड़ें",
"ExtensionsActionEnable": "सक्षम करें",
"ExtensionsActionReload": "पुनः लोड करें",
"ExtensionsActionConnect": "कनेक्ट करें",
"ExtensionsActionReconnect": "फिर कनेक्ट करें",
"ExtensionsActionReauth": "फिर प्रमाणित करें",
"ExtensionsActionDiagnose": "निदान करें",
"ExtensionsActionFocus": "फ़ोकस",
"ExtensionsActionFold": "समेटें",
"ExtensionsActionTabs": "टैब",
@@ -291,6 +295,10 @@
"ConfigLabelAutoCompactThreshold": "कॉम्पैक्ट सीमा",
"ConfigLabelMaxHistory": "इनपुट इतिहास",
"ConfigLabelMcpConfigPath": "MCP कॉन्फ़िग पथ",
"ConfigLabelMcpOpen": "MCP प्रबंधक",
"ConfigLabelMcpReconnect": "MCP फिर कनेक्ट करें",
"ConfigLabelMcpDiagnose": "MCP निदान",
"ConfigLabelPluginsOpen": "प्लगइन",
"ConfigLabelFleetSpawnDepth": "Fleet पुनरावृत्ति गहराई",
"ConfigLabelGoalCommand": "गोल कमांड",
"ConfigLabelWorkflow": "वर्कफ़्लो",
@@ -300,6 +308,10 @@
"ConfigColumnScope": "दायरा",
"ConfigActionOpenProvider": "Enter प्रोवाइडर चयनकर्ता खोलता है",
"ConfigActionOpenModel": "Enter मॉडल चयनकर्ता खोलता है",
"ConfigActionOpenMcp": "Enter MCP प्रबंधक खोलता है",
"ConfigActionMcpReconnect": "Enter MCP फिर कनेक्ट करता है",
"ConfigActionMcpDiagnose": "Enter MCP का निदान करता है",
"ConfigActionOpenPlugins": "Enter प्लगइन खोलता है",
"ConfigActionToggle": "Enter/Space टॉगल करता है",
"ConfigActionChoose": "Enter विकल्प खोलता है",
"ConfigActionEdit": "Enter संपादित करता है",
+12
View File
@@ -2,6 +2,10 @@
"ExtensionsActionAdd": "tambahkan",
"ExtensionsActionEnable": "aktifkan",
"ExtensionsActionReload": "muat ulang",
"ExtensionsActionConnect": "hubungkan",
"ExtensionsActionReconnect": "hubungkan ulang",
"ExtensionsActionReauth": "otentikasi ulang",
"ExtensionsActionDiagnose": "diagnosa",
"ExtensionsActionFocus": "fokus",
"ExtensionsActionFold": "lipat",
"ExtensionsActionTabs": "tab",
@@ -291,6 +295,10 @@
"ConfigLabelAutoCompactThreshold": "Ambang pemadatan",
"ConfigLabelMaxHistory": "Riwayat input",
"ConfigLabelMcpConfigPath": "Jalur konfig MCP",
"ConfigLabelMcpOpen": "Pengelola MCP",
"ConfigLabelMcpReconnect": "Hubungkan ulang MCP",
"ConfigLabelMcpDiagnose": "Diagnosa MCP",
"ConfigLabelPluginsOpen": "Plugin",
"ConfigLabelFleetSpawnDepth": "Kedalaman rekursi Fleet",
"ConfigLabelGoalCommand": "Perintah tujuan",
"ConfigLabelWorkflow": "Alur kerja",
@@ -300,6 +308,10 @@
"ConfigColumnScope": "Cakupan",
"ConfigActionOpenProvider": "Enter membuka pemilih penyedia",
"ConfigActionOpenModel": "Enter membuka pemilih model",
"ConfigActionOpenMcp": "Enter membuka pengelola MCP",
"ConfigActionMcpReconnect": "Enter menghubungkan ulang MCP",
"ConfigActionMcpDiagnose": "Enter mendiagnosa MCP",
"ConfigActionOpenPlugins": "Enter membuka plugin",
"ConfigActionToggle": "Enter/Space mengalihkan",
"ConfigActionChoose": "Enter membuka pilihan",
"ConfigActionEdit": "Enter mengedit",
+12
View File
@@ -2,6 +2,10 @@
"ExtensionsActionAdd": "追加",
"ExtensionsActionEnable": "有効化",
"ExtensionsActionReload": "再読み込み",
"ExtensionsActionConnect": "接続",
"ExtensionsActionReconnect": "再接続",
"ExtensionsActionReauth": "再認証",
"ExtensionsActionDiagnose": "診断",
"ExtensionsActionFocus": "フォーカス",
"ExtensionsActionFold": "折りたたむ",
"ExtensionsActionTabs": "タブ",
@@ -293,6 +297,10 @@
"ConfigLabelAutoCompactThreshold": "コンパクトしきい値",
"ConfigLabelMaxHistory": "入力履歴",
"ConfigLabelMcpConfigPath": "MCP 設定パス",
"ConfigLabelMcpOpen": "MCP マネージャ",
"ConfigLabelMcpReconnect": "MCP を再接続",
"ConfigLabelMcpDiagnose": "MCP を診断",
"ConfigLabelPluginsOpen": "プラグイン",
"ConfigLabelFleetSpawnDepth": "Fleet 再帰深度",
"ConfigLabelGoalCommand": "目標コマンド",
"ConfigLabelWorkflow": "ワークフロー",
@@ -302,6 +310,10 @@
"ConfigColumnScope": "範囲",
"ConfigActionOpenProvider": "Enter でプロバイダ選択を開く",
"ConfigActionOpenModel": "Enter でモデル選択を開く",
"ConfigActionOpenMcp": "Enter で MCP マネージャを開く",
"ConfigActionMcpReconnect": "Enter で MCP を再接続",
"ConfigActionMcpDiagnose": "Enter で MCP を診断",
"ConfigActionOpenPlugins": "Enter でプラグインを開く",
"ConfigActionToggle": "Enter/Space で切り替え",
"ConfigActionChoose": "Enter で選択肢を開く",
"ConfigActionEdit": "Enter で編集",
+12
View File
@@ -2,6 +2,10 @@
"ExtensionsActionAdd": "추가",
"ExtensionsActionEnable": "활성화",
"ExtensionsActionReload": "다시 로드",
"ExtensionsActionConnect": "연결",
"ExtensionsActionReconnect": "다시 연결",
"ExtensionsActionReauth": "재인증",
"ExtensionsActionDiagnose": "진단",
"ExtensionsActionFocus": "포커스",
"ExtensionsActionFold": "접기",
"ExtensionsActionTabs": "탭",
@@ -293,6 +297,10 @@
"ConfigLabelAutoCompactThreshold": "압축 임계값",
"ConfigLabelMaxHistory": "입력 기록",
"ConfigLabelMcpConfigPath": "MCP 설정 경로",
"ConfigLabelMcpOpen": "MCP 관리자",
"ConfigLabelMcpReconnect": "MCP 다시 연결",
"ConfigLabelMcpDiagnose": "MCP 진단",
"ConfigLabelPluginsOpen": "플러그인",
"ConfigLabelFleetSpawnDepth": "Fleet 재귀 깊이",
"ConfigLabelGoalCommand": "목표 명령",
"ConfigLabelWorkflow": "워크플로",
@@ -302,6 +310,10 @@
"ConfigColumnScope": "범위",
"ConfigActionOpenProvider": "Enter로 프로바이더 선택기 열기",
"ConfigActionOpenModel": "Enter로 모델 선택기 열기",
"ConfigActionOpenMcp": "Enter로 MCP 관리자 열기",
"ConfigActionMcpReconnect": "Enter로 MCP 다시 연결",
"ConfigActionMcpDiagnose": "Enter로 MCP 진단",
"ConfigActionOpenPlugins": "Enter로 플러그인 열기",
"ConfigActionToggle": "Enter/Space로 전환",
"ConfigActionChoose": "Enter로 선택 항목 열기",
"ConfigActionEdit": "Enter로 편집",
+12
View File
@@ -2,6 +2,10 @@
"ExtensionsActionAdd": "adicionar",
"ExtensionsActionEnable": "ativar",
"ExtensionsActionReload": "recarregar",
"ExtensionsActionConnect": "conectar",
"ExtensionsActionReconnect": "reconectar",
"ExtensionsActionReauth": "reautenticar",
"ExtensionsActionDiagnose": "diagnosticar",
"ExtensionsActionFocus": "foco",
"ExtensionsActionFold": "recolher",
"ExtensionsActionTabs": "abas",
@@ -293,6 +297,10 @@
"ConfigLabelAutoCompactThreshold": "Limite de compactação",
"ConfigLabelMaxHistory": "Histórico de entrada",
"ConfigLabelMcpConfigPath": "Caminho da configuração MCP",
"ConfigLabelMcpOpen": "Gerenciador MCP",
"ConfigLabelMcpReconnect": "Reconectar MCP",
"ConfigLabelMcpDiagnose": "Diagnosticar MCP",
"ConfigLabelPluginsOpen": "Plugins",
"ConfigLabelFleetSpawnDepth": "Profundidade recursiva da Fleet",
"ConfigLabelGoalCommand": "Comando de objetivo",
"ConfigLabelWorkflow": "Fluxo de trabalho",
@@ -302,6 +310,10 @@
"ConfigColumnScope": "Escopo",
"ConfigActionOpenProvider": "Enter abre o seletor de provedor",
"ConfigActionOpenModel": "Enter abre o seletor de modelo",
"ConfigActionOpenMcp": "Enter abre o gerenciador MCP",
"ConfigActionMcpReconnect": "Enter reconecta o MCP",
"ConfigActionMcpDiagnose": "Enter diagnostica o MCP",
"ConfigActionOpenPlugins": "Enter abre os plugins",
"ConfigActionToggle": "Enter/Espaço alterna",
"ConfigActionChoose": "Enter abre as opções",
"ConfigActionEdit": "Enter edita",
+12
View File
@@ -2,6 +2,10 @@
"ExtensionsActionAdd": "добавить",
"ExtensionsActionEnable": "включить",
"ExtensionsActionReload": "перезагрузить",
"ExtensionsActionConnect": "подключить",
"ExtensionsActionReconnect": "переподключить",
"ExtensionsActionReauth": "повторно войти",
"ExtensionsActionDiagnose": "диагностика",
"ExtensionsActionFocus": "фокус",
"ExtensionsActionFold": "свернуть",
"ExtensionsActionTabs": "вкладки",
@@ -291,6 +295,10 @@
"ConfigLabelAutoCompactThreshold": "Порог сжатия",
"ConfigLabelMaxHistory": "История ввода",
"ConfigLabelMcpConfigPath": "Путь к конфигурации MCP",
"ConfigLabelMcpOpen": "Менеджер MCP",
"ConfigLabelMcpReconnect": "Переподключить MCP",
"ConfigLabelMcpDiagnose": "Диагностика MCP",
"ConfigLabelPluginsOpen": "Плагины",
"ConfigLabelFleetSpawnDepth": "Глубина рекурсии Fleet",
"ConfigLabelGoalCommand": "Команда цели",
"ConfigLabelWorkflow": "Воркфлоу",
@@ -300,6 +308,10 @@
"ConfigColumnScope": "Область",
"ConfigActionOpenProvider": "Enter — выбор провайдера",
"ConfigActionOpenModel": "Enter — выбор модели",
"ConfigActionOpenMcp": "Enter открывает менеджер MCP",
"ConfigActionMcpReconnect": "Enter переподключает MCP",
"ConfigActionMcpDiagnose": "Enter диагностирует MCP",
"ConfigActionOpenPlugins": "Enter открывает плагины",
"ConfigActionToggle": "Enter/Space — переключить",
"ConfigActionChoose": "Enter — открыть варианты",
"ConfigActionEdit": "Enter — изменить",
+12
View File
@@ -2,6 +2,10 @@
"ExtensionsActionAdd": "додати",
"ExtensionsActionEnable": "увімкнути",
"ExtensionsActionReload": "перезавантажити",
"ExtensionsActionConnect": "підключити",
"ExtensionsActionReconnect": "перепідключити",
"ExtensionsActionReauth": "повторно увійти",
"ExtensionsActionDiagnose": "діагностика",
"ExtensionsActionFocus": "фокус",
"ExtensionsActionFold": "згорнути",
"ExtensionsActionTabs": "вкладки",
@@ -291,6 +295,10 @@
"ConfigLabelAutoCompactThreshold": "Поріг стиснення",
"ConfigLabelMaxHistory": "Історія вводу",
"ConfigLabelMcpConfigPath": "Шлях конфігурації MCP",
"ConfigLabelMcpOpen": "Менеджер MCP",
"ConfigLabelMcpReconnect": "Перепідключити MCP",
"ConfigLabelMcpDiagnose": "Діагностика MCP",
"ConfigLabelPluginsOpen": "Плагіни",
"ConfigLabelFleetSpawnDepth": "Глибина рекурсії Fleet",
"ConfigLabelGoalCommand": "Команда цілі",
"ConfigLabelWorkflow": "Робочий процес",
@@ -300,6 +308,10 @@
"ConfigColumnScope": "Область",
"ConfigActionOpenProvider": "Enter відкриває вибір провайдера",
"ConfigActionOpenModel": "Enter відкриває вибір моделі",
"ConfigActionOpenMcp": "Enter відкриває менеджер MCP",
"ConfigActionMcpReconnect": "Enter перепідключає MCP",
"ConfigActionMcpDiagnose": "Enter діагностує MCP",
"ConfigActionOpenPlugins": "Enter відкриває плагіни",
"ConfigActionToggle": "Enter/Space перемикає",
"ConfigActionChoose": "Enter відкриває варіанти",
"ConfigActionEdit": "Enter редагує",
+12
View File
@@ -2,6 +2,10 @@
"ExtensionsActionAdd": "thêm",
"ExtensionsActionEnable": "bật",
"ExtensionsActionReload": "tải lại",
"ExtensionsActionConnect": "kết nối",
"ExtensionsActionReconnect": "kết nối lại",
"ExtensionsActionReauth": "xác thực lại",
"ExtensionsActionDiagnose": "chẩn đoán",
"ExtensionsActionFocus": "tiêu điểm",
"ExtensionsActionFold": "thu gọn",
"ExtensionsActionTabs": "thẻ",
@@ -293,6 +297,10 @@
"ConfigLabelAutoCompactThreshold": "Ngưỡng thu gọn",
"ConfigLabelMaxHistory": "Lịch sử nhập",
"ConfigLabelMcpConfigPath": "Đường dẫn cấu hình MCP",
"ConfigLabelMcpOpen": "Trình quản lý MCP",
"ConfigLabelMcpReconnect": "Kết nối lại MCP",
"ConfigLabelMcpDiagnose": "Chẩn đoán MCP",
"ConfigLabelPluginsOpen": "Plugin",
"ConfigLabelFleetSpawnDepth": "Độ sâu đệ quy Fleet",
"ConfigLabelGoalCommand": "Lệnh mục tiêu",
"ConfigLabelWorkflow": "Quy trình làm việc",
@@ -302,6 +310,10 @@
"ConfigColumnScope": "Phạm vi",
"ConfigActionOpenProvider": "Enter mở bộ chọn nhà cung cấp",
"ConfigActionOpenModel": "Enter mở bộ chọn mô hình",
"ConfigActionOpenMcp": "Enter mở trình quản lý MCP",
"ConfigActionMcpReconnect": "Enter kết nối lại MCP",
"ConfigActionMcpDiagnose": "Enter chẩn đoán MCP",
"ConfigActionOpenPlugins": "Enter mở plugin",
"ConfigActionToggle": "Enter/Dấu cách để bật tắt",
"ConfigActionChoose": "Enter mở các lựa chọn",
"ConfigActionEdit": "Enter để sửa",
+12
View File
@@ -2,6 +2,10 @@
"ExtensionsActionAdd": "添加",
"ExtensionsActionEnable": "启用",
"ExtensionsActionReload": "重新加载",
"ExtensionsActionConnect": "连接",
"ExtensionsActionReconnect": "重新连接",
"ExtensionsActionReauth": "重新登录",
"ExtensionsActionDiagnose": "诊断",
"ExtensionsActionFocus": "焦点",
"ExtensionsActionFold": "折叠",
"ExtensionsActionTabs": "标签页",
@@ -293,6 +297,10 @@
"ConfigLabelAutoCompactThreshold": "压缩阈值",
"ConfigLabelMaxHistory": "输入历史",
"ConfigLabelMcpConfigPath": "MCP 配置路径",
"ConfigLabelMcpOpen": "MCP 管理器",
"ConfigLabelMcpReconnect": "重新连接 MCP",
"ConfigLabelMcpDiagnose": "诊断 MCP",
"ConfigLabelPluginsOpen": "插件",
"ConfigLabelFleetSpawnDepth": "Fleet 递归深度",
"ConfigLabelGoalCommand": "目标命令",
"ConfigLabelWorkflow": "工作流",
@@ -302,6 +310,10 @@
"ConfigColumnScope": "范围",
"ConfigActionOpenProvider": "按 Enter 打开提供商选择器",
"ConfigActionOpenModel": "按 Enter 打开模型选择器",
"ConfigActionOpenMcp": "Enter 打开 MCP 管理器",
"ConfigActionMcpReconnect": "Enter 重新连接 MCP",
"ConfigActionMcpDiagnose": "Enter 诊断 MCP",
"ConfigActionOpenPlugins": "Enter 打开插件",
"ConfigActionToggle": "按 Enter/空格切换",
"ConfigActionChoose": "按 Enter 打开选项",
"ConfigActionEdit": "按 Enter 编辑",
+12
View File
@@ -2,6 +2,10 @@
"ExtensionsActionAdd": "新增",
"ExtensionsActionEnable": "啟用",
"ExtensionsActionReload": "重新載入",
"ExtensionsActionConnect": "連線",
"ExtensionsActionReconnect": "重新連線",
"ExtensionsActionReauth": "重新登入",
"ExtensionsActionDiagnose": "診斷",
"ExtensionsActionFocus": "焦點",
"ExtensionsActionFold": "摺疊",
"ExtensionsActionTabs": "分頁",
@@ -474,6 +478,10 @@
"ConfigActionChoose": "按 Enter 開啟選項",
"ConfigActionEdit": "按 Enter 編輯",
"ConfigActionOpenModel": "按 Enter 開啟模型選擇器",
"ConfigActionOpenMcp": "Enter 開啟 MCP 管理員",
"ConfigActionMcpReconnect": "Enter 重新連線 MCP",
"ConfigActionMcpDiagnose": "Enter 診斷 MCP",
"ConfigActionOpenPlugins": "Enter 開啟外掛",
"ConfigActionOpenProvider": "按 Enter 開啟提供商選擇器",
"ConfigActionReadOnly": "唯讀",
"ConfigActionToggle": "按 Enter/空格切換",
@@ -527,6 +535,10 @@
"ConfigLabelManagedApprovalPolicy": "新工作階段權限(受管理)",
"ConfigLabelMaxHistory": "輸入歷史",
"ConfigLabelMcpConfigPath": "MCP 設定路徑",
"ConfigLabelMcpOpen": "MCP 管理員",
"ConfigLabelMcpReconnect": "重新連線 MCP",
"ConfigLabelMcpDiagnose": "診斷 MCP",
"ConfigLabelPluginsOpen": "外掛",
"ConfigLabelMentionMenuBehavior": "提及選單行為",
"ConfigLabelMentionMenuLimit": "提及選單上限",
"ConfigLabelMentionWalkDepth": "檔案提及深度",
+7 -3
View File
@@ -1086,9 +1086,13 @@ fn canonical_openrouter_recent_model_id(model: &str) -> Option<&'static str> {
OPENROUTER_QWEN_3_8_FLASH_MODEL | "qwen3.8-flash" | "qwen-3.8-flash" => {
Some(OPENROUTER_QWEN_3_8_FLASH_MODEL)
}
OPENROUTER_TENCENT_HY3_PREVIEW_MODEL | "hy3-preview" | "tencent-hy3-preview" => {
Some(OPENROUTER_TENCENT_HY3_PREVIEW_MODEL)
}
OPENROUTER_TENCENT_HY3_PREVIEW_MODEL
| "hy3-preview"
| "tencent-hy3-preview"
| "hy3"
| "hunyuan"
| "tencent-hunyuan"
| "hunyuan-hy3" => Some(OPENROUTER_TENCENT_HY3_PREVIEW_MODEL),
OPENROUTER_XIAOMI_MIMO_V2_5_PRO_MODEL
| "mimo-v2.5-pro"
| "mimo-v2-5-pro"
+23
View File
@@ -6179,6 +6179,29 @@ fn retired_deepseek_aliases_do_not_escape_provider_owned_namespaces() {
}
}
#[test]
fn openrouter_hunyuan_aliases_resolve_to_hy3_preview() {
for alias in [
"tencent/hy3-preview",
"hy3-preview",
"hy3",
"hunyuan",
"tencent-hunyuan",
"hunyuan-hy3",
] {
assert_eq!(
canonical_model_id_for_provider(ApiProvider::Openrouter, alias).as_deref(),
Some(OPENROUTER_TENCENT_HY3_PREVIEW_MODEL),
"{alias} should select the public Hy3 preview id"
);
}
assert_ne!(
canonical_model_id_for_provider(ApiProvider::Openrouter, "hy4").as_deref(),
Some(OPENROUTER_TENCENT_HY3_PREVIEW_MODEL),
"hy4 is not a released public model id"
);
}
#[test]
fn deepseek_default_model_canonicalizes_provider_prefixed_ids() {
let _lock = lock_test_env();
+25 -1
View File
@@ -308,6 +308,10 @@ pub enum MessageId {
ConfigLabelAutoCompact,
ConfigLabelAutoCompactThreshold,
ConfigLabelMaxHistory,
ConfigLabelMcpOpen,
ConfigLabelMcpReconnect,
ConfigLabelMcpDiagnose,
ConfigLabelPluginsOpen,
ConfigLabelMcpConfigPath,
ConfigLabelFleetSpawnDepth,
ConfigLabelGoalCommand,
@@ -318,6 +322,10 @@ pub enum MessageId {
ConfigColumnScope,
ConfigActionOpenProvider,
ConfigActionOpenModel,
ConfigActionOpenMcp,
ConfigActionMcpReconnect,
ConfigActionMcpDiagnose,
ConfigActionOpenPlugins,
ConfigActionToggle,
ConfigActionChoose,
ConfigActionEdit,
@@ -436,6 +444,10 @@ pub enum MessageId {
ExtensionsActionAdd,
ExtensionsActionEnable,
ExtensionsActionReload,
ExtensionsActionConnect,
ExtensionsActionReconnect,
ExtensionsActionReauth,
ExtensionsActionDiagnose,
ExtensionsActionFocus,
ExtensionsActionFold,
ExtensionsActionTabs,
@@ -2104,6 +2116,10 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[
MessageId::ConfigLabelAutoCompact,
MessageId::ConfigLabelAutoCompactThreshold,
MessageId::ConfigLabelMaxHistory,
MessageId::ConfigLabelMcpOpen,
MessageId::ConfigLabelMcpReconnect,
MessageId::ConfigLabelMcpDiagnose,
MessageId::ConfigLabelPluginsOpen,
MessageId::ConfigLabelMcpConfigPath,
MessageId::ConfigLabelFleetSpawnDepth,
MessageId::ConfigLabelGoalCommand,
@@ -2114,6 +2130,10 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[
MessageId::ConfigColumnScope,
MessageId::ConfigActionOpenProvider,
MessageId::ConfigActionOpenModel,
MessageId::ConfigActionOpenMcp,
MessageId::ConfigActionMcpReconnect,
MessageId::ConfigActionMcpDiagnose,
MessageId::ConfigActionOpenPlugins,
MessageId::ConfigActionToggle,
MessageId::ConfigActionChoose,
MessageId::ConfigActionEdit,
@@ -2231,6 +2251,10 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[
MessageId::ExtensionsActionAdd,
MessageId::ExtensionsActionEnable,
MessageId::ExtensionsActionReload,
MessageId::ExtensionsActionConnect,
MessageId::ExtensionsActionReconnect,
MessageId::ExtensionsActionReauth,
MessageId::ExtensionsActionDiagnose,
MessageId::ExtensionsActionFocus,
MessageId::ExtensionsActionFold,
MessageId::ExtensionsActionTabs,
@@ -4893,7 +4917,7 @@ mod tests {
.filter(|key| key.starts_with("Extensions"))
.cloned()
.collect::<Vec<_>>();
assert_eq!(keys.len(), 82, "the complete extensions locale set changed");
assert_eq!(keys.len(), 86, "the complete extensions locale set changed");
let prose_keys = [
"ExtensionsMarketplaceUnavailable",
+78
View File
@@ -3587,6 +3587,84 @@ pub struct McpManagerSnapshot {
pub servers: Vec<McpServerSnapshot>,
}
/// First-class recovery for a configured MCP server. Commands named here exist:
/// `/mcp login`, `/mcp reload`, `/mcp validate`, `/mcp enable`. There is no
/// `/mcp auth`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum McpRecoveryKind {
Enable,
Connect,
Reconnect,
Reauth,
Diagnose,
}
impl McpRecoveryKind {
#[must_use]
pub fn slash_command(self, name: &str) -> String {
match self {
Self::Enable => format!("/mcp enable {name}"),
Self::Connect | Self::Reconnect => "/mcp reload".to_string(),
Self::Reauth => format!("/mcp login {name}"),
Self::Diagnose => "/mcp validate".to_string(),
}
}
#[must_use]
pub fn label_key(self) -> crate::localization::MessageId {
match self {
Self::Enable => crate::localization::MessageId::ExtensionsActionEnable,
Self::Connect => crate::localization::MessageId::ExtensionsActionConnect,
Self::Reconnect => crate::localization::MessageId::ExtensionsActionReconnect,
Self::Reauth => crate::localization::MessageId::ExtensionsActionReauth,
Self::Diagnose => crate::localization::MessageId::ExtensionsActionDiagnose,
}
}
}
#[must_use]
pub fn mcp_name_is_command_safe(name: &str) -> bool {
!name.is_empty()
&& name
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.'))
}
#[must_use]
pub fn mcp_server_oauth_capable(config: &McpServerConfig) -> bool {
config.url.is_some()
&& (config.oauth.is_some() || !config.scopes.is_empty() || config.oauth_resource.is_some())
}
#[must_use]
pub fn mcp_recovery_kind(
enabled: bool,
inspected: bool,
connected: bool,
error: Option<&str>,
oauth_capable: bool,
) -> McpRecoveryKind {
if !enabled {
return McpRecoveryKind::Enable;
}
if let Some(error) = error {
if oauth::error_text_looks_auth_required(error) {
return McpRecoveryKind::Reauth;
}
return McpRecoveryKind::Diagnose;
}
if !inspected {
return McpRecoveryKind::Connect;
}
if connected {
return McpRecoveryKind::Diagnose;
}
if oauth_capable {
return McpRecoveryKind::Reauth;
}
McpRecoveryKind::Reconnect
}
pub fn load_config(path: &Path) -> Result<McpConfig> {
validate_mcp_config_path(path)?;
let Some(contents) = read_mcp_config_file(path)? else {
+37 -1
View File
@@ -46,12 +46,19 @@ impl std::fmt::Display for McpAuthStatus {
}
pub fn error_looks_auth_required(error: &anyhow::Error) -> bool {
let text = format!("{error:#}").to_ascii_lowercase();
error_text_looks_auth_required(&format!("{error:#}"))
}
pub fn error_text_looks_auth_required(text: &str) -> bool {
let text = text.to_ascii_lowercase();
text.contains("401")
|| text.contains("unauthorized")
|| text.contains("authentication_required")
|| text.contains("not logged in")
|| text.contains("not-logged-in")
|| text.contains("re-authorize")
|| text.contains("/mcp login")
|| text.contains("mcp login")
}
pub fn auth_required_login_hint(server_name: &str) -> String {
@@ -60,6 +67,16 @@ pub fn auth_required_login_hint(server_name: &str) -> String {
)
}
/// TUI recovery for a stale Streamable HTTP OAuth session. `/mcp auth` is not a
/// command; login is `/mcp login <name>` (CLI: `codewhale mcp login <name>`).
pub fn tui_reauth_hint() -> &'static str {
"Re-authorize this server (/mcp login <name>) to continue."
}
pub fn tui_reauth_refresh_failed_hint() -> &'static str {
"Re-authorize this server (/mcp login <name>) or configure a fresh bearer token."
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct StoredMcpOAuthTokens {
pub server_name: String,
@@ -1229,6 +1246,25 @@ mod tests {
let hint = auth_required_login_hint("nordic-mcp");
assert!(hint.contains("nordic-mcp"));
assert!(hint.contains("codewhale mcp login nordic-mcp"));
assert!(!hint.contains("/mcp auth"));
}
#[test]
fn tui_reauth_hints_name_the_login_command() {
for hint in [tui_reauth_hint(), tui_reauth_refresh_failed_hint()] {
assert!(
hint.contains("/mcp login <name>"),
"OAuth recovery must name the implemented command"
);
assert!(
!hint.contains("/mcp auth"),
"OAuth recovery must not advertise a missing /mcp auth command"
);
}
assert!(error_text_looks_auth_required(
"MCP server rejected the request with 401 Unauthorized"
));
assert!(!error_text_looks_auth_required("connection refused"));
}
#[tokio::test]
+5 -4
View File
@@ -216,15 +216,16 @@ pub(super) async fn read_body_capped(
Ok(String::from_utf8_lossy(&buf).into_owned())
}
/// TUI recovery for a rejected OAuth session. The implemented command is
/// `/mcp login <name>` (`codewhale mcp login`); `/mcp auth` is not a command.
/// TUI recovery for a rejected OAuth session. Settings recovery and the
/// Streamable HTTP path share the oauth helpers so `/mcp login <name>` stays
/// the only advertised command; `/mcp auth` is not a command.
fn oauth_refresh_failed_hint() -> &'static str {
"Re-authorize this server (/mcp login <name>) or configure a fresh bearer token."
super::oauth::tui_reauth_refresh_failed_hint()
}
fn unauthorized_session_hint(oauth_configured: bool) -> &'static str {
if oauth_configured {
"Re-authorize this server (/mcp login <name>) to continue."
super::oauth::tui_reauth_hint()
} else {
"Check the configured bearer token (or its environment variable)."
}
+56
View File
@@ -5716,3 +5716,59 @@ fn removed_runtime_server_config_can_be_retried_with_same_name() {
pool.add_runtime_server_config("retryable".to_string(), config)
.expect("rollback must release the deterministic runtime name");
}
#[test]
fn mcp_recovery_kind_names_real_login_and_reload_commands() {
assert_eq!(
mcp_recovery_kind(false, true, false, None, false),
McpRecoveryKind::Enable
);
assert_eq!(
mcp_recovery_kind(true, false, false, None, false),
McpRecoveryKind::Connect
);
assert_eq!(
mcp_recovery_kind(true, true, false, Some("connection refused"), false),
McpRecoveryKind::Diagnose
);
assert_eq!(
mcp_recovery_kind(true, true, false, Some("connection refused"), true),
McpRecoveryKind::Diagnose
);
assert_eq!(
mcp_recovery_kind(true, true, false, Some("401 Unauthorized"), true),
McpRecoveryKind::Reauth
);
assert_eq!(
mcp_recovery_kind(true, true, false, None, true),
McpRecoveryKind::Reauth
);
assert_eq!(
mcp_recovery_kind(true, true, false, None, false),
McpRecoveryKind::Reconnect
);
assert_eq!(
mcp_recovery_kind(true, true, true, None, false),
McpRecoveryKind::Diagnose
);
assert_eq!(
McpRecoveryKind::Reauth.slash_command("github"),
"/mcp login github"
);
assert_eq!(
McpRecoveryKind::Connect.slash_command("github"),
"/mcp reload"
);
assert_eq!(
McpRecoveryKind::Diagnose.slash_command("github"),
"/mcp validate"
);
assert!(
!McpRecoveryKind::Reauth
.slash_command("github")
.contains("/mcp auth")
);
assert!(mcp_name_is_command_safe("github"));
assert!(!mcp_name_is_command_safe("github mcp"));
}
+58 -4
View File
@@ -34,10 +34,7 @@ pub(super) fn format_mcp_manager(snapshot: &McpManagerSnapshot, locale: Locale)
}
lines.push(String::new());
lines.push(
"Actions: /mcp init, /mcp add stdio <name> <command> [args...], /mcp add http <name> <url>, /mcp enable <name>, /mcp disable <name>, /mcp remove <name>, /mcp validate, /mcp reload."
.to_string(),
);
lines.push("Next: Connect /mcp reload · Diagnose /mcp validate".to_string());
lines.join("\n")
}
@@ -65,6 +62,32 @@ fn push_server(lines: &mut Vec<String>, server: &McpServerSnapshot, locale: Loca
if let Some(error) = server.error.as_ref() {
lines.push(format!(" error: {error}"));
}
let recovery = crate::mcp::mcp_recovery_kind(
server.enabled,
true,
server.connected,
server.error.as_deref(),
false,
);
let command = if crate::mcp::mcp_name_is_command_safe(&server.name)
|| matches!(
recovery,
crate::mcp::McpRecoveryKind::Connect
| crate::mcp::McpRecoveryKind::Reconnect
| crate::mcp::McpRecoveryKind::Diagnose
) {
recovery.slash_command(&server.name)
} else {
"/mcp validate".to_string()
};
let verb = match recovery {
crate::mcp::McpRecoveryKind::Enable => "Enable",
crate::mcp::McpRecoveryKind::Connect => "Connect",
crate::mcp::McpRecoveryKind::Reconnect => "Reconnect",
crate::mcp::McpRecoveryKind::Reauth => "Re-auth",
crate::mcp::McpRecoveryKind::Diagnose => "Diagnose",
};
lines.push(format!(" next: {verb} {command}"));
lines.push(format!(
" discovered: {} tools, {} resources, {} prompts",
server.tools.len(),
@@ -205,6 +228,37 @@ mod tests {
assert!(text.contains("boom"));
assert!(text.contains("Advertised capabilities: tools"));
assert!(text.contains("not observed because the server is not connected"));
assert!(text.contains("next: Diagnose /mcp validate"));
assert!(text.contains("Next: Connect /mcp reload · Diagnose /mcp validate"));
assert!(!text.contains("/mcp auth"));
}
#[test]
fn manager_text_names_login_for_stale_oauth() {
let snapshot = McpManagerSnapshot {
config_path: PathBuf::from("/tmp/mcp.json"),
config_exists: true,
reload_required: false,
servers: vec![McpServerSnapshot {
name: "github".to_string(),
enabled: true,
required: false,
transport: "http".to_string(),
command_or_url: "https://api.githubcopilot.com/mcp/".to_string(),
connect_timeout: 10,
execute_timeout: 60,
read_timeout: 120,
connected: false,
error: Some("401 Unauthorized".to_string()),
capability_metadata: McpServerCapabilityMetadata::NotObserved,
tools: Vec::new(),
resources: Vec::new(),
prompts: Vec::new(),
}],
};
let text = format_mcp_manager(&snapshot, Locale::En);
assert!(text.contains("next: Re-auth /mcp login github"), "{text}");
assert!(!text.contains("/mcp auth"));
}
#[test]
+87 -17
View File
@@ -639,7 +639,15 @@ fn plugins_model(app: &App, locale: Locale) -> ExtensionsTabModel {
crate::plugins::types::PluginScope::Workspace => 2,
};
let diagnostic_count = plugin.diagnostics.len();
let action = if plugin.active() {
let has_error_diagnostics = plugin.diagnostics.iter().any(|diagnostic| {
diagnostic.level == crate::plugins::types::PluginDiagnosticLevel::Error
});
let action = if has_error_diagnostics {
ExtensionAction::Command {
label: tr(locale, MessageId::ExtensionsActionDiagnose).into_owned(),
command: format!("/plugin validate {}", plugin.name()),
}
} else if plugin.active() {
ExtensionAction::Command {
label: tr(locale, MessageId::LaunchHintOpen).into_owned(),
command: format!("/plugin show {}", plugin.name()),
@@ -698,7 +706,7 @@ fn plugins_model(app: &App, locale: Locale) -> ExtensionsTabModel {
tr(locale, MessageId::ExtensionsGroupWorkspace).into_owned(),
),
];
let groups = labels
let mut groups = labels
.into_iter()
.zip(by_scope)
.filter(|(_, items)| !items.is_empty())
@@ -707,7 +715,40 @@ fn plugins_model(app: &App, locale: Locale) -> ExtensionsTabModel {
label,
items,
})
.collect();
.collect::<Vec<_>>();
let problems = app
.plugin_registry
.diagnostics()
.iter()
.enumerate()
.map(|(index, diagnostic)| ExtensionItem {
id: format!("plugin-diagnostic-{index}"),
label: diagnostic.code.to_string(),
description: diagnostic.message.clone(),
state: if diagnostic.level == crate::plugins::types::PluginDiagnosticLevel::Error {
tr(locale, MessageId::ExtensionsStateInvalid)
} else {
tr(locale, MessageId::ExtensionsStateWarning)
}
.into_owned(),
detail: diagnostic
.path
.as_ref()
.map(|path| path.display().to_string())
.unwrap_or_else(|| diagnostic.message.clone()),
action: Some(ExtensionAction::Command {
label: tr(locale, MessageId::ExtensionsActionDiagnose).into_owned(),
command: "/plugin validate".into(),
}),
})
.collect::<Vec<_>>();
if !problems.is_empty() {
groups.push(ExtensionGroup {
id: "problems".into(),
label: tr(locale, MessageId::ExtensionsGroupProblems).into_owned(),
items: problems,
});
}
ExtensionsTabModel {
groups,
problem: app.plugin_registry.state_error().map(ToString::to_string),
@@ -906,23 +947,29 @@ fn mcp_model(app: &App, locale: Locale) -> ExtensionsTabModel {
tr(locale, MessageId::PickerActionConfigured)
}
.into_owned();
let action = if !enabled
&& name
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.'))
{
let oauth_capable = config.is_some_and(crate::mcp::mcp_server_oauth_capable);
let recovery = crate::mcp::mcp_recovery_kind(
enabled,
observed.is_some(),
observed.is_some_and(|server| server.connected),
observed.and_then(|server| server.error.as_deref()),
oauth_capable,
);
let action = if crate::mcp::mcp_name_is_command_safe(&name)
|| matches!(
recovery,
crate::mcp::McpRecoveryKind::Connect
| crate::mcp::McpRecoveryKind::Reconnect
| crate::mcp::McpRecoveryKind::Diagnose
) {
ExtensionAction::Command {
label: tr(locale, MessageId::ExtensionsActionEnable).into_owned(),
command: format!("/mcp enable {name}"),
}
} else if enabled && observed.is_none() {
ExtensionAction::Command {
label: tr(locale, MessageId::ExtensionsActionReload).into_owned(),
command: "/mcp reload".into(),
label: tr(locale, recovery.label_key()).into_owned(),
command: recovery.slash_command(&name),
}
} else {
ExtensionAction::Status {
label: state.clone(),
ExtensionAction::Command {
label: tr(locale, MessageId::ExtensionsActionDiagnose).into_owned(),
command: "/mcp validate".into(),
}
};
ExtensionItem {
@@ -1506,3 +1553,26 @@ impl ModalView for ExtensionsView {
self
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mcp::McpRecoveryKind;
#[test]
fn mcp_item_action_for_stale_oauth_is_login() {
let recovery =
crate::mcp::mcp_recovery_kind(true, true, false, Some("401 Unauthorized"), true);
assert_eq!(recovery, McpRecoveryKind::Reauth);
assert_eq!(recovery.slash_command("github"), "/mcp login github");
assert_eq!(tr(Locale::En, recovery.label_key()).as_ref(), "re-auth");
}
#[test]
fn mcp_item_action_for_disconnected_server_is_reconnect() {
let recovery = crate::mcp::mcp_recovery_kind(true, true, false, None, false);
assert_eq!(recovery, McpRecoveryKind::Reconnect);
assert_eq!(recovery.slash_command("playwright"), "/mcp reload");
assert_eq!(tr(Locale::En, recovery.label_key()).as_ref(), "reconnect");
}
}
+160 -3
View File
@@ -1386,7 +1386,13 @@ impl SettingsRegistry {
SettingKind::ReadOnly
} else if matches!(
row.key.as_str(),
"provider" | "model" | "provider_templates"
"provider"
| "model"
| "provider_templates"
| "mcp_open"
| "mcp_reconnect"
| "mcp_diagnose"
| "plugins_open"
) {
SettingKind::Action
} else if config_boolean_key(&row.key) {
@@ -1570,6 +1576,7 @@ pub struct ConfigView {
last_render_scroll: Cell<usize>,
last_row_hitboxes: RefCell<Vec<(u16, usize)>>,
last_choice_hitboxes: RefCell<Vec<(u16, usize)>>,
last_tab_hitboxes: RefCell<Vec<(u16, u16, u16, ConfigTab)>>,
last_mouse_selected: Option<usize>,
api_provider: ApiProvider,
route_base_url: String,
@@ -2097,6 +2104,34 @@ impl ConfigView {
editable: true,
scope: ConfigScope::Saved,
},
ConfigRow {
section: ConfigSection::Mcp,
key: "mcp_open".to_string(),
value: "/mcp".to_string(),
editable: true,
scope: ConfigScope::Session,
},
ConfigRow {
section: ConfigSection::Mcp,
key: "mcp_reconnect".to_string(),
value: "/mcp reload".to_string(),
editable: true,
scope: ConfigScope::Session,
},
ConfigRow {
section: ConfigSection::Mcp,
key: "mcp_diagnose".to_string(),
value: "/mcp validate".to_string(),
editable: true,
scope: ConfigScope::Session,
},
ConfigRow {
section: ConfigSection::Mcp,
key: "plugins_open".to_string(),
value: "/plugin".to_string(),
editable: true,
scope: ConfigScope::Session,
},
ConfigRow {
section: ConfigSection::Mcp,
key: "mcp_config_path".to_string(),
@@ -2215,6 +2250,7 @@ impl ConfigView {
last_render_scroll: Cell::new(0),
last_row_hitboxes: RefCell::new(Vec::new()),
last_choice_hitboxes: RefCell::new(Vec::new()),
last_tab_hitboxes: RefCell::new(Vec::new()),
last_mouse_selected: None,
api_provider: app.api_provider,
route_base_url: app.active_route_base_url.clone(),
@@ -2356,6 +2392,12 @@ impl ConfigView {
"locale" => "UI language. Scope: saved settings.",
"reasoning_effort" => "Default reasoning effort for capable models.",
"default_mode" => "Startup mode (agent / plan / operate).",
"mcp_open" => {
"Open the MCP manager. Connect, reconnect, re-auth, or diagnose from there."
}
"mcp_reconnect" => "Reconnect configured MCP servers (/mcp reload).",
"mcp_diagnose" => "Diagnose MCP servers (/mcp validate).",
"plugins_open" => "Open plugins. Trust, enable, or diagnose invalid manifests.",
_ => "Enter change · R reset · Esc close. Scope shown on the row badge.",
}
}
@@ -2494,6 +2536,10 @@ impl ConfigView {
"provider" if row.editable => "/provider",
"provider_templates" if row.editable => "/provider templates",
"model" if row.editable => "/model",
"mcp_open" if row.editable => "/mcp",
"mcp_reconnect" if row.editable => "/mcp reload",
"mcp_diagnose" if row.editable => "/mcp validate",
"plugins_open" if row.editable => "/plugin",
_ => return None,
};
Some(ViewAction::Emit(ViewEvent::CommandPaletteSelected {
@@ -2832,6 +2878,14 @@ impl ConfigView {
MessageId::ConfigActionOpenProviderTemplates
} else if row.key == "model" {
MessageId::ConfigActionOpenModel
} else if row.key == "mcp_open" {
MessageId::ConfigActionOpenMcp
} else if row.key == "mcp_reconnect" {
MessageId::ConfigActionMcpReconnect
} else if row.key == "mcp_diagnose" {
MessageId::ConfigActionMcpDiagnose
} else if row.key == "plugins_open" {
MessageId::ConfigActionOpenPlugins
} else if meta.kind == SettingKind::Boolean {
MessageId::ConfigActionToggle
} else if meta.kind == SettingKind::Choice {
@@ -3013,6 +3067,10 @@ fn config_label_message(key: &str) -> Option<MessageId> {
"auto_compact" => MessageId::ConfigLabelAutoCompact,
"auto_compact_threshold_percent" => MessageId::ConfigLabelAutoCompactThreshold,
"max_history" => MessageId::ConfigLabelMaxHistory,
"mcp_open" => MessageId::ConfigLabelMcpOpen,
"mcp_reconnect" => MessageId::ConfigLabelMcpReconnect,
"mcp_diagnose" => MessageId::ConfigLabelMcpDiagnose,
"plugins_open" => MessageId::ConfigLabelPluginsOpen,
"mcp_config_path" => MessageId::ConfigLabelMcpConfigPath,
"fleet.exec.max_spawn_depth" => MessageId::ConfigLabelFleetSpawnDepth,
"goal_command" => MessageId::ConfigLabelGoalCommand,
@@ -3165,6 +3223,10 @@ fn config_literal_hint_for_key(key: &str) -> &'static str {
"reasoning_effort" => {
"Per-model thinking ladder from the active route. default clears the saved value and uses that model's official default. Always-thinking models omit off."
}
"mcp_open" => "open the MCP manager · Connect / Reconnect / Re-auth / Diagnose",
"mcp_reconnect" => "reconnect MCP · /mcp reload",
"mcp_diagnose" => "diagnose MCP · /mcp validate",
"plugins_open" => "open plugins · trust, enable, or diagnose",
"mcp_config_path" => "path to mcp.json",
"fleet.exec.max_spawn_depth" => {
"0 blocks child agents; 3 default (same axis as sub-agents); capped at 8"
@@ -3651,6 +3713,22 @@ impl ModalView for ConfigView {
return ViewAction::None;
}
let clicked_tab = self
.last_tab_hitboxes
.borrow()
.iter()
.find_map(|(x0, x1, y, tab)| {
(*y == mouse.row && mouse.column >= *x0 && mouse.column < *x1).then_some(*tab)
});
if let Some(tab) = clicked_tab {
if self.active_tab != tab {
self.active_tab = tab;
self.select_first_visible_row();
}
self.last_mouse_selected = None;
return ViewAction::None;
}
let selected = self
.last_row_hitboxes
.borrow()
@@ -3686,6 +3764,7 @@ impl ModalView for ConfigView {
render_underwater_surface(area, buf, self.tr(MessageId::ConfigModalTitle).to_string());
let (lines, footer) = if let Some(edit) = self.editing.as_ref() {
*self.last_choice_hitboxes.borrow_mut() = Vec::new();
*self.last_tab_hitboxes.borrow_mut() = Vec::new();
let footer_text = if edit.choices.is_some() {
if inner.width < 56 || inner.height <= 8 {
" ↑/↓ choose · Enter apply · Esc ".to_string()
@@ -3907,13 +3986,20 @@ impl ModalView for ConfigView {
]);
// Category tabs — app-style shell, not ASCII table headers.
let mut tab_spans = Vec::new();
let tab_y = inner.y.saturating_add(u16::from(!compact));
let mut tab_x = inner.x;
let mut tab_hitboxes = Vec::new();
for (i, tab) in ConfigTab::ALL.iter().enumerate() {
if i > 0 {
tab_spans.push(Span::styled(" ", Style::default()));
tab_x = tab_x.saturating_add(2);
}
let label = format!(" {} ", tab.label());
let width = UnicodeWidthStr::width(label.as_str()) as u16;
tab_hitboxes.push((tab_x, tab_x.saturating_add(width), tab_y, *tab));
let active = *tab == self.active_tab;
tab_spans.push(Span::styled(
format!(" {} ", tab.label()),
label,
if active {
Style::default()
.fg(palette::SELECTION_TEXT)
@@ -3923,7 +4009,9 @@ impl ModalView for ConfigView {
Style::default().fg(palette::TEXT_MUTED)
},
));
tab_x = tab_x.saturating_add(width);
}
*self.last_tab_hitboxes.borrow_mut() = tab_hitboxes;
let tab_line = Line::from(tab_spans);
let mut lines: Vec<Line> = if compact {
vec![tab_line, search_line]
@@ -4879,7 +4967,7 @@ fn fit_config_column(text: &str, width: usize) -> String {
#[cfg(test)]
mod tests {
use super::{
ActionHint, ConfigListItem, ConfigScope, ConfigTab, ConfigView, EmptyState,
ActionHint, ConfigListItem, ConfigScope, ConfigSection, ConfigTab, ConfigView, EmptyState,
FocusTextureMode, HelpView, ListDetailLayout, ModalKind, ModalView, SettingKind,
SettingsRegistry, ViewAction, ViewEvent, ViewStack, action_footer_lines,
canonical_config_choice, centered_modal_area, config_choice_detail, config_choice_label,
@@ -5682,6 +5770,10 @@ mod tests {
assert!(keys.contains(&"bracketed_paste"));
assert!(keys.contains(&"context_panel"));
assert!(keys.contains(&"cost_currency"));
assert!(keys.contains(&"mcp_open"));
assert!(keys.contains(&"mcp_reconnect"));
assert!(keys.contains(&"mcp_diagnose"));
assert!(keys.contains(&"plugins_open"));
assert!(keys.contains(&"mcp_config_path"));
assert!(keys.contains(&"fleet.exec.max_spawn_depth"));
assert!(keys.contains(&"features.vision_model"));
@@ -7002,6 +7094,10 @@ context_window = 262144
assert_eq!(kind_for("low_motion"), SettingKind::Boolean);
assert_eq!(kind_for("default_mode"), SettingKind::Choice);
assert_eq!(kind_for("mention_menu_limit"), SettingKind::Integer);
assert_eq!(kind_for("mcp_open"), SettingKind::Action);
assert_eq!(kind_for("mcp_reconnect"), SettingKind::Action);
assert_eq!(kind_for("mcp_diagnose"), SettingKind::Action);
assert_eq!(kind_for("plugins_open"), SettingKind::Action);
assert_eq!(kind_for("mcp_config_path"), SettingKind::Text);
assert_eq!(kind_for("fast_model"), SettingKind::ReadOnly);
@@ -7130,6 +7226,67 @@ context_window = 262144
assert!(view.editing.is_none());
}
#[test]
fn config_view_tabs_are_clickable() {
let app = create_test_app();
let mut view = ConfigView::new_for_app(&app);
assert_eq!(view.active_tab, ConfigTab::General);
let area = Rect::new(0, 0, 100, 30);
let mut buf = Buffer::empty(area);
view.render(area, &mut buf);
let hitboxes = view.last_tab_hitboxes.borrow().clone();
let (x0, _x1, y, tab) = hitboxes
.iter()
.copied()
.find(|(_, _, _, tab)| *tab == ConfigTab::Advanced)
.expect("Advanced tab should have a hitbox");
let action = view.handle_mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: x0.saturating_add(1),
row: y,
modifiers: KeyModifiers::NONE,
});
assert!(matches!(action, ViewAction::None));
assert_eq!(tab, ConfigTab::Advanced);
assert_eq!(view.active_tab, ConfigTab::Advanced);
assert!(
view.rows
.get(view.selected)
.is_some_and(|row| row.section == ConfigSection::Mcp
|| row.section == ConfigSection::Fleet
|| row.section == ConfigSection::Workflow
|| row.section == ConfigSection::Session
|| row.section == ConfigSection::Legacy
|| row.section == ConfigSection::Experimental)
);
}
#[test]
fn config_view_mcp_action_rows_run_existing_commands() {
let app = create_test_app();
let mut view = ConfigView::new_for_app(&app);
for (key, command) in [
("mcp_open", "/mcp"),
("mcp_reconnect", "/mcp reload"),
("mcp_diagnose", "/mcp validate"),
("plugins_open", "/plugin"),
] {
view.focus_key(key);
let action = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match action {
ViewAction::Emit(ViewEvent::CommandPaletteSelected {
action: CommandPaletteAction::ExecuteCommand { command: emitted },
}) => {
assert_eq!(emitted, command, "{key}");
assert!(!emitted.contains("/mcp auth"), "{key}");
}
other => panic!("{key} should run {command}, got {other:?}"),
}
}
}
#[test]
fn config_view_bottom_hint_semantically_truncates_at_narrow_width() {
// The dense bottom status line must truncate on a word boundary with an