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
35 changes: 30 additions & 5 deletions src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -557,11 +557,7 @@ export async function loadConfig(
noWorkflow,
...(profile.workflow !== undefined ? { workflow: profile.workflow } : {}),
...(settings?.defaultProvider !== undefined ? { globalDefaultProvider: settings.defaultProvider } : {}),
providers: [
...buildProviderCatalog(settings, resolved).filter((e) => !isCodexProviderName(e.name) && !isXaiProviderName(e.name)),
...codexProfilesToCatalogEntries(codexProfiles),
...xaiProfilesToCatalogEntries(xaiProfiles),
],
providers: mergeOAuthCatalog(settings, resolved, codexProfiles, xaiProfiles),
...(profile.profile !== undefined ? { profile: profile.profile } : {}),
...(profile.systemPromptExtensions !== undefined
? { systemPromptExtensions: profile.systemPromptExtensions }
Expand All @@ -583,6 +579,35 @@ export async function loadConfig(
};
}

// Settings-file providers plus Codex/xAI OAuth profile-store entries, merged
// the same way loadConfig assembles Config.providers. Exposed so a live
// provider connect (mid-session, no restart) can rebuild the picker's
// catalog after writing new credentials, instead of only taking effect on
// the next process start.
function mergeOAuthCatalog(
settings: Settings | null,
resolved: ResolvedProvider,
codexProfiles: readonly CodexProfile[],
xaiProfiles: readonly XaiProfile[],
): ProviderCatalogEntry[] {
return [
...buildProviderCatalog(settings, resolved).filter(
(e) => !isCodexProviderName(e.name) && !isXaiProviderName(e.name),
),
...codexProfilesToCatalogEntries(codexProfiles),
...xaiProfilesToCatalogEntries(xaiProfiles),
];
}

/** Rescans home-level Codex/xAI credential stores and rebuilds the live provider catalog. */
export async function refreshLiveProviderCatalog(
settings: Settings | null,
resolved: ResolvedProvider,
): Promise<ProviderCatalogEntry[]> {
const [codexProfiles, xaiProfiles] = await Promise.all([listCodexProfiles(), listXaiProfiles()]);
return mergeOAuthCatalog(settings, resolved, codexProfiles, xaiProfiles);
}

export function catalogEntryAsProviderSettings(entry: ProviderCatalogEntry): ProviderSettings {
// Anthropic and Go anthropic-protocol bases must not be forced through the
// OpenAI-compatible normalizer (which assumes a /v1 chat-completions root).
Expand Down
19 changes: 19 additions & 0 deletions src/tui-opentui/provider-setup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { createHarness, type Harness } from "./harness.js"
import {
CUSTOM_CHOICE_ID,
failureGuidance,
isChoiceConnected,
LOGIN_CANCELLED_MESSAGE,
LOGIN_TIMEOUT_MESSAGE,
maskEcho,
Expand All @@ -20,6 +21,7 @@ import {
stepsFor,
summaryRows,
TYPE_MODEL_ID,
unconnectedProviderChoices,
type OAuthLoginStarter,
type ProviderFormValues,
type ProviderSetupSubmit,
Expand Down Expand Up @@ -111,6 +113,23 @@ describe("provider setup pure helpers", () => {
expect(providerChoiceRows(choices)[0]?.label).toContain("OpenAI")
})

test("a connected Codex account clears the ChatGPT connect row (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
// account. Exact-id matching alone would leave the connect row stuck
// forever after a successful login.
const connected = [{ name: "codex/default" }]
expect(unconnectedProviderChoices(connected).map((c) => c.id)).not.toContain("codex")
expect(unconnectedProviderChoices([]).map((c) => c.id)).toContain("codex")
})

test("isChoiceConnected does not prefix-match key-based providers", () => {
const openaiChoice = providerChoiceById("openai")
if (openaiChoice === undefined) throw new Error("expected an openai choice")
expect(isChoiceConnected(openaiChoice, [{ name: "openai-eu" }])).toBe(false)
expect(isChoiceConnected(openaiChoice, [{ name: "openai" }])).toBe(true)
})

test("model rows come from the provider catalog plus a free-text escape", () => {
const openai = providerChoiceById("openai")
expect(openai).toBeDefined()
Expand Down
24 changes: 24 additions & 0 deletions src/tui-opentui/provider-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,30 @@ export function providerChoiceById(id: string): ProviderChoice | undefined {
return providerChoices().find((c) => c.id === id)
}

/**
* Whether `choice` already has a connected provider in `providers`. OAuth
* choices (`codex`, `xai`) are keyed by vendor id, but a signed-in account
* lands in the catalog as `codex/<profile>` / `xai/<profile>` — one row per
* account — so exact-id matching alone never clears the "connect" row after
* a successful browser login. Key-based choices still match by exact id.
*/
export function isChoiceConnected(
choice: ProviderChoice,
providers: readonly { readonly name: string }[],
): boolean {
return providers.some(
(p) => p.name === choice.id || (choice.oauth !== null && p.name.startsWith(`${choice.id}/`)),
)
}

/** Known choices with no connected provider yet — the picker's "connect →" rows. */
export function unconnectedProviderChoices(
providers: readonly { readonly name: string }[],
choices: readonly ProviderChoice[] = providerChoices(),
): readonly ProviderChoice[] {
return choices.filter((choice) => !choice.custom && !isChoiceConnected(choice, providers))
}

/** Pick-list rows for the provider step. */
export function providerChoiceRows(
choices: readonly ProviderChoice[] = providerChoices(),
Expand Down
39 changes: 39 additions & 0 deletions src/tui-opentui/runner-host.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,45 @@ describe("mountRunnerHost model picker", () => {
}
})

test("refreshModels swaps in a freshly connected provider and drops its connect row", async () => {
// Mount-time deps are a snapshot; a live provider connect (CL-5602) must be
// able to replace them without remounting the host, or the newly connected
// provider's models never appear and its "connect →" row never clears.
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: () => {},
unconnectedProviders: [
{ name: "openai", label: "OpenAI", modelCount: 1, authKind: "key" },
],
commands: [],
onCommand: () => {},
chrome: () => ({ goal: null, agents: [] }),
subAgentSessions: () => [],
createRenderer: async () => harness.renderer,
})
try {
host.refreshModels(
[],
[],
{ xai: { models: ["grok-4"] }, openai: { models: ["gpt-5"] } },
[],
)
expect(host.openSurface("models")).toBe(true)
// The connect row is gone and the provider now has its own group row
// (drilling into it would surface "gpt-5") instead of a stub message.
expect(host.shell.overlayItems).toContain("openai")
expect(host.shell.overlayItems).not.toContain("OpenAI — connect →")
} finally {
host.dispose()
harness.destroy()
}
})

test("f toggles favorite on the focused row via onFavoriteToggle", async () => {
const harness = await createHarness({ width: 80, height: 24 })
const toggled: string[] = []
Expand Down
24 changes: 19 additions & 5 deletions src/tui-opentui/runner-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,10 +132,16 @@ export type RunnerHost = ProductHost & {
* Recompute the models-first catalog from fresh recent/favorite refs and
* push it into the already-open host — the picker's Recent/Favorites
* sections would otherwise never reflect a same-session selection.
*
* `providers`/`unconnected` default to the values last passed here (or the
* mount-time deps) — pass fresh ones after a live provider connect so a
* newly authorized provider's models appear without a restart.
*/
readonly refreshModels: (
recentModels: readonly ModelCatalogRef[],
favoriteModels: readonly ModelCatalogRef[],
providers?: ModelCatalogProvidersInput,
unconnected?: readonly ModelCatalogUnconnectedProvider[],
) => void
/** Re-reads `showPromptCost` and cost/context state, repainting the border immediately. */
readonly refreshCostContext: () => void
Expand Down Expand Up @@ -210,16 +216,20 @@ export function observeSessionFromSubAgents(

/** Mount the OpenTUI host for a live session. */
export async function mountRunnerHost(deps: RunnerHostDeps): Promise<RunnerHost> {
// Mutable so a live provider connect (see refreshModels below) can replace
// the catalog source without remounting the host.
let liveProviders = deps.providers
let liveUnconnected = deps.unconnectedProviders ?? []
let catalog: readonly ModelCatalogOption[] = buildModelsFirstCatalog({
providers: deps.providers,
providers: liveProviders,
recent: deps.recentModels ?? [],
favorites: deps.favoriteModels ?? [],
unconnected: deps.unconnectedProviders ?? [],
unconnected: liveUnconnected,
})
const describeModel = (itemId: string): ItemDescription | null =>
describeModelCatalogOption(
catalog.find((o) => o.id === itemId) ?? { id: itemId, label: itemId },
{ unconnected: deps.unconnectedProviders ?? [] },
{ unconnected: liveUnconnected },
)
const readModelLabel = deps.modelLabel
const onModelSelect = (id: string): void => {
Expand Down Expand Up @@ -323,12 +333,16 @@ export async function mountRunnerHost(deps: RunnerHostDeps): Promise<RunnerHost>
const refreshModels = (
recentModels: readonly ModelCatalogRef[],
favoriteModels: readonly ModelCatalogRef[],
providers?: ModelCatalogProvidersInput,
unconnected?: readonly ModelCatalogUnconnectedProvider[],
): void => {
if (providers !== undefined) liveProviders = providers
if (unconnected !== undefined) liveUnconnected = unconnected
catalog = buildModelsFirstCatalog({
providers: deps.providers,
providers: liveProviders,
recent: recentModels,
favorites: favoriteModels,
unconnected: deps.unconnectedProviders ?? [],
unconnected: liveUnconnected,
})
host.setModels?.(catalog, describeModel)
}
Expand Down
80 changes: 64 additions & 16 deletions src/tui/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,13 @@ import { noopAuditStore, permissiveAuthorize } from "@intx/agent/testing";
import { getLogger } from "@intx/log";
import { createOptimizedContextStore, loadRecentTurns } from "../session/optimized-context-store.js";
import { type } from "arktype";
import { buildCodexSource, buildOpenAISource, buildXaiSource, type Config } from "../config/index.js";
import {
buildCodexSource,
buildOpenAISource,
buildXaiSource,
refreshLiveProviderCatalog,
type Config,
} from "../config/index.js";
import {
globalSettingsPath,
loadLocalSettings,
Expand All @@ -31,11 +37,13 @@ import {
markLastChangelogVersion,
toggleFavoriteModel,
type ModelRef,
type ResolvedProvider,
type Settings,
type LocalSettings,
type PluginConfig,
} from "../config/settings.js";
import { providerChoices } from "../tui-opentui/provider-setup.js";
import { unconnectedProviderChoices } from "../tui-opentui/provider-setup.js";
import { connectProviderInline } from "../tui-opentui/provider-connect.js";
import type { SessionModeScope } from "../tui-opentui/command-surfaces.js";
import { resolveWaitForApproval, type ToolWatchdogConfig } from "./tool-execution-watchdog.js";
import { createGateRequestApproval } from "./request-approval.js";
Expand Down Expand Up @@ -1930,6 +1938,17 @@ 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.
// Providers the picker offers as "connect →" rows: known choices minus
// whatever is already in the live catalog. Recomputed after a live connect
// so a newly authorized provider drops out of this list immediately.
const computeUnconnectedProviders = (providers: Config["providers"]) =>
unconnectedProviderChoices(providers).map((choice) => ({
name: choice.id,
label: choice.label,
modelCount: choice.models.length,
authKind: choice.oauth !== null ? ("oauth" as const) : ("key" as const),
}));

const host = await mountRunnerHost({
// An unnamed session shows nothing rather than a placeholder.
title: runTaskTitle,
Expand All @@ -1954,21 +1973,50 @@ export async function runTUI(initialConfig: Config): Promise<number> {
providers: config.providers,
recentModels: listRecentModels(config.settings ?? { providers: {} }),
favoriteModels: listFavoriteModels(config.settings ?? { providers: {} }),
unconnectedProviders: providerChoices()
.filter((choice) => !choice.custom)
.filter((choice) => !config.providers.some((p) => p.name === choice.id))
.map((choice) => ({
name: choice.id,
label: choice.label,
modelCount: choice.models.length,
authKind: choice.oauth !== null ? ("oauth" as const) : ("key" as const),
})),
unconnectedProviders: computeUnconnectedProviders(config.providers),
onConnectProvider: (providerName) => {
// Live inline connect (CL-5499) needs a text-input-capable overlay that
// does not exist in shell.ts's list-overlay kit yet — see AGENTS report.
systemRow(
`Connecting ${providerName} from the running session isn't wired up yet — run /model after restarting, or reconnect via onboarding.`,
);
void (async () => {
let result: Awaited<ReturnType<typeof connectProviderInline>>;
try {
result = await connectProviderInline({
providerId: providerName,
settingsPath: trueGlobalSettingsPath,
localSettingsPath: localSettingsFile,
cwd: config.cwd,
existing: config.settings ?? null,
});
} catch (err) {
systemRow(
`Connecting ${providerName} failed: ${err instanceof Error ? err.message : String(err)}`,
);
return;
}
if (!result.connected) return;

const onDisk = await loadSettings(trueGlobalSettingsPath);
const resolvedForCatalog: ResolvedProvider = {
apiKey: config.apiKey,
baseURL: config.baseURL,
model: config.model,
providerName: config.providerName,
...(config.keyless !== undefined ? { keyless: config.keyless } : {}),
};
const providers = await refreshLiveProviderCatalog(onDisk, resolvedForCatalog);
config = { ...config, providers, ...(onDisk !== null ? { settings: onDisk } : {}) };
liveSubAgentCatalog.current = providers;
liveSubAgentSettings.current = config.settings;
host.refreshModels(
listRecentModels(config.settings ?? { providers: {} }),
listFavoriteModels(config.settings ?? { providers: {} }),
providers,
computeUnconnectedProviders(providers),
);
systemRow(`Connected ${result.providerName ?? providerName}. Open /model to pick a model.`);
})().catch((err: unknown) => {
tuiLogger.debug("provider connect failed: {error}", {
error: err instanceof Error ? err.message : String(err),
});
});
},
modelLabel: () => ({
profile: config.providerName,
Expand Down
Loading