From c0c6d01d2646240406362c0a6de504dd1f3f7424 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 10 Aug 2026 11:30:48 -0700 Subject: [PATCH] Offer Custom endpoint from the Alt+A add-provider selector The add-provider list now includes Custom alongside first-class kinds so operators can connect a free-form endpoint without leaving the model picker. --- CHANGELOG.md | 7 +++++++ docs/IMPLEMENTATION.md | 2 +- docs/TUI.md | 8 +++++--- src/tui/product-host.test.ts | 27 +++++++++++++++++++++++++++ src/tui/provider-setup.test.ts | 20 ++++++++++++++++++++ src/tui/provider-setup.ts | 23 +++++++++++++++++++++++ src/tui/runner.ts | 20 +++++++------------- 7 files changed, 90 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 16b8d2efd..ea5e6d704 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,13 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename `anthropic/work`, …). Reusing an existing name replaces that instance after an explicit confirm. Custom endpoints stay free-form and single-entry. +### TUI + +- **Custom from Alt+A.** The add-provider selector now includes Custom alongside + first-class kinds, so free-form OpenAI-compatible endpoints are reachable from + the model picker without dropping into onboarding. Custom still uses the full + manual form (name, base URL, key, model). + ## [0.2.97] - 2026-08-10 Codex connect works again: streaming responses no longer die on a missing diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index 42662050d..01e702899 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -263,7 +263,7 @@ Profiles supply per-project or named-profile overrides for `model`, `maxTurns`, 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 model list (Recent, Favorites, then provider groups) built by `buildModelsFirstList` (`src/tui/model-picker.ts`). **Alt+A** / **c** opens Connect; API-key first-class rows use an auth-only form (key only; catalog base URL is display-only). **Alt+F** toggles favorites; recent/favorite pairs live in global settings (`recentModels` / `favoriteModels`). 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; API-key providers pre-seed models and persist on save so selection works without restart. 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 model list (Recent, Favorites, then provider groups) built by `buildModelsFirstList` (`src/tui/model-picker.ts`). **Alt+A** / **c** opens Connect via `addProviderSelectorChoices` (`src/tui/provider-setup.ts`), which lists every first-class kind including Custom; API-key first-class rows use an auth-only form (key only; catalog base URL is display-only), while Custom keeps the full manual form. **Alt+F** toggles favorites; recent/favorite pairs live in global settings (`recentModels` / `favoriteModels`). 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; API-key providers pre-seed models and persist on save so selection works without restart. 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/TUI.md b/docs/TUI.md index de97903d4..cea6b44c9 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -435,9 +435,11 @@ 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` (`src/tui/overlays.ts:openAddProviderOverlay`), a separate `PrimaryOverlayKind` -listing every first-class provider kind from `providerChoices()` — OAuth and -API-key alike — each annotated with its live connected-account count and none -of them filtered out. Esc returns to the model list through the same +listing every first-class provider kind from `providerChoices()` — OAuth, +API-key, 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); first-class kinds keep their auth-only or browser +login paths. Esc returns to the model list through the same `openModels()` entry point the picker itself uses. 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 diff --git a/src/tui/product-host.test.ts b/src/tui/product-host.test.ts index 527ab95de..1f911ec85 100644 --- a/src/tui/product-host.test.ts +++ b/src/tui/product-host.test.ts @@ -612,6 +612,7 @@ describe("flat type-to-filter model picker", () => { addProviderChoices: () => [ { id: "codex", label: "Codex", hint: "ChatGPT subscription", accountCount: 2 }, { id: "openai", label: "OpenAI", hint: "", accountCount: 0 }, + { id: "custom", label: "Custom", hint: "any OpenAI-compatible endpoint", accountCount: 0 }, ], }) try { @@ -623,6 +624,7 @@ describe("flat type-to-filter model picker", () => { expect(host.shell.overlayItems).toEqual([ "Codex — 2 accounts", "OpenAI — 0 accounts", + "Custom — 0 accounts", ]) } finally { host.dispose() @@ -630,6 +632,31 @@ describe("flat type-to-filter model picker", () => { } }) + test("Enter on a Custom add-provider row runs the connect flow for custom", async () => { + const connected: string[] = [] + const { harness, host } = await mountPicker({ + onConnectProvider: (name) => connected.push(name), + addProviderChoices: () => [ + { id: "openai", label: "OpenAI", hint: "", accountCount: 0 }, + { id: "custom", label: "Custom", hint: "", accountCount: 0 }, + ], + }) + try { + host.openModels?.() + await harness.renderOnce() + runOverlayAction(host.shell, altA) + await harness.renderOnce() + // Move to the Custom row (second item) and accept. + moveOverlaySelection(host.shell, 1) + acceptOverlaySelection(host.shell) + expect(connected).toEqual(["custom"]) + } finally { + host.dispose() + harness.destroy() + } + }) + + test("Esc from the add-provider selector returns to the model list", async () => { const { harness, host } = await mountPicker({ onConnectProvider: () => {}, diff --git a/src/tui/provider-setup.test.ts b/src/tui/provider-setup.test.ts index 582dccec2..5e2b18d36 100644 --- a/src/tui/provider-setup.test.ts +++ b/src/tui/provider-setup.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test" import { createHarness, type Harness } from "./harness.js" import { + addProviderSelectorChoices, connectedAccountCount, CUSTOM_CHOICE_ID, failureGuidance, @@ -140,6 +141,25 @@ describe("provider setup pure helpers", () => { expect(providerChoiceRows(choices)[0]?.label).toContain("OpenAI") }) + test("Alt+A selector rows include Custom and never filter by account count", () => { + // Regression for CL-5899: a prior filter dropped Custom from Alt+A even + // though onboarding still offered the full manual form. Connected kinds + // also stay listed so a second account remains reachable. + const choices = providerChoices() + const rows = addProviderSelectorChoices(choices, [ + { name: "openai" }, + { name: "codex/default" }, + ]) + expect(rows.map((r) => r.id)).toContain(CUSTOM_CHOICE_ID) + expect(rows.map((r) => r.id)).toEqual(choices.map((c) => c.id)) + const openai = rows.find((r) => r.id === "openai") + const codex = rows.find((r) => r.id === "codex") + const custom = rows.find((r) => r.id === CUSTOM_CHOICE_ID) + expect(openai?.accountCount).toBe(1) + expect(codex?.accountCount).toBe(1) + expect(custom?.accountCount).toBe(0) + }) + test("a connected Codex account counts under its profile-qualified name (CL-5606)", () => { // The ChatGPT-via-browser choice is keyed "codex", but a signed-in // account lands in the catalog as "codex/" — one row per diff --git a/src/tui/provider-setup.ts b/src/tui/provider-setup.ts index f0fc93f36..ca66bdb9a 100644 --- a/src/tui/provider-setup.ts +++ b/src/tui/provider-setup.ts @@ -423,6 +423,29 @@ export function resolveApiKeyInstanceName( return compound } +/** + * Rows for the model picker's Alt+A add-provider selector. Every first-class + * kind is included, including Custom — filtering Custom out made free-form + * endpoints unreachable from Alt+A even though onboarding still offered them. + * Account counts use the same rules as the onboarding list. + */ +export function addProviderSelectorChoices( + choices: readonly ProviderChoice[], + providers: readonly { readonly name: string }[], +): readonly { + readonly id: string + readonly label: string + readonly hint: string + readonly accountCount: number +}[] { + return choices.map((choice) => ({ + id: choice.id, + label: choice.label, + hint: choice.hint, + accountCount: connectedAccountCount(choice, providers), + })) +} + /** Pick-list rows for the provider step. */ export function providerChoiceRows( choices: readonly ProviderChoice[] = providerChoices(), diff --git a/src/tui/runner.ts b/src/tui/runner.ts index de1ee6220..95edd7d0c 100644 --- a/src/tui/runner.ts +++ b/src/tui/runner.ts @@ -42,7 +42,7 @@ import { type LocalSettings, type PluginConfig, } from "../config/settings.js"; -import { connectedAccountCount, providerChoices } from "./provider-setup.js"; +import { addProviderSelectorChoices, providerChoices } from "./provider-setup.js"; import { connectProviderInline } from "./provider-connect.js"; import { modelOptionId } from "./model-catalog.js"; import type { SessionModeScope } from "./command-surfaces.js"; @@ -2044,19 +2044,13 @@ export async function runTUI(initialConfig: Config): Promise { // Mount OpenTUI before the initial task is sent so gate and stream listeners // are registered first. Ctrl+C stays with the shell (interrupt the run); // OpenTUI owns the alternate screen and mouse reporting itself. - // Alt+A add-provider selector rows: every first-class provider kind, no - // already-connected filtering, so a second OAuth account is reachable once - // the first is already connected. Read fresh on each open against the live - // catalog. + // Alt+A add-provider selector rows: every first-class provider kind, including + // Custom (full manual form). No already-connected filtering — OAuth and + // multi-instance accounts are per-name, so dropping a kind once it has one + // account would hide the path to a second. Read fresh on each open against + // the live catalog. const computeAddProviderChoices = () => - providerChoices() - .filter((choice) => !choice.custom) - .map((choice) => ({ - id: choice.id, - label: choice.label, - hint: choice.hint, - accountCount: connectedAccountCount(choice, config.providers), - })); + addProviderSelectorChoices(providerChoices(), config.providers); const host = await mountRunnerHost({ // An unnamed session shows nothing rather than a placeholder.