Skip to content
33 changes: 33 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,39 @@ 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]

Codex connect works again: streaming responses no longer die on a missing
header, multiple ChatGPT accounts can be connected by name, and providers are
added from the model picker with Alt+A.

### Providers

- **Codex streaming repaired.** Some Codex models (the gpt-5.6 family) stream
valid responses with no Content-Type header, which failed every turn with
"Cannot detect response kind". The response protocol is now recovered from
what the request asked for, so those models work; genuinely malformed
responses still fail loudly.
- **Named accounts with re-auth.** Browser sign-in asks for an account name
first, so any number of ChatGPT or Grok accounts can be connected side by
side (`codex/work`, `codex/personal`, …). Reusing an existing name
re-authorizes that account after an explicit confirmation — the recovery
path for expired sign-ins. Second sign-ins can no longer silently overwrite
an existing account's credentials.

### TUI

- **Alt+A adds providers.** The model picker lists only connected accounts
and their models; Alt+A opens an add-provider selector that always shows
every provider with its connected-account count, so adding a second account
is never blocked. After connecting, the picker reopens focused on the new
account.
- **Connect works mid-session.** Adding a provider from a running session no
longer crashes with a renderer conflict; the sign-in surface shares the
session's screen and hands control back when done.
- **Pickers stay on screen.** Overlays opened after using one on the launch
screen no longer render below the prompt box.

## [0.2.96] - 2026-09-08

Drag-select auto-copy, a flat type-to-filter model picker, install-aware upgrade
Expand Down
31 changes: 23 additions & 8 deletions docs/TUI.md
Original file line number Diff line number Diff line change
Expand Up @@ -419,14 +419,29 @@ known, accepted cost of the badge rather than an oversight — see
The model/provider picker is one flat, type-to-filter list
(`src/tui/product-host.ts` + `openModelPickerOverlay({ typeToFilter: true })`):
recent and favorite provider+model pairs sit at the top, then every
`provider / model` leaf from the catalog — no nested provider pane. Typing
narrows the list in place (printable keys claimed by the filter row, same
pattern as the command palette); Enter selects. Escape closes the picker.
The row matching the session's live active model gets a `(current)` suffix.
Alt+F on a model row still toggles favorite when a favorite hook is wired.
While type-to-filter is active, bare `j`/`k` type into the filter rather than
moving the highlight — use arrow keys (or the filtered list's navigation) to
move.
`provider / model` leaf from the catalog. Typing narrows the list in place
(printable keys claimed by the filter row, same pattern as the command
palette); Enter selects. Escape closes the picker. The row matching the
session's live active model gets a `(current)` suffix. Alt+F on a model row
still toggles favorite when a favorite hook is wired. While type-to-filter is
active, bare `j`/`k` type into the filter rather than moving the highlight —
use arrow keys (or the filtered list's navigation) to move.

The list itself never nests by provider, but connecting a new provider is not
a flat-list row either: the picker used to grow a "connect →" row per
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`
(`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
`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.

Onboarding (the standalone provider-setup screen, `provider-setup.ts`) and
the satellite pickers used for session resume and session-mode selection
Expand Down
66 changes: 66 additions & 0 deletions src/provider/codex-responses-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,72 @@ export const CODEX_SESSION_ID_OPTION = "codexSessionId";

const EMPTY_PARTIAL: PartialMessage = { text: "" };

type FetchLike = (
input: string | URL | Request,
init?: RequestInit,
) => Promise<Response>;

function requestURL(input: string | URL | Request): string {
if (typeof input === "string") return input;
if (input instanceof URL) return input.toString();
return input.url;
}

// Content type the request's accept header committed to, or null when the
// commitment is ambiguous. Reads init headers first, falling back to a
// Request object's own headers so both fetch calling conventions are
// honored. Media types are prefix-matched per comma-separated entry so
// parameters do not defeat the match; a list naming BOTH supported
// protocols is ambiguous and yields null.
function acceptedContentType(
input: string | URL | Request,
init: RequestInit | undefined,
): string | null {
const headers =
init?.headers !== undefined
? new Headers(init.headers)
: input instanceof Request
? input.headers
: undefined;
const accept = headers?.get("accept");
if (accept === undefined || accept === null) return null;
const supported = new Set<string>();
for (const entry of accept.toLowerCase().split(",")) {
const media = entry.trim();
if (media.startsWith("text/event-stream")) supported.add("text/event-stream");
else if (media.startsWith("application/json")) supported.add("application/json");
}
if (supported.size !== 1) return null;
return [...supported][0] ?? null;
}

// The Codex backend omits the Content-Type header entirely on some model
// streams (observed live with the gpt-5.6 family) while the body is a valid
// SSE stream. The vendored harness detects the response protocol from that
// header alone and fails the turn when it is absent, so the header is
// restored here — at the fetch boundary Corbits owns, scoped to Codex
// responses requests — from the protocol the request's accept header
// declared. Responses that declare any Content-Type, non-2xx responses, and
// requests whose accept header is ambiguous pass through untouched, keeping
// the harness's loud protocol-mismatch failure for genuine violations.
export function withCodexContentTypeRepair(fetchImpl: FetchLike): FetchLike {
return async (input, init) => {
const response = await fetchImpl(input, init);
if (!requestURL(input).endsWith(CODEX_RESPONSES_PATH)) return response;
if (!response.ok) return response;
if (response.headers.get("content-type") !== null) return response;
const declared = acceptedContentType(input, init);
if (declared === null) return response;
const headers = new Headers(response.headers);
headers.set("content-type", declared);
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
});
};
}

// ---------------------------------------------------------------------------
// Request building — internal turns → Responses `input` items
// ---------------------------------------------------------------------------
Expand Down
12 changes: 10 additions & 2 deletions src/provider/inference-dependencies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ import * as codexResponses from "./codex-responses-adapter.js";
import * as grokResponses from "./grok-responses-adapter.js";
import * as bifrostAdapter from "./bifrost-adapter.js";
import * as openaiResponses from "./openai-responses-adapter.js";
import { CODEX_RESPONSES_PROVIDER } from "./codex-responses-adapter.js";
import {
CODEX_RESPONSES_PROVIDER,
withCodexContentTypeRepair,
} from "./codex-responses-adapter.js";
import { GROK_RESPONSES_PROVIDER } from "./grok-responses-adapter.js";
import { BIFROST_PROVIDER } from "./bifrost-adapter.js";
import { OPENAI_RESPONSES_PROVIDER } from "./openai-responses-adapter.js";
Expand Down Expand Up @@ -58,7 +61,12 @@ export function createInferenceDependencies(): Promise<Dependencies> {
if (cached === undefined) {
cached = loadAdapterRegistry(manifest, {
import: (specifier) => Promise.resolve(localModules[specifier]),
}).then(createDependencies);
})
.then(createDependencies)
.then((deps) => ({
...deps,
fetch: withCodexContentTypeRepair(deps.fetch),
}));
}
return cached;
}
6 changes: 3 additions & 3 deletions src/tui/commands/built-in.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,9 @@ export function registerBuiltInCommands(): void {
handler: (_args, _ctx) => ({ type: "overlay", overlay: "hooks" }),
});

// Models-first connect: providers are connected from /model (Ctrl+A / c), not a
// standalone /login picker. Keep codex/xai login modals reachable only via
// Connect or re-auth on an expired profile.
// 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.

// 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,
Expand Down
25 changes: 0 additions & 25 deletions src/tui/model-catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { describe, expect, test } from "bun:test"
import {
buildModelCatalog,
buildModelsFirstCatalog,
connectRowId,
describeModelCatalogOption,
modelOptionId,
type ModelCatalogProvider,
Expand Down Expand Up @@ -196,33 +195,9 @@ describe("buildModelsFirstCatalog", () => {
})
expect(list[0]?.label).toBe("custom / m1")
})

test("appends a not-connected connect row for each unconnected provider", () => {
const list = buildModelsFirstCatalog({
providers: [xai],
recent: [],
favorites: [],
unconnected: [
{ name: "openai", label: "OpenAI", modelCount: 4, authKind: "key" },
],
})

const row = list.find((r) => r.section === "unconnected")
expect(row?.id).toBe(connectRowId("openai"))
expect(row?.label).toBe("OpenAI — connect →")
})
})

describe("describeModelCatalogOption", () => {
test("describes an unconnected provider's connect row", () => {
const description = describeModelCatalogOption(
{ id: connectRowId("openai"), label: "OpenAI — connect →", section: "unconnected" },
{ unconnected: [{ name: "openai", label: "OpenAI", modelCount: 4, authKind: "key" }] },
)
expect(description?.what).toMatch(/not set up yet/i)
expect(description?.impact).toMatch(/4 models become available/)
})

test("surfaces the Go-on-Zen billing warning as a consequence-toned impact, not the label", () => {
const description = describeModelCatalogOption(
{ id: "zen:kimi-k2.7-code", label: "OpenCode Zen / kimi-k2.7-code", warning: "Go model on Zen path" },
Expand Down
50 changes: 3 additions & 47 deletions src/tui/model-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { contextWindowFor, hasContextWindowFor } from "../provider/context-windo
import { modelReasoningCapability } from "../provider/reasoning-effort.js"
import type { ItemDescription } from "./shell.js"

export type ModelCatalogSection = "recent" | "favorites" | "provider" | "unconnected"
export type ModelCatalogSection = "recent" | "favorites" | "provider"

/** Picker row — superset of ProductHostModelOption (`id`, `label`). */
export type ModelCatalogOption = {
Expand Down Expand Up @@ -129,16 +129,6 @@ const GO_ON_ZEN_WARNING = "Go model on Zen path — billed as Zen credits"
/** Default recent-section cap (mirrors config/settings.js DEFAULT_RECENT_MODELS_SHOWN). */
const DEFAULT_RECENT_MAX = 5

/** Known-but-unconfigured provider, surfaced as a "connect →" row. */
export type ModelCatalogUnconnectedProvider = {
readonly name: string
readonly label?: string
/** How many models become selectable once this provider is connected. */
readonly modelCount: number
/** "key" prompts for an API key; "oauth" runs the authorize-link flow. */
readonly authKind: "key" | "oauth"
}

export type BuildModelsFirstCatalogArgs = {
readonly providers: ModelCatalogProvidersInput
readonly recent?: readonly ModelCatalogRef[]
Expand All @@ -151,8 +141,6 @@ export type BuildModelsFirstCatalogArgs = {
* billing-product detector; override in tests.
*/
readonly isGoModelOnZenPath?: (model: string, provider: ModelCatalogProvider) => boolean
/** Known providers with no stored credentials yet — rendered as "connect →" rows. */
readonly unconnected?: readonly ModelCatalogUnconnectedProvider[]
}

function providerLabelOf(p: ModelCatalogProvider): string {
Expand Down Expand Up @@ -224,29 +212,9 @@ export function buildModelsFirstCatalog(
}
}

for (const provider of args.unconnected ?? []) {
const id = connectRowId(provider.name)
if (seen.has(id)) continue
seen.add(id)
const label = provider.label !== undefined && provider.label.trim().length > 0
? provider.label.trim()
: provider.name
out.push({ id, label: `${label} — connect →`, section: "unconnected" })
}

return out
}

/** Stable id for an unconnected-provider "connect" row. */
export function connectRowId(providerName: string): string {
return `connect:${providerName}`
}

/** Provider name a connect-row id refers to, or null when `id` is not a connect row. */
export function providerFromConnectRowId(id: string): string | null {
return id.startsWith("connect:") ? id.slice("connect:".length) : null
}

function formatPrice(perToken: number): string {
const perMtok = perToken * 1_000_000
return `$${perMtok % 1 === 0 ? perMtok.toFixed(0) : perMtok.toFixed(2)}`
Expand Down Expand Up @@ -282,27 +250,15 @@ function whatLine(model: string): string {

/**
* Description-zone content for a picker row. `pricing` defaults to the live
* models.dev cache; override in tests. Unconnected "connect →" rows and rows
* with a billing warning override the plain what/impact pair.
* models.dev cache; override in tests. Rows with a billing warning override
* the plain what/impact pair.
*/
export function describeModelCatalogOption(
option: ModelCatalogOption,
args?: {
readonly pricing?: PricingCache | null
readonly unconnected?: readonly ModelCatalogUnconnectedProvider[]
},
): ItemDescription | null {
const providerName = providerFromConnectRowId(option.id)
if (providerName !== null) {
const provider = (args?.unconnected ?? []).find((p) => p.name === providerName)
const count = provider?.modelCount ?? 0
return {
what: "Not set up yet. Connecting asks for an API key and stores it in your global settings.",
impact: `${count} model${count === 1 ? "" : "s"} become available. Nothing is sent until you send a message.`,
tone: "plain",
}
}

const model = option.id.slice(option.id.indexOf(":") + 1)
const pricing = args?.pricing !== undefined ? args.pricing : getActivePricingCache()

Expand Down
56 changes: 56 additions & 0 deletions src/tui/overlay-float-reset.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { expect, test } from "bun:test"

import { withTestRenderer } from "./harness"
import { appendStreamRow, closeInsetOverlay, createAppShell } from "./shell"
import { openModelPickerOverlay } from "./overlays"
import type { PaletteCommand } from "./command-catalog"

const CATALOG: readonly PaletteCommand[] = [
{ id: "model", label: "/model" },
{ id: "mcp", label: "/mcp" },
]

// Regression: the overlay host floats absolutely over the landing (top set to
// a large row offset) but is an in-flow band once a transcript exists.
// Un-floating used to leave the absolute insets behind, and under relative
// positioning a stale top offsets the band downward — the slash popup rendered
// below the prompt, clipped off the bottom of the screen.
test("slash popup stays above the prompt after a landing-floated overlay", async () => {
await withTestRenderer(
async (h) => {
const shell = createAppShell(h.renderer, {
terminal: { columns: 120, rows: 50 },
wireKeys: true,
run: "idle",
paletteCatalog: CATALOG,
})
try {
// Landing: overlay floats absolutely with a large top offset.
openModelPickerOverlay(shell, { items: ["codex/def / gpt-5.6-sol"] })
await h.renderOnce()
closeInsetOverlay(shell)
await h.renderOnce()

// Transcript starts; overlays are in-flow bands from here on.
appendStreamRow(shell, { role: "user", text: "hi" })
appendStreamRow(shell, { role: "assistant", text: "Hi! What can I help you with?" })
await h.renderOnce()

h.pressKey("/")
h.pressKey("m")
h.pressKey("o")
await h.renderOnce()
const frame = h.captureCharFrame()
const lines = frame.split("\n")
const popupRow = lines.findIndex((l) => l.includes("/model"))
const promptRow = lines.findIndex((l) => l.includes("/mo") && !l.includes("/model"))
expect(popupRow).toBeGreaterThanOrEqual(0)
expect(promptRow).toBeGreaterThanOrEqual(0)
expect(popupRow).toBeLessThan(promptRow)
} finally {
shell.dispose()
}
},
{ width: 120, height: 50 },
)
})
4 changes: 3 additions & 1 deletion src/tui/overlay-paint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,12 +106,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.
addProviderHint: true,
}),
size,
)

const expected = [
" model · Esc cancel · Enter choose",
" model · Esc cancel · Enter choose · Alt+A add provider",
` > ${ITEMS[0]}`,
...ITEMS.slice(1).map((i) => ` ${i}`),
]
Expand Down
Loading
Loading