From ae54351452a091a0b50e9067f968f11ca3ec0427 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 31 Aug 2026 13:06:01 -0700 Subject: [PATCH 1/4] Fix add provider shortcut on composed macOS input --- CHANGELOG.md | 3 +++ src/tui/product-host.test.ts | 50 ++++++++++++++++++++++++++++++++++++ src/tui/product-host.ts | 12 ++++++--- src/tui/shell.ts | 30 ++++++++++++++++++++++ 4 files changed, 91 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e0c40b4..35156be2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,9 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename API endpoints keep dollar estimates. - CLI `--help` / `-h` is recognized in any argument position. Value flags no longer swallow `--*` or `-h` as their option values. +- `/model` Alt+A opens Add Provider on non-US macOS layouts that emit å/Å + for Option+A without the option modifier, instead of type-to-filter + claiming the glyph. ## [0.3.11] - 2026-08-31 diff --git a/src/tui/product-host.test.ts b/src/tui/product-host.test.ts index e9e1c438..3f96f5cb 100644 --- a/src/tui/product-host.test.ts +++ b/src/tui/product-host.test.ts @@ -11,6 +11,7 @@ import { createHarness } from "./harness.js"; import { acceptOverlaySelection, closeInsetOverlay, + handleListFilterKey, moveOverlaySelection, runOverlayAction, } from "./shell.js"; @@ -732,6 +733,55 @@ describe("flat type-to-filter model picker", () => { } }); + test("composed Option+A (å) opens add-provider and is not claimed by type-to-filter", async () => { + // Non-US macOS layouts often emit å/Å for Option+A without meta/option set. + // Type-to-filter used to claim that printable before runOverlayAction ran. + 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("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({ diff --git a/src/tui/product-host.ts b/src/tui/product-host.ts index 915411e7..c9a659df 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, @@ -585,13 +586,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) { + 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(); 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; diff --git a/src/tui/shell.ts b/src/tui/shell.ts index 9a66c75a..666bd53b 100644 --- a/src/tui/shell.ts +++ b/src/tui/shell.ts @@ -3866,6 +3866,26 @@ export function handlePaletteFilterKey(shell: AppShell, key: KeyEvent): boolean return true; } +/** + * Glyphs macOS emits for Option+A on common layouts (US, ABC, Nordic). + * Non-US input sources often deliver these without meta/option set. + */ +const OPTION_A_COMPOSED_CHARS = new Set(["å", "Å"]); + +/** + * True when a key event is the model-picker Alt+A add-provider chord. + * US layouts send name `"a"` with meta/option; non-US layouts often emit + * å/Å with neither modifier, so type-to-filter would otherwise claim them. + */ +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 +3899,16 @@ export function handleListFilterKey(shell: AppShell, key: KeyEvent): boolean { if (shell.overlayKind === "palette") return false; if (key.ctrl || key.meta || key.option) return false; + // When Alt+A add-provider is wired, leave Option+A composed glyphs (å/Å) + // for runOverlayAction — non-US macOS layouts emit them without meta/option. + 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); From 8e1e28f289231eedbe89564d16c75bb009463dbb Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 31 Aug 2026 14:23:57 -0700 Subject: [PATCH 2/4] Add /connect and pin composed Option+A add-provider coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slash /connect opens Add Provider from any layout. Option+A still works, including terminals that emit å/Å without the option modifier. --- CHANGELOG.md | 11 ++- src/tui/command-surfaces.test.ts | 16 +++++ src/tui/command-surfaces.ts | 8 ++- src/tui/commands/built-in.test.ts | 15 +++- src/tui/commands/built-in.ts | 10 ++- src/tui/commands/registry.ts | 5 +- src/tui/product-host.test.ts | 113 +++++++++++++++++++++++++++++- src/tui/product-host.ts | 8 ++- src/tui/runner-host.test.ts | 56 +++++++++++++++ src/tui/runner-host.ts | 3 +- src/tui/shell.ts | 18 +++-- 11 files changed, 240 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 35156be2..e8ad58ce 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 @@ -23,9 +31,6 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename API endpoints keep dollar estimates. - CLI `--help` / `-h` is recognized in any argument position. Value flags no longer swallow `--*` or `-h` as their option values. -- `/model` Alt+A opens Add Provider on non-US macOS layouts that emit å/Å - for Option+A without the option modifier, instead of type-to-filter - claiming the glyph. ## [0.3.11] - 2026-08-31 diff --git a/src/tui/command-surfaces.test.ts b/src/tui/command-surfaces.test.ts index 2c39997a..dfd1093b 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 f3e89cdf..bdcf2fb9 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). */ + readonly openAddProvider?: () => 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 58cb506b..896d27a3 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 41540a17..d9bab743 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 e94ec735..ac76ca4f 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/product-host.test.ts b/src/tui/product-host.test.ts index 3f96f5cb..65f0541f 100644 --- a/src/tui/product-host.test.ts +++ b/src/tui/product-host.test.ts @@ -734,8 +734,7 @@ describe("flat type-to-filter model picker", () => { }); test("composed Option+A (å) opens add-provider and is not claimed by type-to-filter", async () => { - // Non-US macOS layouts often emit å/Å for Option+A without meta/option set. - // Type-to-filter used to claim that printable before runOverlayAction ran. + // Terminals may deliver Option+A as å/Å without meta/option. const { harness, host } = await mountPicker({ onConnectProvider: () => {}, addProviderChoices: () => [{ id: "codex", label: "Codex", hint: "", accountCount: 0 }], @@ -759,6 +758,90 @@ describe("flat type-to-filter model picker", () => { } }); + 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("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: () => {}, @@ -874,6 +957,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 c9a659df..4eea5d11 100644 --- a/src/tui/product-host.ts +++ b/src/tui/product-host.ts @@ -205,6 +205,10 @@ 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. + */ + readonly openAddProvider?: () => void; /** Swap the picker's rows/descriptions in place (e.g. after a provider connects). */ readonly setModels?: ( models: readonly ProductHostModelOption[], @@ -514,6 +518,7 @@ export async function mountProductHost(config: ProductHostConfig): Promise void) | undefined; + let openAddProvider: (() => void) | undefined; if (config.onModelSelect) { const onSelect = config.onModelSelect; const onConnect = config.onConnectProvider; @@ -528,7 +533,7 @@ export async function mountProductHost(config: ProductHostConfig): Promise { const rows = addProviderChoices(); @@ -644,5 +649,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 51562fa6..f8eef055 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 f97cc620..f8e11bde 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/shell.ts b/src/tui/shell.ts index 666bd53b..9851d6f8 100644 --- a/src/tui/shell.ts +++ b/src/tui/shell.ts @@ -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 in the footer and yield composed Option+A (å/Å) from + * type-to-filter. 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. */ readonly addProviderHint?: boolean; /** @@ -3867,15 +3867,13 @@ export function handlePaletteFilterKey(shell: AppShell, key: KeyEvent): boolean } /** - * Glyphs macOS emits for Option+A on common layouts (US, ABC, Nordic). - * Non-US input sources often deliver these without meta/option set. + * 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. - * US layouts send name `"a"` with meta/option; non-US layouts often emit - * å/Å with neither modifier, so type-to-filter would otherwise claim them. + * Terminals may deliver Option+A as å/Å without meta/option. */ export function isAddProviderShortcutKey(key: KeyEvent): boolean { if (key.ctrl) return false; @@ -3899,8 +3897,8 @@ export function handleListFilterKey(shell: AppShell, key: KeyEvent): boolean { if (shell.overlayKind === "palette") return false; if (key.ctrl || key.meta || key.option) return false; - // When Alt+A add-provider is wired, leave Option+A composed glyphs (å/Å) - // for runOverlayAction — non-US macOS layouts emit them without meta/option. + // 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" && From cbffe3a455c22a97e0af554991470fb0cfd71e2c Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 31 Aug 2026 14:42:30 -0700 Subject: [PATCH 3/4] Dismiss add-provider from /connect without reopening models Esc from /connect was reopening the model picker because the opener always wired onCancel to openModels. Only Alt+A from an already-open picker needs that return path. --- docs/IMPLEMENTATION.md | 2 +- docs/PRODUCT.md | 4 ++-- src/tui/command-surfaces.ts | 4 ++-- src/tui/overlays.ts | 2 +- src/tui/product-host.test.ts | 20 ++++++++++++++++++++ src/tui/product-host.ts | 18 +++++++++++------- src/tui/runner.ts | 3 ++- 7 files changed, 39 insertions(+), 14 deletions(-) diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index 8cd03cb3..0a988189 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 6c49868e..0adf991d 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/src/tui/command-surfaces.ts b/src/tui/command-surfaces.ts index bdcf2fb9..40b66085 100644 --- a/src/tui/command-surfaces.ts +++ b/src/tui/command-surfaces.ts @@ -167,8 +167,8 @@ 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). */ - readonly openAddProvider?: () => 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; } diff --git a/src/tui/overlays.ts b/src/tui/overlays.ts index 354b3a84..81801f68 100644 --- a/src/tui/overlays.ts +++ b/src/tui/overlays.ts @@ -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 65f0541f..adcde98d 100644 --- a/src/tui/product-host.test.ts +++ b/src/tui/product-host.test.ts @@ -911,6 +911,26 @@ 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"); + closeInsetOverlay(host.shell); + await harness.renderOnce(); + expect(host.shell.overlayKind).not.toBe("model_picker"); + 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({ diff --git a/src/tui/product-host.ts b/src/tui/product-host.ts index 4eea5d11..7ff1ac0c 100644 --- a/src/tui/product-host.ts +++ b/src/tui/product-host.ts @@ -207,8 +207,11 @@ export interface ProductHost { 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?: () => void; + 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[], @@ -518,7 +521,7 @@ export async function mountProductHost(config: ProductHostConfig): Promise void) | undefined; - let openAddProvider: (() => void) | undefined; + let openAddProvider: ((opts?: { returnToModels?: boolean }) => void) | undefined; if (config.onModelSelect) { const onSelect = config.onModelSelect; const onConnect = config.onConnectProvider; @@ -535,7 +538,7 @@ export async function mountProductHost(config: ProductHostConfig): Promise { + ? (opts?: { returnToModels?: boolean }): void => { const rows = addProviderChoices(); closeInsetOverlay(shell); openAddProviderOverlay(shell, { @@ -558,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; @@ -595,7 +599,7 @@ export async function mountProductHost(config: ProductHostConfig): 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": From d42a578d533e6a0738a106ed410fe5690e7e1843 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 31 Aug 2026 19:08:45 -0700 Subject: [PATCH 4/4] Name /connect on the model picker hint and pin Esc paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The footer still advertised only Alt+A, which is a dead chord on layouts whose Option+A is not å/Å. Tests now cover closed-prompt å, other composed glyphs, sequence-only å, typed /connect Esc, and Alt+A Esc through the key path. --- docs/TUI.md | 17 +++-- src/tui/overlay-paint.test.ts | 4 +- src/tui/overlays.ts | 2 +- src/tui/product-host.test.ts | 116 ++++++++++++++++++++++++++++++++-- src/tui/shell.ts | 14 ++-- 5 files changed, 132 insertions(+), 21 deletions(-) diff --git a/docs/TUI.md b/docs/TUI.md index 6c721b53..c796aa4e 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/overlay-paint.test.ts b/src/tui/overlay-paint.test.ts index 97be7297..fc34dd41 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 81801f68..4dbb3c71 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; diff --git a/src/tui/product-host.test.ts b/src/tui/product-host.test.ts index adcde98d..6df498c3 100644 --- a/src/tui/product-host.test.ts +++ b/src/tui/product-host.test.ts @@ -10,7 +10,6 @@ import { AGENTS_PANEL_LINGER_MS } from "./chrome-state.js"; import { createHarness } from "./harness.js"; import { acceptOverlaySelection, - closeInsetOverlay, handleListFilterKey, moveOverlaySelection, runOverlayAction, @@ -690,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. @@ -700,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(); @@ -799,6 +800,74 @@ describe("flat type-to-filter model picker", () => { } }); + 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 { @@ -901,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); @@ -921,7 +991,8 @@ describe("flat type-to-filter model picker", () => { host.openAddProvider?.(); 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).not.toBe("model_picker"); expect(host.shell.overlayKind).toBeNull(); @@ -931,6 +1002,41 @@ describe("flat type-to-filter model picker", () => { } }); + 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({ diff --git a/src/tui/shell.ts b/src/tui/shell.ts index 9851d6f8..e5605d0a 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", ]; @@ -3569,9 +3569,9 @@ export interface OpenListOverlayOpts { */ readonly typeToFilter?: boolean; /** - * Advertise Alt+A in the footer and yield composed Option+A (å/Å) from - * type-to-filter. 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; /**