Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/IMPLEMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
8 changes: 5 additions & 3 deletions docs/TUI.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions src/tui/product-host.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -623,13 +624,39 @@ describe("flat type-to-filter model picker", () => {
expect(host.shell.overlayItems).toEqual([
"Codex — 2 accounts",
"OpenAI — 0 accounts",
"Custom — 0 accounts",
])
} 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({
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: () => {},
Expand Down
20 changes: 20 additions & 0 deletions src/tui/provider-setup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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/<profile>" — one row per
Expand Down
23 changes: 23 additions & 0 deletions src/tui/provider-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
20 changes: 7 additions & 13 deletions src/tui/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -2044,19 +2044,13 @@ export async function runTUI(initialConfig: Config): Promise<number> {
// 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.
Expand Down
Loading