diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e0c40b4f..e8ad58cec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,14 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename ## [Unreleased] +### TUI + +- `/connect` opens Add Provider from any layout. +- Option+A still opens Add Provider. Terminals that emit å/Å for Option+A + without the option modifier also open it in `/model` (US/ABC compose). + Dedicated Nordic å in `/model` is treated as that shortcut when Add + Provider is offered. + ### Fixed - Codex ChatGPT subscription sessions no longer show a public-rate dollar diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index 8cd03cb32..0a9881894 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -320,7 +320,7 @@ Profiles supply per-project or named-profile overrides for `model` and `systemPr Providers and credentials are read exclusively from settings files: the global `~/.corbits/settings.json` (definitions + credentials) and the per-repo `.corbits/settings.json` (selection only). There are no `OPENAI_COMPATIBLE_*` environment-variable overrides, and `index.ts` does not load `.env` files — a deliberately stale or exported key can no longer shadow the configured provider. -**Models-first connect.** There is no standalone `/login` command. `/model` opens on a flat **models-only** list (Recent, Favorites, then connected provider/model rows) built by `buildModelsFirstList` (`src/tui/model-picker.ts`); type-to-filter owns printable keys. Selecting a row runs `applyLiveModelSwitch` (`src/session/live-model-switch.ts`) so inference sources, permission-gate identity, grant persistence identity, and advertised tool schemas cut over together. **Alt+A** opens Connect via `addProviderSelectorChoices` (`src/tui/provider-setup.ts`), which lists every first-class kind including Custom — never bare `c` / Ctrl+A, and never in-list “connect →” rows. First-class API-key rows use a named-instance + auth-only form (instance name, key; catalog base URL is display-only); Custom keeps the full manual form. **Alt+F** toggles favorites; recent/favorite pairs live in global settings (`recentModels` / `favoriteModels`). **Alt+D** sets the default via `setDefaultModel` (global `defaultProvider` + that provider's `defaultModel`) plus `persistConnectedSelection` without switching the live session. First-class providers ship from `packages/first-class-providers` (corbits-agnostic defs) and `packages/opencode-go` (Go catalog, auth validate, multi-protocol endpoints, usage). OAuth providers open the existing browser login modal with a named account step; API-key providers share the same multi-instance naming and pre-seed models on save so selection works without restart. Both OAuth and API-key (including Custom) connects share `persistConnectedSelection` in `provider-setup-submit.ts` so project-local provider/model selection is written alongside global credentials. OpenCode Go forces `OPENCODE_GO_BASE_URL` when `opencodeGo` is set so subscription traffic is not billed as Zen PAYG. +**Models-first connect.** There is no standalone `/login` command. `/model` opens on a flat **models-only** list (Recent, Favorites, then connected provider/model rows) built by `buildModelsFirstList` (`src/tui/model-picker.ts`); type-to-filter owns printable keys. Selecting a row runs `applyLiveModelSwitch` (`src/session/live-model-switch.ts`) so inference sources, permission-gate identity, grant persistence identity, and advertised tool schemas cut over together. **Alt+A** or `/connect` opens Connect via `addProviderSelectorChoices` (`src/tui/provider-setup.ts`), which lists every first-class kind including Custom — never bare `c` / Ctrl+A, and never in-list “connect →” rows. First-class API-key rows use a named-instance + auth-only form (instance name, key; catalog base URL is display-only); Custom keeps the full manual form. **Alt+F** toggles favorites; recent/favorite pairs live in global settings (`recentModels` / `favoriteModels`). **Alt+D** sets the default via `setDefaultModel` (global `defaultProvider` + that provider's `defaultModel`) plus `persistConnectedSelection` without switching the live session. First-class providers ship from `packages/first-class-providers` (corbits-agnostic defs) and `packages/opencode-go` (Go catalog, auth validate, multi-protocol endpoints, usage). OAuth providers open the existing browser login modal with a named account step; API-key providers share the same multi-instance naming and pre-seed models on save so selection works without restart. Both OAuth and API-key (including Custom) connects share `persistConnectedSelection` in `provider-setup-submit.ts` so project-local provider/model selection is written alongside global credentials. OpenCode Go forces `OPENCODE_GO_BASE_URL` when `opencodeGo` is set so subscription traffic is not billed as Zen PAYG. **OpenCode Go multi-protocol.** Each Go model carries protocol metadata (`chat-completions`, `responses`, or `messages`). `buildGoSource` / `resolveGoEndpoint` pick the adapter and base URL per model (not a single provider-wide OpenAI route). When Go is the active provider, subscription usage is fetched for the status bar and omitted on auth/network failure. diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index 6c49868e2..0adf991da 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -103,11 +103,11 @@ recovery line instead of dumping the file path and parse details. ## Slash Commands (TUI) -The TUI has an extensible slash-command framework. Built-ins include `/help` (shortcut + command overlay), `/model` (models-only picker for connected accounts; **Alt+A** adds a provider), `/settings`, `/permissions`, `/plugins`, `/clear`, `/new`, `/mcp`, and `/yolo` (persists as the user-global skip-permissions default; `--dangerously-skip-permissions` still forces this process; secret-guard and authz still apply; `/yolo [on|off|toggle]`, bare `/yolo` toggles), plus a `/` command per available workflow. When a session starts with the persisted default already on, the TUI shows a startup notice ("Permission prompts are disabled by your saved default…") so the silent machine-wide default is never invisible; `corbits exec` prints the equivalent warning to stderr. Plugins can register additional commands. +The TUI has an extensible slash-command framework. Built-ins include `/help` (shortcut + command overlay), `/model` (models-only picker for connected accounts; **Alt+A** or `/connect` adds a provider), `/settings`, `/permissions`, `/plugins`, `/clear`, `/new`, `/mcp`, and `/yolo` (persists as the user-global skip-permissions default; `--dangerously-skip-permissions` still forces this process; secret-guard and authz still apply; `/yolo [on|off|toggle]`, bare `/yolo` toggles), plus a `/` command per available workflow. When a session starts with the persisted default already on, the TUI shows a startup notice ("Permission prompts are disabled by your saved default…") so the silent machine-wide default is never invisible; `corbits exec` prints the equivalent warning to stderr. Plugins can register additional commands. **Default skills** exist out of the gate as first-party slash **actions**, not director names: `/implement`, `/plan`, `/refactor`, `/review`, `/pull-request-review`, `/create-issue`, `/scribe`, `/interview`, `/ast-grep`. Each one is a how-to playbook — the slash sends the skill body to the primary, which follows the steps. Skills do not assign identity or route the fleet; that stays on director system prompts. `/review` is how to review a branch; `/scribe` is how to maintain PRODUCT / ARCHITECTURE / IMPLEMENTATION; `/implement` is the per-commit review/build/critique loop; `/plan` authors an eng change plan (files, AC, non-goals, risks, ordered steps) and does not implement. `/create-issue` remains the tracker command: Linear MCP when available; otherwise it `ask_operator`s for the platform (GitHub etc.) and persists `Preferred issue tracker` in `.corbits/MEMORY.md` (GitHub via `gh issue create`). There is no first-party dispatch skill — Skywalker orchestrates natively. `git-rebase`, `linear-issue-workflow`, `style`, `philosophy`, `typescript`, and `opsh` stay `use_skill` only (`user-invocable: false`). Draper and emil are not slashes; they remain closed directors via `task(agent=…)`. There is no catch-all worker. Slash names are also available to the model via `use_skill`. Disable the catalog in `/plugins` (`corbits-skills`) if you want them gone. -Providers are **models-first**: there is no standalone `/login` command. `/model` opens a **models-only list** (Recent, Favorites, then connected provider/model rows) — type-to-filter owns printable keys, so Connect is never a bare letter. **Alt+A** opens a dedicated add-provider selector over every first-class kind (OpenAI dual-path ChatGPT OAuth or API key, xAI, OpenCode Zen, Anthropic, Google, OpenCode Go, Z.AI Coding Plan, Ollama, Custom), each annotated with its live account count and never filtered out for “already connected.” **Alt+F** toggles favorite on the highlighted model. **Alt+D** persists the highlighted pair as the default without switching the live session. Advanced provider drill-down (edit/delete/tiers) stays on the advanced surface, not a bare printable key while the model list is filtering. OAuth providers open their existing browser login with a named account step so multiple accounts per kind coexist (`codex/work`, …). API-key providers use the same named-instance step before the key (auth-only form: instance name + key + fixed catalog base URL), so personal and team keys land as distinct catalog rows (`openai/default`, `anthropic/work`, …); reusing a name re-keys that instance after confirm. Custom remains a free-form single endpoint (full manual form). Successful connect refreshes the catalog and reopens the model list focused on the new account’s default model. OpenCode Go routes each model by its protocol metadata (chat completions, OpenAI responses, or Anthropic messages) and can show subscription usage in the status bar when active (rolling 5h / weekly / monthly windows when the usage API responds; omitted on auth or network failure). When Go returns a quota or rate-limit error — including some HTTP 400 responses that carry limit payloads — Corbits classifies them so quota aborts cleanly and short provider rate limits remain retryable. On a free-tier or subscription quota hit, wait for the window to reset or use OpenCode Zen free models. +Providers are **models-first**: there is no standalone `/login` command. `/model` opens a **models-only list** (Recent, Favorites, then connected provider/model rows) — type-to-filter owns printable keys, so Connect is never a bare letter. **Alt+A** or `/connect` opens a dedicated add-provider selector over every first-class kind (OpenAI dual-path ChatGPT OAuth or API key, xAI, OpenCode Zen, Anthropic, Google, OpenCode Go, Z.AI Coding Plan, Ollama, Custom), each annotated with its live account count and never filtered out for “already connected.” **Alt+F** toggles favorite on the highlighted model. **Alt+D** persists the highlighted pair as the default without switching the live session. Advanced provider drill-down (edit/delete/tiers) stays on the advanced surface, not a bare printable key while the model list is filtering. OAuth providers open their existing browser login with a named account step so multiple accounts per kind coexist (`codex/work`, …). API-key providers use the same named-instance step before the key (auth-only form: instance name + key + fixed catalog base URL), so personal and team keys land as distinct catalog rows (`openai/default`, `anthropic/work`, …); reusing a name re-keys that instance after confirm. Custom remains a free-form single endpoint (full manual form). Successful connect refreshes the catalog and reopens the model list focused on the new account’s default model. OpenCode Go routes each model by its protocol metadata (chat completions, OpenAI responses, or Anthropic messages) and can show subscription usage in the status bar when active (rolling 5h / weekly / monthly windows when the usage API responds; omitted on auth or network failure). When Go returns a quota or rate-limit error — including some HTTP 400 responses that carry limit payloads — Corbits classifies them so quota aborts cleanly and short provider rate limits remain retryable. On a free-tier or subscription quota hit, wait for the window to reset or use OpenCode Zen free models. Ollama is a first-class, keyless local provider: users can run Corbits against local models without creating a cloud account or storing an API key. Local availability is treated gracefully during setup — an Ollama server that is not running yet is an expected, nonfatal state, with clear retry and server-address editing paths. Once Ollama is reachable, Corbits distinguishes a server with no models from an invalid response so the user gets the relevant recovery guidance. The only prerequisite guidance Corbits gives is to start Ollama and pull at least one model; installation remains Ollama's concern. diff --git a/docs/TUI.md b/docs/TUI.md index 6c721b538..c796aa4ec 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -410,15 +410,20 @@ not-yet-configured provider kind, filtered out once that kind had any connected account. That filtering made a second OAuth account (a second Codex or xAI login) unreachable — OAuth accounts are per-profile, so kind-level "already connected" filtering hid the connect path the moment the -first profile existed. **Alt+A** now opens `add_provider` +first profile existed. **Alt+A** (US-style Option+A, including composed å/Å) +and **/connect** now open `add_provider` (`src/tui/overlays.ts:openAddProviderOverlay`), a separate `PrimaryOverlayKind` listing every first-class provider kind from `providerChoices()` — OAuth, API-key, keyless local, and Custom alike — each annotated with its live -connected-account count and none of them filtered out. Custom uses the full -manual form (name, base URL, key, model); OAuth and API-key kinds keep their -auth-only or browser login paths, while Ollama has a keyless local setup path. -Esc returns to the model list through the same -`openModels()` entry point the picker itself uses. Picking a row runs the +connected-account count and none of them filtered out. `/connect` is the +layout-proof path: layouts whose Option+A is not å/Å still type a printable +glyph, so Alt+A is a dead chord there. Custom uses the full manual form +(name, base URL, key, model); OAuth and API-key kinds keep their auth-only +or browser login paths, while Ollama has a keyless local setup path. Esc +after Alt+A from `/model` returns to the model list through the same +`openModels()` entry point the picker itself uses. Esc after `/connect` +from a closed prompt dismisses the selector without reopening `/model`. +Picking a row runs the existing inline connect flow (`provider-connect.ts`); first-class kinds (OAuth and API-key) both ask for an instance/account name before auth so multiple instances coexist as `kind/slug` catalog rows, and reusing a name confirms diff --git a/src/tui/command-surfaces.test.ts b/src/tui/command-surfaces.test.ts index 2c39997a3..dfd1093ba 100644 --- a/src/tui/command-surfaces.test.ts +++ b/src/tui/command-surfaces.test.ts @@ -622,6 +622,22 @@ describe("model surface", () => { }); }); +describe("add-provider surface", () => { + test("routes to the host opener, and reports the gap when absent", async () => { + await withShell((shell) => { + let opened = 0; + expect( + openCommandSurface(shell, "add-provider", { + notify: () => {}, + openAddProvider: () => opened++, + }), + ).toBe(true); + expect(opened).toBe(1); + expect(openCommandSurface(shell, "add-provider", { notify: () => {} })).toBe(false); + }); + }); +}); + describe("help surface", () => { test("opens the keymap overlay", async () => { await withShell((shell) => { diff --git a/src/tui/command-surfaces.ts b/src/tui/command-surfaces.ts index f3e89cdf8..40b66085c 100644 --- a/src/tui/command-surfaces.ts +++ b/src/tui/command-surfaces.ts @@ -167,13 +167,15 @@ export interface CommandSurfaceDeps { readonly settings?: SettingsSurfaceDeps; /** Opens the host's model/provider picker (owned by the product host). */ readonly openModels?: () => void; + /** Opens the host's add-provider selector (owned by the product host). `/connect` omits returnToModels. */ + readonly openAddProvider?: (opts?: { returnToModels?: boolean }) => void; /** Fallback channel for surfaces with no live data source. */ readonly notify: (text: string) => void; } /** Surface a command result can ask for. */ export type CommandSurfaceKind = - "help" | "settings" | "permissions" | "plugins" | "hooks" | "mcp" | "models"; + "help" | "settings" | "permissions" | "plugins" | "hooks" | "mcp" | "models" | "add-provider"; const CLOSE_ID = "__close__"; const BACK_ID = "__back__"; @@ -1034,5 +1036,9 @@ export function openCommandSurface( if (deps.openModels === undefined) return false; deps.openModels(); return true; + case "add-provider": + if (deps.openAddProvider === undefined) return false; + deps.openAddProvider(); + return true; } } diff --git a/src/tui/commands/built-in.test.ts b/src/tui/commands/built-in.test.ts index 58cb506b2..896d27a3d 100644 --- a/src/tui/commands/built-in.test.ts +++ b/src/tui/commands/built-in.test.ts @@ -36,11 +36,24 @@ describe("removed commands", () => { expect(getCommand("scope")).toBeUndefined(); }); - it("/login is not registered (connect from /model)", () => { + it("/login is not registered (connect from /model or /connect)", () => { expect(getCommand("login")).toBeUndefined(); }); }); +describe("/connect command", () => { + it("is registered", () => { + expect(getCommand("connect")).toBeDefined(); + }); + + it("requests the add-provider overlay", () => { + expect(getCommand("connect")!.handler("", makeCtx())).toEqual({ + type: "overlay", + overlay: "add-provider", + }); + }); +}); + describe("/status command", () => { it("answers from the live fleet without sending anything to the model", () => { const ctx: CommandContext = { diff --git a/src/tui/commands/built-in.ts b/src/tui/commands/built-in.ts index 41540a172..d9bab7435 100644 --- a/src/tui/commands/built-in.ts +++ b/src/tui/commands/built-in.ts @@ -51,9 +51,13 @@ export function registerBuiltInCommands(): void { handler: (_args, _ctx) => ({ type: "overlay", overlay: "hooks" }), }); - // Models-first connect: providers are connected from /model via the Alt+A - // add-provider selector, not a standalone /login picker. The OAuth sign-in - // surface is reachable only through that connect flow. + // Layout-proof add-provider path: `/` works on every keyboard. There is no + // standalone /login; OAuth sign-in is still reached only through this flow. + registerCommand({ + name: "connect", + description: "Add a provider account", + handler: () => ({ type: "overlay", overlay: "add-provider" }), + }); // signalClear rotates to a fresh session: the on-screen transcript and run // telemetry are reset and the agent is rebuilt against a new state directory, diff --git a/src/tui/commands/registry.ts b/src/tui/commands/registry.ts index e94ec7355..ac76ca4ff 100644 --- a/src/tui/commands/registry.ts +++ b/src/tui/commands/registry.ts @@ -33,7 +33,10 @@ export type CommandResult = | { type: "message"; text: string } | { type: "send"; text: string } | { type: "view"; view: "tasks" } - | { type: "overlay"; overlay: "help" | "permissions" | "plugins" | "settings" | "hooks" | "mcp" } + | { + type: "overlay"; + overlay: "help" | "permissions" | "plugins" | "settings" | "hooks" | "mcp" | "add-provider"; + } | { type: "modal"; modal: "agent" | "codex-login" | "xai-login" } | { type: "workflow"; name: string; args?: string } | { type: "paste-image" } diff --git a/src/tui/overlay-paint.test.ts b/src/tui/overlay-paint.test.ts index 97be72978..fc34dd41e 100644 --- a/src/tui/overlay-paint.test.ts +++ b/src/tui/overlay-paint.test.ts @@ -100,14 +100,14 @@ describe("overlay host never shares cells with the prompt border", () => { kind: "model_picker", title: "model", items: ITEMS, - // Mirror production /model, which always wires Alt+A. + // Mirror production /model, which always wires add-provider. addProviderHint: true, }), size, ); const expected = [ - " model · Esc cancel · Enter choose · Alt+A add provider", + " model · Esc cancel · Enter choose · Alt+A /connect add provider", ` > ${ITEMS[0]}`, ...ITEMS.slice(1).map((i) => ` ${i}`), ]; diff --git a/src/tui/overlays.ts b/src/tui/overlays.ts index 354b3a847..4dbb3c71f 100644 --- a/src/tui/overlays.ts +++ b/src/tui/overlays.ts @@ -175,7 +175,7 @@ export interface OpenModelPickerOpts { * as you type. Off by default so other list overlays keep j/k. */ readonly typeToFilter?: boolean; - /** Advertise Alt+A in the footer — only when the caller wired the handler. */ + /** Advertise Alt+A /connect in the footer — only when the caller wired the handler. */ readonly addProviderHint?: boolean; /** Advertise Alt+D in the footer — only when the caller wired the handler. */ readonly setDefaultHint?: boolean; @@ -208,7 +208,7 @@ export interface OpenAddProviderOpts { readonly onAccept?: (selection: OverlaySelection) => void; /** Description-zone source, keyed by the focused row's id. */ readonly describe?: (itemId: string) => ItemDescription | null; - /** Per-open Esc/dismiss — the caller returns to the model list. */ + /** Per-open Esc/dismiss. Set when Esc should return to the model list (Alt+A). */ readonly onCancel?: () => void; } diff --git a/src/tui/product-host.test.ts b/src/tui/product-host.test.ts index e9e1c4388..6df498c3c 100644 --- a/src/tui/product-host.test.ts +++ b/src/tui/product-host.test.ts @@ -10,7 +10,7 @@ import { AGENTS_PANEL_LINGER_MS } from "./chrome-state.js"; import { createHarness } from "./harness.js"; import { acceptOverlaySelection, - closeInsetOverlay, + handleListFilterKey, moveOverlaySelection, runOverlayAction, } from "./shell.js"; @@ -689,7 +689,7 @@ describe("flat type-to-filter model picker", () => { const altA = { name: "a", ctrl: false, meta: false, option: true } as KeyEvent; - test("the model picker footer advertises Alt+A", async () => { + test("the model picker footer advertises Alt+A and /connect", async () => { const { harness, host } = await mountPicker({ // The hint requires the full wiring — choices AND the connect handler — // because that is exactly when the key actually works. @@ -699,7 +699,9 @@ describe("flat type-to-filter model picker", () => { try { host.openModels?.(); await harness.renderOnce(); - expect(harness.captureCharFrame()).toContain("Alt+A"); + const frame = harness.captureCharFrame(); + expect(frame).toContain("Alt+A"); + expect(frame).toContain("/connect"); } finally { host.dispose(); harness.destroy(); @@ -732,6 +734,206 @@ describe("flat type-to-filter model picker", () => { } }); + test("composed Option+A (å) opens add-provider and is not claimed by type-to-filter", async () => { + // Terminals may deliver Option+A as å/Å without meta/option. + const { harness, host } = await mountPicker({ + onConnectProvider: () => {}, + addProviderChoices: () => [{ id: "codex", label: "Codex", hint: "", accountCount: 0 }], + }); + try { + host.openModels?.(); + await harness.renderOnce(); + const composed = { + name: "å", + sequence: "å", + ctrl: false, + meta: false, + option: false, + } as KeyEvent; + expect(handleListFilterKey(host.shell, composed)).toBe(false); + expect(runOverlayAction(host.shell, composed)).toBe(true); + expect(host.shell.overlayKind).toBe("add_provider"); + } finally { + host.dispose(); + harness.destroy(); + } + }); + + test("composed Option+A (Å) opens add-provider and is not claimed by type-to-filter", async () => { + const { harness, host } = await mountPicker({ + onConnectProvider: () => {}, + addProviderChoices: () => [{ id: "codex", label: "Codex", hint: "", accountCount: 0 }], + }); + try { + host.openModels?.(); + await harness.renderOnce(); + const composed = { + name: "Å", + sequence: "Å", + ctrl: false, + meta: false, + option: false, + } as KeyEvent; + expect(handleListFilterKey(host.shell, composed)).toBe(false); + expect(runOverlayAction(host.shell, composed)).toBe(true); + expect(host.shell.overlayKind).toBe("add_provider"); + } finally { + host.dispose(); + harness.destroy(); + } + }); + + test("composed å through the key path opens add-provider", async () => { + const { harness, host } = await mountPicker({ + onConnectProvider: () => {}, + addProviderChoices: () => [{ id: "codex", label: "Codex", hint: "", accountCount: 0 }], + }); + try { + host.openModels?.(); + await harness.renderOnce(); + harness.pressKey("å"); + await harness.renderOnce(); + expect(host.shell.overlayKind).toBe("add_provider"); + } finally { + host.dispose(); + harness.destroy(); + } + }); + + test("closed-prompt å stays in the prompt and does not open add-provider", async () => { + const { harness, host } = await mountPicker({ + onConnectProvider: () => {}, + addProviderChoices: () => [{ id: "codex", label: "Codex", hint: "", accountCount: 0 }], + }); + try { + expect(host.shell.overlayKind).toBeNull(); + harness.pressKey("å"); + await harness.renderOnce(); + expect(host.shell.overlayKind).toBeNull(); + expect(host.shell.prompt.value).toContain("å"); + } finally { + host.dispose(); + harness.destroy(); + } + }); + + test("other composed glyphs still type-to-filter in the model picker", async () => { + const { harness, host } = await mountPicker({ + onConnectProvider: () => {}, + addProviderChoices: () => [{ id: "codex", label: "Codex", hint: "", accountCount: 0 }], + }); + try { + host.openModels?.(); + await harness.renderOnce(); + for (const glyph of ["ø", "ä", "æ"] as const) { + const composed = { + name: glyph, + sequence: glyph, + ctrl: false, + meta: false, + option: false, + } as KeyEvent; + expect(handleListFilterKey(host.shell, composed)).toBe(true); + expect(host.shell.overlayKind).toBe("model_picker"); + } + } finally { + host.dispose(); + harness.destroy(); + } + }); + + test("sequence-only å with name a opens add-provider from the model picker", async () => { + // Terminals can report Option+A as sequence å while name stays ASCII a + // and option/meta stay false (#482). + const { harness, host } = await mountPicker({ + onConnectProvider: () => {}, + addProviderChoices: () => [{ id: "codex", label: "Codex", hint: "", accountCount: 0 }], + }); + try { + host.openModels?.(); + await harness.renderOnce(); + const sequenceOnly = { + name: "a", + sequence: "å", + ctrl: false, + meta: false, + option: false, + } as KeyEvent; + expect(handleListFilterKey(host.shell, sequenceOnly)).toBe(false); + expect(runOverlayAction(host.shell, sequenceOnly)).toBe(true); + expect(host.shell.overlayKind).toBe("add_provider"); + } finally { + host.dispose(); + harness.destroy(); + } + }); + + test("composed å still type-to-filters when add-provider is not wired", async () => { + const { harness, host } = await mountPicker(); + try { + host.openModels?.(); + await harness.renderOnce(); + const composed = { + name: "å", + sequence: "å", + ctrl: false, + meta: false, + option: false, + } as KeyEvent; + expect(handleListFilterKey(host.shell, composed)).toBe(true); + expect(host.shell.overlayKind).toBe("model_picker"); + } finally { + host.dispose(); + harness.destroy(); + } + }); + + test("bare ASCII a still type-to-filters when add-provider is wired", async () => { + const { harness, host } = await mountPicker({ + onConnectProvider: () => {}, + addProviderChoices: () => [{ id: "codex", label: "Codex", hint: "", accountCount: 0 }], + }); + try { + host.openModels?.(); + await harness.renderOnce(); + const letter = { + name: "a", + sequence: "a", + ctrl: false, + meta: false, + option: false, + } as KeyEvent; + expect(handleListFilterKey(host.shell, letter)).toBe(true); + expect(host.shell.overlayKind).toBe("model_picker"); + } finally { + host.dispose(); + harness.destroy(); + } + }); + + test("ordinary letters still type-to-filter when add-provider is wired", async () => { + const { harness, host } = await mountPicker({ + onConnectProvider: () => {}, + addProviderChoices: () => [{ id: "codex", label: "Codex", hint: "", accountCount: 0 }], + }); + try { + host.openModels?.(); + await harness.renderOnce(); + const letter = { + name: "g", + sequence: "g", + ctrl: false, + meta: false, + option: false, + } as KeyEvent; + expect(handleListFilterKey(host.shell, letter)).toBe(true); + expect(host.shell.overlayKind).toBe("model_picker"); + } finally { + host.dispose(); + harness.destroy(); + } + }); + test("Enter on a Custom add-provider row runs the connect flow for custom", async () => { const connected: string[] = []; const { harness, host } = await mountPicker({ @@ -768,7 +970,8 @@ describe("flat type-to-filter model picker", () => { runOverlayAction(host.shell, altA); await harness.renderOnce(); expect(host.shell.overlayKind).toBe("add_provider"); - closeInsetOverlay(host.shell); + harness.pressKey("Escape"); + await new Promise((r) => setTimeout(r, 60)); await harness.renderOnce(); expect(host.shell.overlayKind).toBe("model_picker"); expect(host.shell.overlayItems).toEqual(modelItems); @@ -778,6 +981,62 @@ describe("flat type-to-filter model picker", () => { } }); + test("Esc after openAddProvider from a closed prompt does not reopen the model list", async () => { + const { harness, host } = await mountPicker({ + onConnectProvider: () => {}, + addProviderChoices: () => [{ id: "codex", label: "Codex", hint: "", accountCount: 1 }], + }); + try { + expect(host.shell.overlayKind).toBeNull(); + host.openAddProvider?.(); + await harness.renderOnce(); + expect(host.shell.overlayKind).toBe("add_provider"); + harness.pressKey("Escape"); + await new Promise((r) => setTimeout(r, 60)); + await harness.renderOnce(); + expect(host.shell.overlayKind).not.toBe("model_picker"); + expect(host.shell.overlayKind).toBeNull(); + } finally { + host.dispose(); + harness.destroy(); + } + }); + + test("typed /connect then Enter opens add-provider and Esc leaves overlay null", async () => { + const queued: { open?: () => void } = {}; + const { harness, host } = await mountPicker({ + onConnectProvider: () => {}, + addProviderChoices: () => [{ id: "codex", label: "Codex", hint: "", accountCount: 1 }], + commands: [ + { + id: "connect", + label: "/connect", + description: "Add a provider account", + keywords: ["connect", "Add a provider account", "slash", "command"], + }, + ], + onCommand: (name) => { + if (name === "connect") queued.open?.(); + }, + }); + queued.open = () => host.openAddProvider?.(); + try { + expect(host.shell.overlayKind).toBeNull(); + for (const ch of "/connect") harness.pressKey(ch); + await harness.renderOnce(); + harness.pressKey("Enter"); + await harness.renderOnce(); + expect(host.shell.overlayKind).toBe("add_provider"); + harness.pressKey("Escape"); + await new Promise((r) => setTimeout(r, 60)); + await harness.renderOnce(); + expect(host.shell.overlayKind).toBeNull(); + } finally { + host.dispose(); + harness.destroy(); + } + }); + test("Enter on an add-provider row runs the connect flow for that provider", async () => { const connected: string[] = []; const { harness, host } = await mountPicker({ @@ -824,6 +1083,32 @@ describe("flat type-to-filter model picker", () => { } }); + test("openAddProvider opens the add-provider selector when choices are wired", async () => { + const { harness, host } = await mountPicker({ + onConnectProvider: () => {}, + addProviderChoices: () => [{ id: "codex", label: "Codex", hint: "", accountCount: 0 }], + }); + try { + host.openAddProvider?.(); + await harness.renderOnce(); + expect(host.shell.overlayKind).toBe("add_provider"); + expect(host.shell.overlayItems).toEqual(["Codex — 0 accounts"]); + } finally { + host.dispose(); + harness.destroy(); + } + }); + + test("openAddProvider is absent when add-provider is not wired", async () => { + const { harness, host } = await mountPicker(); + try { + expect(host.openAddProvider).toBeUndefined(); + } finally { + host.dispose(); + harness.destroy(); + } + }); + test("openModels(focusId) preselects the given row instead of the top of the list", async () => { const { harness, host } = await mountPicker(); try { diff --git a/src/tui/product-host.ts b/src/tui/product-host.ts index 915411e7b..7ff1ac0c4 100644 --- a/src/tui/product-host.ts +++ b/src/tui/product-host.ts @@ -43,6 +43,7 @@ import { clearTranscript, closeInsetOverlay, createAppShell, + isAddProviderShortcutKey, paintChrome, setChromeZones, setHeader, @@ -204,6 +205,13 @@ export interface ProductHost { * default model) instead of the top of the list. */ readonly openModels?: (focusId?: string) => void; + /** + * Opens the add-provider selector; absent when connect choices are not wired. + * Pass `returnToModels: true` when opening from the model picker (Alt+A) so + * Esc returns there. `/connect` and other closed-prompt callers omit it so + * Esc dismisses to a closed overlay. + */ + readonly openAddProvider?: (opts?: { returnToModels?: boolean }) => void; /** Swap the picker's rows/descriptions in place (e.g. after a provider connects). */ readonly setModels?: ( models: readonly ProductHostModelOption[], @@ -513,6 +521,7 @@ export async function mountProductHost(config: ProductHostConfig): Promise void) | undefined; + let openAddProvider: ((opts?: { returnToModels?: boolean }) => void) | undefined; if (config.onModelSelect) { const onSelect = config.onModelSelect; const onConnect = config.onConnectProvider; @@ -527,9 +536,9 @@ export async function mountProductHost(config: ProductHostConfig): Promise { + ? (opts?: { returnToModels?: boolean }): void => { const rows = addProviderChoices(); closeInsetOverlay(shell); openAddProviderOverlay(shell, { @@ -552,9 +561,10 @@ export async function mountProductHost(config: ProductHostConfig): Promise openModels?.(), + // Alt+A from the model picker: Esc returns through the same entry + // point Alt+A itself, /model, and a completed connect all use. + // /connect from a closed prompt omits this so Esc dismisses. + ...(opts?.returnToModels === true ? { onCancel: () => openModels?.() } : {}), }); } : undefined; @@ -585,13 +595,16 @@ export async function mountProductHost(config: ProductHostConfig): Promise { - if (key.ctrl || !(key.meta || key.option)) return false; - const name = typeof key.name === "string" ? key.name.toLowerCase() : ""; - // Alt+A / Alt+F / Alt+D, never bare — type-to-filter claims printable keys. - if (name === "a" && openAddProvider !== undefined) { - openAddProvider(); + if (key.ctrl) return false; + // Alt+A / composed Option+A (å/Å) — never bare ASCII `a`; + // type-to-filter claims ordinary printables. + if (openAddProvider !== undefined && isAddProviderShortcutKey(key)) { + openAddProvider({ returnToModels: true }); return true; } + if (!(key.meta || key.option)) return false; + const name = typeof key.name === "string" ? key.name.toLowerCase() : ""; + // Alt+F / Alt+D, never bare — type-to-filter claims printable keys. if (name === "f" && onFavoriteToggle !== undefined) { // Empty id is the "(no matches)" filter sentinel — not a model. if (itemId.length === 0) return false; @@ -640,5 +653,6 @@ export async function mountProductHost(config: ProductHostConfig): Promise setHeader(shell, title), pushObserveRow: (row) => appendObserveStreamRow(shell, row), ...(openModels !== undefined ? { openModels, setModels } : {}), + ...(openAddProvider !== undefined ? { openAddProvider } : {}), }; } diff --git a/src/tui/runner-host.test.ts b/src/tui/runner-host.test.ts index 51562fa67..f8eef0556 100644 --- a/src/tui/runner-host.test.ts +++ b/src/tui/runner-host.test.ts @@ -387,6 +387,62 @@ describe("mountRunnerHost model picker", () => { harness.destroy(); } }); + + test("openSurface add-provider opens the selector when choices are wired", async () => { + const harness = await createHarness({ width: 80, height: 24 }); + const host = await mountRunnerHost({ + title: "test", + eventEmitter: new EventEmitter(), + send: () => {}, + interrupt: () => {}, + providers: { xai: { models: ["grok-4"] } }, + onModelSelect: () => {}, + onConnectProvider: () => {}, + addProviderChoices: () => [ + { id: "codex", label: "Codex", hint: "", accountCount: 1 }, + { id: "openai", label: "OpenAI", hint: "", accountCount: 0 }, + ], + commands: [], + onCommand: () => {}, + chrome: () => ({ agents: [] }), + subscribeChrome: () => () => {}, + subAgentSessions: () => [], + createRenderer: async () => harness.renderer, + }); + try { + expect(host.openSurface("add-provider")).toBe(true); + expect(host.shell.overlayKind).toBe("add_provider"); + expect(host.shell.overlayItems).toEqual(["Codex — 1 account", "OpenAI — 0 accounts"]); + } finally { + host.dispose(); + harness.destroy(); + } + }); + + test("openSurface add-provider returns false when add-provider is not wired", async () => { + const harness = await createHarness({ width: 80, height: 24 }); + const host = await mountRunnerHost({ + title: "test", + eventEmitter: new EventEmitter(), + send: () => {}, + interrupt: () => {}, + providers: { xai: { models: ["grok-4"] } }, + onModelSelect: () => {}, + commands: [], + onCommand: () => {}, + chrome: () => ({ agents: [] }), + subscribeChrome: () => () => {}, + subAgentSessions: () => [], + createRenderer: async () => harness.renderer, + }); + try { + expect(host.openSurface("add-provider")).toBe(false); + expect(host.shell.overlayKind).not.toBe("add_provider"); + } finally { + host.dispose(); + harness.destroy(); + } + }); }); describe("bottom border cost run", () => { diff --git a/src/tui/runner-host.ts b/src/tui/runner-host.ts index f97cc6205..f8e11bde5 100644 --- a/src/tui/runner-host.ts +++ b/src/tui/runner-host.ts @@ -133,7 +133,7 @@ export interface RunnerHostDeps { * Live data behind the command surfaces (settings, permissions, plugins). * `notify` is supplied by the host itself. */ - readonly surfaces?: Omit; + readonly surfaces?: Omit; /** Renderer factory override for headless mounting in tests. */ readonly createRenderer?: () => Promise; /** First-run telemetry disclosure, shown on the landing screen. */ @@ -365,6 +365,7 @@ export async function mountRunnerHost(deps: RunnerHostDeps): Promise const surfaceDeps: CommandSurfaceDeps = { ...(deps.surfaces ?? {}), ...(host.openModels !== undefined ? { openModels: host.openModels } : {}), + ...(host.openAddProvider !== undefined ? { openAddProvider: host.openAddProvider } : {}), notify: (text) => surfaceSystemNotice(host.shell, text), }; diff --git a/src/tui/runner.ts b/src/tui/runner.ts index 409b32b7d..eedaa7573 100644 --- a/src/tui/runner.ts +++ b/src/tui/runner.ts @@ -2186,7 +2186,8 @@ export async function runTUI(initialConfig: Config): Promise { return; case "overlay": if (!host.openSurface(result.overlay)) { - systemNotice(`No surface for /${result.overlay}.`); + const named = result.overlay === "add-provider" ? "connect" : result.overlay; + systemNotice(`No surface for /${named}.`); } return; case "modal": diff --git a/src/tui/shell.ts b/src/tui/shell.ts index 9a66c75aa..e5605d0ac 100644 --- a/src/tui/shell.ts +++ b/src/tui/shell.ts @@ -1332,8 +1332,8 @@ const DEFAULT_OVERLAY_HINTS = ["Esc cancel · Enter choose", "Esc · Enter"] as */ /** Model picker only: same three-tier fallback shape as DEFAULT_OVERLAY_HINTS. */ const MODEL_PICKER_HINTS = [ - "Esc cancel · Enter choose · Alt+A add provider", - "Esc · Enter · Alt+A add", + "Esc cancel · Enter choose · Alt+A /connect add provider", + "Esc · Enter · Alt+A /connect", "Esc · Enter", ] as const; @@ -1355,8 +1355,8 @@ function overlayHints(shell: AppShell): readonly string[] { const setDefault = bag?.overlaySetDefaultHint === true; if (addProvider && setDefault) { return [ - "Esc cancel · Enter choose · Alt+A add provider · Alt+D set default", - "Esc · Enter · Alt+A add · Alt+D default", + "Esc cancel · Enter choose · Alt+A /connect add provider · Alt+D set default", + "Esc · Enter · Alt+A /connect · Alt+D default", "Esc · Enter · Alt+A · Alt+D", "Esc · Enter", ]; @@ -1977,7 +1977,7 @@ interface ShellInternals { overlayDescribe: ((itemId: string) => ItemDescription | null) | null; /** Per-open bare-key claim for the open primary overlay. */ overlayOnAction: ((itemId: string, key: KeyEvent) => boolean) | null; - /** Whether the open primary advertises Alt+A in the footer hints. */ + /** Whether the open primary advertises Alt+A and yields å/Å from type-to-filter. */ overlayAddProviderHint: boolean; /** Whether the open primary advertises Alt+D in the footer hints. */ overlaySetDefaultHint: boolean; @@ -3569,9 +3569,9 @@ export interface OpenListOverlayOpts { */ readonly typeToFilter?: boolean; /** - * Advertise the Alt+A add-provider hint in the footer for this open. Set - * only when the caller actually wired an Alt+A handler via `onAction`, so - * the hint can never name a key that is a dead end. + * Advertise Alt+A and /connect in the footer and yield composed Option+A + * (å/Å) from type-to-filter. Set only when the caller actually wired an + * add-provider handler via `onAction`, so the hint never names a dead chord. */ readonly addProviderHint?: boolean; /** @@ -3866,6 +3866,24 @@ export function handlePaletteFilterKey(shell: AppShell, key: KeyEvent): boolean return true; } +/** + * Glyphs some terminals emit for Option+A without setting meta/option. + */ +const OPTION_A_COMPOSED_CHARS = new Set(["å", "Å"]); + +/** + * True when a key event is the model-picker Alt+A add-provider chord. + * Terminals may deliver Option+A as å/Å without meta/option. + */ +export function isAddProviderShortcutKey(key: KeyEvent): boolean { + if (key.ctrl) return false; + const name = typeof key.name === "string" ? key.name : ""; + const seq = typeof key.sequence === "string" ? key.sequence : ""; + if ((key.meta || key.option) && name.toLowerCase() === "a") return true; + if (OPTION_A_COMPOSED_CHARS.has(name) || OPTION_A_COMPOSED_CHARS.has(seq)) return true; + return false; +} + /** * Keys a type-to-filter list overlay claims while open, so the `>` row * narrows as you type. Mirrors the palette filter, but updates the open @@ -3879,6 +3897,16 @@ export function handleListFilterKey(shell: AppShell, key: KeyEvent): boolean { if (shell.overlayKind === "palette") return false; if (key.ctrl || key.meta || key.option) return false; + // overlayAddProviderHint also gates this filter-bypass so composed Option+A + // (å/Å) reaches runOverlayAction instead of type-to-filter. + if ( + bag?.overlayAddProviderHint === true && + shell.overlayKind === "model_picker" && + isAddProviderShortcutKey(key) + ) { + return false; + } + if (key.name === "backspace") { if (state.query.length === 0) return true; state.query = state.query.slice(0, -1);