diff --git a/src/config/index.ts b/src/config/index.ts index f5a548a5b..3310d189f 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -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 } @@ -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 { + 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). diff --git a/src/tui-opentui/provider-setup.test.ts b/src/tui-opentui/provider-setup.test.ts index ff0437569..0b7413da1 100644 --- a/src/tui-opentui/provider-setup.test.ts +++ b/src/tui-opentui/provider-setup.test.ts @@ -4,6 +4,7 @@ import { createHarness, type Harness } from "./harness.js" import { CUSTOM_CHOICE_ID, failureGuidance, + isChoiceConnected, LOGIN_CANCELLED_MESSAGE, LOGIN_TIMEOUT_MESSAGE, maskEcho, @@ -20,6 +21,7 @@ import { stepsFor, summaryRows, TYPE_MODEL_ID, + unconnectedProviderChoices, type OAuthLoginStarter, type ProviderFormValues, type ProviderSetupSubmit, @@ -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/" — 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() diff --git a/src/tui-opentui/provider-setup.ts b/src/tui-opentui/provider-setup.ts index 483d969e0..4ecbfd27a 100644 --- a/src/tui-opentui/provider-setup.ts +++ b/src/tui-opentui/provider-setup.ts @@ -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/` / `xai/` — 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(), diff --git a/src/tui-opentui/runner-host.test.ts b/src/tui-opentui/runner-host.test.ts index 3395e8d76..741d6a953 100644 --- a/src/tui-opentui/runner-host.test.ts +++ b/src/tui-opentui/runner-host.test.ts @@ -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[] = [] diff --git a/src/tui-opentui/runner-host.ts b/src/tui-opentui/runner-host.ts index 41dacf498..b98abd8d8 100644 --- a/src/tui-opentui/runner-host.ts +++ b/src/tui-opentui/runner-host.ts @@ -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 @@ -210,16 +216,20 @@ export function observeSessionFromSubAgents( /** Mount the OpenTUI host for a live session. */ export async function mountRunnerHost(deps: RunnerHostDeps): Promise { + // 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 => { @@ -323,12 +333,16 @@ export async function mountRunnerHost(deps: RunnerHostDeps): Promise 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) } diff --git a/src/tui/runner.ts b/src/tui/runner.ts index 49a5d0ed8..c24367b8c 100644 --- a/src/tui/runner.ts +++ b/src/tui/runner.ts @@ -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, @@ -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"; @@ -1930,6 +1938,17 @@ 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. + // 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, @@ -1954,21 +1973,50 @@ export async function runTUI(initialConfig: Config): Promise { 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>; + 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,