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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,16 @@ matching `## [X.Y.Z]` section (plus install instructions). Do not maintain
parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
`## [Unreleased]` to `## [X.Y.Z] - YYYY-MM-DD`, then run the release script.

## [Unreleased]

### Providers

- **Named API-key instances.** First-class API-key providers (OpenAI key,
Anthropic, Google, OpenCode Zen/Go, Z.AI, …) ask for an instance name before
the key, so personal and team keys can coexist (`openai/default`,
`anthropic/work`, …). Reusing an existing name replaces that instance after
an explicit confirm. Custom endpoints stay free-form and single-entry.

## [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/PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ Continues from the last saved state in the working directory.

The TUI has an extensible slash-command framework. Built-ins include `/help` (shortcut + command overlay), `/model` (open the agent configuration surface — connect providers with **c** / **Ctrl+A**, pick models, tiers, and profiles), `/settings`, `/permissions`, `/plugins`, `/clear`, `/new`, and `/mcp`, plus a `/<name>` command per available workflow. Plugins can register additional commands.

Providers are **models-first**: there is no standalone `/login` command. `/model` opens on a **model list** (Recent, Favorites, then providers) so you pick a model without drilling provider first. **Alt+A** (or **c**) opens Connect; **Alt+F** toggles favorite on the highlighted model; **a** opens the advanced provider drill-down (edit/delete/tiers). Connect lists first-class providers (OpenAI dual-path ChatGPT OAuth or API key, xAI, OpenCode Zen, Anthropic, Google, OpenCode Go, Z.AI Coding Plan, Custom). OAuth providers open their existing browser login; API-key providers show an **auth-only** form (key + fixed catalog base URL), validate, and persist pre-seeded models for immediate selection. 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 on a **model list** (Recent, Favorites, then providers) so you pick a model without drilling provider first. **Alt+A** (or **c**) opens Connect; **Alt+F** toggles favorite on the highlighted model; **a** opens the advanced provider drill-down (edit/delete/tiers). Connect lists first-class providers (OpenAI dual-path ChatGPT OAuth or API key, xAI, OpenCode Zen, Anthropic, Google, OpenCode Go, Z.AI Coding Plan, Custom). 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. 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.

`/model` opens a dedicated full-screen modal — the single place agent configuration lives. The default view is models-first (Recent / Favorites / Providers); connect, tiers, and profiles remain reachable from the same surface. A switch applies to the running session immediately (no restart), and can be saved as this project's default (written to the per-repo selection file). Recent and favorite model pairs are stored in global settings (no credentials).

Expand Down
8 changes: 5 additions & 3 deletions docs/TUI.md
Original file line number Diff line number Diff line change
Expand Up @@ -439,9 +439,11 @@ 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
`openModels()` entry point the picker itself uses. Picking a row runs the
existing inline connect flow (`provider-connect.ts`); on success the picker
reopens focused on the new account's default model instead of the top of the
list.
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
before re-auth or re-key. On success the picker reopens focused on the new
account's default model instead of the top of the list.

Onboarding (the standalone provider-setup screen, `provider-setup.ts`) and
the satellite pickers used for session resume and session-mode selection
Expand Down
1 change: 1 addition & 0 deletions src/tui/onboarding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export async function runOnboarding(config: UnconfiguredConfig): Promise<number>

const submitted = await runProviderSetup({
showTelemetryNotice,
existingProviderNames: Object.keys(existing?.providers ?? {}),
onSubmit: buildProviderSubmitHandler(settingsPath, existing, config.cwd),
});

Expand Down
5 changes: 4 additions & 1 deletion src/tui/provider-connect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,10 @@ describe("connectProviderInline", () => {
})
await harness.renderOnce()

// initialProviderId lands directly on the api key step; leave it blank.
// initialProviderId lands on the instance-name step first.
harness.pressKey("Enter")
await harness.renderOnce()
// Leave the api key blank.
harness.pressKey("Enter")
await harness.renderOnce()
// Model step: accept the default.
Expand Down
1 change: 1 addition & 0 deletions src/tui/provider-connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export async function connectProviderInline(
const submitted = await runProviderSetup({
showTelemetryNotice: false,
initialProviderId: input.providerId,
existingProviderNames: Object.keys(input.existing?.providers ?? {}),
...(input.createRenderer !== undefined ? { createRenderer: input.createRenderer } : {}),
...(input.startLogin !== undefined ? { startLogin: input.startLogin } : {}),
onSubmit: async (values, setPhase, opts) => {
Expand Down
153 changes: 135 additions & 18 deletions src/tui/provider-setup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
connectedAccountCount,
CUSTOM_CHOICE_ID,
failureGuidance,
instanceSlugsForKind,
LOGIN_CANCELLED_MESSAGE,
LOGIN_TIMEOUT_MESSAGE,
maskEcho,
Expand All @@ -14,6 +15,7 @@ import {
providerChoiceById,
providerChoiceRows,
providerChoices,
resolveApiKeyInstanceName,
runProviderSetup,
secretFromMaskedEdit,
stepHeadline,
Expand Down Expand Up @@ -78,12 +80,12 @@ describe("provider setup pure helpers", () => {
expect(secretFromMaskedEdit(secret, "")).toBe("")
})

test("a picked provider takes three steps, custom takes five, oauth takes four", () => {
test("a picked API-key provider names an instance before the key; custom and oauth keep their shapes", () => {
const openai = providerChoiceById("openai")
expect(openai?.baseURL).toBe("https://api.openai.com/v1")
expect(stepsFor(openai ?? null)).toEqual(["provider", "apiKey", "model"])
// A subscription provider swaps the paste for a name-then-sign-in pair,
// so it lands one step longer than a preset.
// Multi-instance API-key path: pick, name, key, model.
expect(stepsFor(openai ?? null)).toEqual(["provider", "name", "apiKey", "model"])
// A subscription provider swaps the paste for a name-then-sign-in pair.
expect(stepsFor(providerChoiceById("codex") ?? null)).toEqual([
"provider",
"name",
Expand Down Expand Up @@ -151,11 +153,36 @@ describe("provider setup pure helpers", () => {
expect(connectedAccountCount(codexChoice, [])).toBe(0)
})

test("connectedAccountCount does not prefix-match key-based providers", () => {
test("connected API-key instances count under kind and kind/slug", () => {
// CL-5898: first-class API-key kinds are multi-instance. A bare "openai"
// key is the legacy single-instance row; "openai/work" is a sibling.
// Unrelated names like "openai-eu" must not count.
const openaiChoice = providerChoiceById("openai")
if (openaiChoice === undefined) throw new Error("expected an openai choice")
expect(connectedAccountCount(openaiChoice, [{ name: "openai-eu" }])).toBe(0)
expect(connectedAccountCount(openaiChoice, [{ name: "openai" }])).toBe(1)
expect(
connectedAccountCount(openaiChoice, [
{ name: "openai" },
{ name: "openai/work" },
{ name: "openai-eu" },
]),
).toBe(2)
})

test("instance slug helpers map legacy bare keys and compound names", () => {
expect(instanceSlugsForKind("openai", [])).toEqual([])
expect(instanceSlugsForKind("openai", ["openai"])).toEqual(["default"])
expect(instanceSlugsForKind("openai", ["openai", "openai/work", "anthropic"])).toEqual([
"default",
"work",
])
expect(resolveApiKeyInstanceName("openai", "work", [])).toBe("openai/work")
expect(resolveApiKeyInstanceName("openai", "default", ["openai"])).toBe("openai")
expect(resolveApiKeyInstanceName("openai", "default", ["openai/default"])).toBe(
"openai/default",
)
expect(resolveApiKeyInstanceName("openai", "default", [])).toBe("openai/default")
})

test("model rows come from the provider catalog plus a free-text escape", () => {
Expand Down Expand Up @@ -185,6 +212,14 @@ describe("provider setup pure helpers", () => {
expect(rows[1]).toMatchObject({ label: "account name", value: "work" })
})

test("the API-key name step is headlined and summarized as an account name", () => {
const openai = providerChoiceById("openai") ?? null
const steps = stepsFor(openai)
expect(stepHeadline(steps, 1, openai)).toBe("step 2 of 4 · account name")
const rows = summaryRows(steps, 2, { ...EMPTY, oauthProfile: "work" }, openai)
expect(rows[1]).toMatchObject({ label: "account name", value: "work" })
})

test("summary rows mark done, current, and pending steps", () => {
const values: ProviderFormValues = { ...EMPTY, name: "openai" }
const choice = providerChoiceById("openai") ?? null
Expand Down Expand Up @@ -216,11 +251,13 @@ describe("provider setup pure helpers", () => {
async function mountSetup(
onSubmit: ProviderSetupSubmit = async () => {},
showTelemetryNotice = false,
existingProviderNames: readonly string[] = [],
): Promise<{ done: Promise<boolean>; harness: Harness }> {
const harness = await createHarness({ width: 80, height: 30 })
const done = runProviderSetup({
onSubmit,
showTelemetryNotice,
existingProviderNames,
createRenderer: async () => harness.renderer,
})
await harness.renderOnce()
Expand Down Expand Up @@ -252,9 +289,13 @@ async function pickRow(

const PROVIDER_IDS = providerChoiceRows().map((r) => r.id)

/** Pick OpenAI, type a key, accept its default model. */
/** Pick OpenAI, accept the suggested instance name, type a key, accept model. */
async function connectOpenAI(harness: Harness, key = "sk-key"): Promise<void> {
await pickRow(harness, PROVIDER_IDS, "openai")
await flush(harness)
// Suggested slug is "default" when no instances exist yet.
harness.pressKey("Enter")
await harness.renderOnce()
type(harness, key)
harness.pressKey("Enter")
await harness.renderOnce()
Expand Down Expand Up @@ -677,38 +718,42 @@ describe("runProviderSetup", () => {
await harness.renderOnce()
const frame = harness.captureCharFrame()
expect(frame).toContain("setup")
expect(frame).toContain("step 1 of 3")
expect(frame).toContain("step 1 of 4")
expect(frame).toContain("OpenAI")
expect(frame).toContain("Custom")
harness.pressKey("Ctrl+C")
expect(await done).toBe(false)
})

test("picking a known provider prefills base URL and model", async () => {
test("picking a known provider names an instance then takes a key", async () => {
const seen: ProviderFormValues[] = []
const opts: SubmitOpts[] = []
const { done, harness } = await mountSetup(async (values, _phase, o) => {
seen.push({ ...values })
opts.push(o)
})
await pickRow(harness, PROVIDER_IDS, "openai")
// Two steps left: only the key is typed.
expect(harness.captureCharFrame()).toContain("step 2 of 3")
await flush(harness)
expect(harness.captureCharFrame()).toContain("step 2 of 4")
// Accept suggested "default" instance name.
harness.pressKey("Enter")
await harness.renderOnce()
expect(harness.captureCharFrame()).toContain("step 3 of 4")
type(harness, "sk-key")
harness.pressKey("Enter")
await harness.renderOnce()
expect(harness.captureCharFrame()).toContain("step 3 of 3")
expect(harness.captureCharFrame()).toContain("step 4 of 4")
harness.pressKey("Enter")
await harness.renderOnce()

expect(await done).toBe(true)
const openai = providerChoiceById("openai")
expect(seen[0]).toEqual({
name: "openai",
name: "openai/default",
baseURL: "https://api.openai.com/v1",
apiKey: "sk-key",
model: openai?.defaultModel ?? "",
oauthProfile: "",
oauthProfile: "default",
})
expect(opts[0]?.preset?.id).toBe("openai")
expect(opts[0]?.preset?.models.length).toBeGreaterThan(1)
Expand Down Expand Up @@ -750,6 +795,9 @@ describe("runProviderSetup", () => {
seen.push({ ...values })
})
await pickRow(harness, PROVIDER_IDS, "openai")
await flush(harness)
harness.pressKey("Enter")
await harness.renderOnce()
type(harness, "sk-key")
harness.pressKey("Enter")
await harness.renderOnce()
Expand All @@ -763,6 +811,7 @@ describe("runProviderSetup", () => {
await harness.renderOnce()
expect(await done).toBe(true)
expect(seen[0]?.model).toBe("gpt-4o")
expect(seen[0]?.name).toBe("openai/default")
})

test("shows the telemetry notice only when asked to", async () => {
Expand Down Expand Up @@ -792,17 +841,17 @@ describe("runProviderSetup", () => {
test("Escape goes back a step", async () => {
const { done, harness } = await mountSetup()
await pickRow(harness, PROVIDER_IDS, "openai")
expect(harness.captureCharFrame()).toContain("step 2 of 3")
expect(harness.captureCharFrame()).toContain("step 2 of 4")
await pressEscape(harness)
expect(harness.captureCharFrame()).toContain("step 1 of 3")
expect(harness.captureCharFrame()).toContain("step 1 of 4")
harness.pressKey("Ctrl+C")
await done
})

test("Escape on the first step stays put", async () => {
const { done, harness } = await mountSetup()
await pressEscape(harness)
expect(harness.captureCharFrame()).toContain("step 1 of 3")
expect(harness.captureCharFrame()).toContain("step 1 of 4")
harness.pressKey("Ctrl+C")
await done
})
Expand All @@ -821,6 +870,9 @@ describe("runProviderSetup", () => {
test("the typed API key is never painted in the clear", async () => {
const { done, harness } = await mountSetup()
await pickRow(harness, PROVIDER_IDS, "openai")
await flush(harness)
harness.pressKey("Enter")
await harness.renderOnce()
type(harness, "sk-secret")
await harness.renderOnce()
const frame = harness.captureCharFrame()
Expand Down Expand Up @@ -907,11 +959,70 @@ describe("runProviderSetup", () => {
submits += 1
})
await pickRow(harness, PROVIDER_IDS, "openai")
await flush(harness)
type(harness, "sk-key")
harness.pressKey("Ctrl+C")
expect(await done).toBe(false)
expect(submits).toBe(0)
})

test("a second API-key instance gets a compound name without overwriting the first", async () => {
const seen: ProviderFormValues[] = []
const { done, harness } = await mountSetup(
async (values) => {
seen.push({ ...values })
},
false,
["openai/default"],
)
await pickRow(harness, PROVIDER_IDS, "openai")
await flush(harness)
// Existing "default" forces suggested "default-2".
expect(harness.captureCharFrame()).toContain("default-2")
harness.pressKey("Enter")
await harness.renderOnce()
type(harness, "sk-work")
harness.pressKey("Enter")
await harness.renderOnce()
harness.pressKey("Enter")
await harness.renderOnce()
expect(await done).toBe(true)
expect(seen[0]?.name).toBe("openai/default-2")
expect(seen[0]?.oauthProfile).toBe("default-2")
expect(seen[0]?.apiKey).toBe("sk-work")
})

test("reusing an existing API-key instance name requires confirm before replace", async () => {
const seen: ProviderFormValues[] = []
const { done, harness } = await mountSetup(
async (values) => {
seen.push({ ...values })
},
false,
["openai"],
)
await pickRow(harness, PROVIDER_IDS, "openai")
await flush(harness)
// Clear suggested "default-2" and type the legacy bare-key slug "default".
for (let i = 0; i < 80; i++) harness.pressKey("Backspace")
type(harness, "default")
harness.pressKey("Enter")
await flush(harness)
expect(harness.captureCharFrame()).toContain("already connected")
// Confirm replace.
harness.pressKey("Enter")
await harness.renderOnce()
type(harness, "sk-replaced")
harness.pressKey("Enter")
await harness.renderOnce()
harness.pressKey("Enter")
await harness.renderOnce()
expect(await done).toBe(true)
// Legacy bare key is updated in place rather than rewritten as openai/default.
expect(seen[0]?.name).toBe("openai")
expect(seen[0]?.oauthProfile).toBe("default")
expect(seen[0]?.apiKey).toBe("sk-replaced")
})
})

/**
Expand All @@ -920,7 +1031,7 @@ describe("runProviderSetup", () => {
* handler is registered would pass while paste was broken.
*/
describe("runProviderSetup paste", () => {
/** Pick OpenAI, paste `key`, accept the default model, return what was saved. */
/** Pick OpenAI, accept the instance name, paste `key`, accept default model. */
async function pasteKey(
key: string,
): Promise<{ values: ProviderFormValues | null; frame: string }> {
Expand All @@ -930,6 +1041,9 @@ describe("runProviderSetup paste", () => {
})
try {
await pickRow(harness, PROVIDER_IDS, "openai")
await flush(harness)
harness.pressKey("Enter")
await harness.renderOnce()
await harness.mockInput.pasteBracketedText(key)
await harness.renderOnce()
const frame = harness.captureCharFrame()
Expand Down Expand Up @@ -984,7 +1098,7 @@ describe("runProviderSetup pick-list height cap", () => {
// The garbled-overlap bug glued the step line and the intro line
// together on one row; each survives as its own line, or is clipped
// entirely, but never merges into the other.
const stepLine = lines.find((l) => l.includes("step 1 of 3"))
const stepLine = lines.find((l) => l.includes("step 1 of 4"))
if (stepLine !== undefined) {
expect(stepLine).not.toContain("connect an inference provider")
}
Expand Down Expand Up @@ -1025,6 +1139,9 @@ describe("runProviderSetup pick-list height cap", () => {
await harness.renderOnce()
await harness.renderOnce()
await pickRow(harness, PROVIDER_IDS, "openai")
await flush(harness)
harness.pressKey("Enter")
await harness.renderOnce()
type(harness, "sk-key")
harness.pressKey("Enter")
await harness.renderOnce()
Expand Down
Loading
Loading