Skip to content

Commit c33d528

Browse files
Merge pull request #397 from corbitsdev/cl-5602-adding-new-models-doesnt-work
Wire the in-session provider connect flow to actually connect
2 parents 3d1bc83 + 0f49003 commit c33d528

6 files changed

Lines changed: 195 additions & 26 deletions

File tree

src/config/index.ts

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -557,11 +557,7 @@ export async function loadConfig(
557557
noWorkflow,
558558
...(profile.workflow !== undefined ? { workflow: profile.workflow } : {}),
559559
...(settings?.defaultProvider !== undefined ? { globalDefaultProvider: settings.defaultProvider } : {}),
560-
providers: [
561-
...buildProviderCatalog(settings, resolved).filter((e) => !isCodexProviderName(e.name) && !isXaiProviderName(e.name)),
562-
...codexProfilesToCatalogEntries(codexProfiles),
563-
...xaiProfilesToCatalogEntries(xaiProfiles),
564-
],
560+
providers: mergeOAuthCatalog(settings, resolved, codexProfiles, xaiProfiles),
565561
...(profile.profile !== undefined ? { profile: profile.profile } : {}),
566562
...(profile.systemPromptExtensions !== undefined
567563
? { systemPromptExtensions: profile.systemPromptExtensions }
@@ -583,6 +579,35 @@ export async function loadConfig(
583579
};
584580
}
585581

582+
// Settings-file providers plus Codex/xAI OAuth profile-store entries, merged
583+
// the same way loadConfig assembles Config.providers. Exposed so a live
584+
// provider connect (mid-session, no restart) can rebuild the picker's
585+
// catalog after writing new credentials, instead of only taking effect on
586+
// the next process start.
587+
function mergeOAuthCatalog(
588+
settings: Settings | null,
589+
resolved: ResolvedProvider,
590+
codexProfiles: readonly CodexProfile[],
591+
xaiProfiles: readonly XaiProfile[],
592+
): ProviderCatalogEntry[] {
593+
return [
594+
...buildProviderCatalog(settings, resolved).filter(
595+
(e) => !isCodexProviderName(e.name) && !isXaiProviderName(e.name),
596+
),
597+
...codexProfilesToCatalogEntries(codexProfiles),
598+
...xaiProfilesToCatalogEntries(xaiProfiles),
599+
];
600+
}
601+
602+
/** Rescans home-level Codex/xAI credential stores and rebuilds the live provider catalog. */
603+
export async function refreshLiveProviderCatalog(
604+
settings: Settings | null,
605+
resolved: ResolvedProvider,
606+
): Promise<ProviderCatalogEntry[]> {
607+
const [codexProfiles, xaiProfiles] = await Promise.all([listCodexProfiles(), listXaiProfiles()]);
608+
return mergeOAuthCatalog(settings, resolved, codexProfiles, xaiProfiles);
609+
}
610+
586611
export function catalogEntryAsProviderSettings(entry: ProviderCatalogEntry): ProviderSettings {
587612
// Anthropic and Go anthropic-protocol bases must not be forced through the
588613
// OpenAI-compatible normalizer (which assumes a /v1 chat-completions root).

src/tui-opentui/provider-setup.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { createHarness, type Harness } from "./harness.js"
44
import {
55
CUSTOM_CHOICE_ID,
66
failureGuidance,
7+
isChoiceConnected,
78
LOGIN_CANCELLED_MESSAGE,
89
LOGIN_TIMEOUT_MESSAGE,
910
maskEcho,
@@ -20,6 +21,7 @@ import {
2021
stepsFor,
2122
summaryRows,
2223
TYPE_MODEL_ID,
24+
unconnectedProviderChoices,
2325
type OAuthLoginStarter,
2426
type ProviderFormValues,
2527
type ProviderSetupSubmit,
@@ -111,6 +113,23 @@ describe("provider setup pure helpers", () => {
111113
expect(providerChoiceRows(choices)[0]?.label).toContain("OpenAI")
112114
})
113115

116+
test("a connected Codex account clears the ChatGPT connect row (CL-5606)", () => {
117+
// The ChatGPT-via-browser choice is keyed "codex", but a signed-in
118+
// account lands in the catalog as "codex/<profile>" — one row per
119+
// account. Exact-id matching alone would leave the connect row stuck
120+
// forever after a successful login.
121+
const connected = [{ name: "codex/default" }]
122+
expect(unconnectedProviderChoices(connected).map((c) => c.id)).not.toContain("codex")
123+
expect(unconnectedProviderChoices([]).map((c) => c.id)).toContain("codex")
124+
})
125+
126+
test("isChoiceConnected does not prefix-match key-based providers", () => {
127+
const openaiChoice = providerChoiceById("openai")
128+
if (openaiChoice === undefined) throw new Error("expected an openai choice")
129+
expect(isChoiceConnected(openaiChoice, [{ name: "openai-eu" }])).toBe(false)
130+
expect(isChoiceConnected(openaiChoice, [{ name: "openai" }])).toBe(true)
131+
})
132+
114133
test("model rows come from the provider catalog plus a free-text escape", () => {
115134
const openai = providerChoiceById("openai")
116135
expect(openai).toBeDefined()

src/tui-opentui/provider-setup.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,30 @@ export function providerChoiceById(id: string): ProviderChoice | undefined {
277277
return providerChoices().find((c) => c.id === id)
278278
}
279279

280+
/**
281+
* Whether `choice` already has a connected provider in `providers`. OAuth
282+
* choices (`codex`, `xai`) are keyed by vendor id, but a signed-in account
283+
* lands in the catalog as `codex/<profile>` / `xai/<profile>` — one row per
284+
* account — so exact-id matching alone never clears the "connect" row after
285+
* a successful browser login. Key-based choices still match by exact id.
286+
*/
287+
export function isChoiceConnected(
288+
choice: ProviderChoice,
289+
providers: readonly { readonly name: string }[],
290+
): boolean {
291+
return providers.some(
292+
(p) => p.name === choice.id || (choice.oauth !== null && p.name.startsWith(`${choice.id}/`)),
293+
)
294+
}
295+
296+
/** Known choices with no connected provider yet — the picker's "connect →" rows. */
297+
export function unconnectedProviderChoices(
298+
providers: readonly { readonly name: string }[],
299+
choices: readonly ProviderChoice[] = providerChoices(),
300+
): readonly ProviderChoice[] {
301+
return choices.filter((choice) => !choice.custom && !isChoiceConnected(choice, providers))
302+
}
303+
280304
/** Pick-list rows for the provider step. */
281305
export function providerChoiceRows(
282306
choices: readonly ProviderChoice[] = providerChoices(),

src/tui-opentui/runner-host.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,45 @@ describe("mountRunnerHost model picker", () => {
227227
}
228228
})
229229

230+
test("refreshModels swaps in a freshly connected provider and drops its connect row", async () => {
231+
// Mount-time deps are a snapshot; a live provider connect (CL-5602) must be
232+
// able to replace them without remounting the host, or the newly connected
233+
// provider's models never appear and its "connect →" row never clears.
234+
const harness = await createHarness({ width: 80, height: 24 })
235+
const host = await mountRunnerHost({
236+
title: "test",
237+
eventEmitter: new EventEmitter(),
238+
send: () => {},
239+
interrupt: () => {},
240+
providers: { xai: { models: ["grok-4"] } },
241+
onModelSelect: () => {},
242+
unconnectedProviders: [
243+
{ name: "openai", label: "OpenAI", modelCount: 1, authKind: "key" },
244+
],
245+
commands: [],
246+
onCommand: () => {},
247+
chrome: () => ({ goal: null, agents: [] }),
248+
subAgentSessions: () => [],
249+
createRenderer: async () => harness.renderer,
250+
})
251+
try {
252+
host.refreshModels(
253+
[],
254+
[],
255+
{ xai: { models: ["grok-4"] }, openai: { models: ["gpt-5"] } },
256+
[],
257+
)
258+
expect(host.openSurface("models")).toBe(true)
259+
// The connect row is gone and the provider now has its own group row
260+
// (drilling into it would surface "gpt-5") instead of a stub message.
261+
expect(host.shell.overlayItems).toContain("openai")
262+
expect(host.shell.overlayItems).not.toContain("OpenAI — connect →")
263+
} finally {
264+
host.dispose()
265+
harness.destroy()
266+
}
267+
})
268+
230269
test("f toggles favorite on the focused row via onFavoriteToggle", async () => {
231270
const harness = await createHarness({ width: 80, height: 24 })
232271
const toggled: string[] = []

src/tui-opentui/runner-host.ts

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -132,10 +132,16 @@ export type RunnerHost = ProductHost & {
132132
* Recompute the models-first catalog from fresh recent/favorite refs and
133133
* push it into the already-open host — the picker's Recent/Favorites
134134
* sections would otherwise never reflect a same-session selection.
135+
*
136+
* `providers`/`unconnected` default to the values last passed here (or the
137+
* mount-time deps) — pass fresh ones after a live provider connect so a
138+
* newly authorized provider's models appear without a restart.
135139
*/
136140
readonly refreshModels: (
137141
recentModels: readonly ModelCatalogRef[],
138142
favoriteModels: readonly ModelCatalogRef[],
143+
providers?: ModelCatalogProvidersInput,
144+
unconnected?: readonly ModelCatalogUnconnectedProvider[],
139145
) => void
140146
/** Re-reads `showPromptCost` and cost/context state, repainting the border immediately. */
141147
readonly refreshCostContext: () => void
@@ -210,16 +216,20 @@ export function observeSessionFromSubAgents(
210216

211217
/** Mount the OpenTUI host for a live session. */
212218
export async function mountRunnerHost(deps: RunnerHostDeps): Promise<RunnerHost> {
219+
// Mutable so a live provider connect (see refreshModels below) can replace
220+
// the catalog source without remounting the host.
221+
let liveProviders = deps.providers
222+
let liveUnconnected = deps.unconnectedProviders ?? []
213223
let catalog: readonly ModelCatalogOption[] = buildModelsFirstCatalog({
214-
providers: deps.providers,
224+
providers: liveProviders,
215225
recent: deps.recentModels ?? [],
216226
favorites: deps.favoriteModels ?? [],
217-
unconnected: deps.unconnectedProviders ?? [],
227+
unconnected: liveUnconnected,
218228
})
219229
const describeModel = (itemId: string): ItemDescription | null =>
220230
describeModelCatalogOption(
221231
catalog.find((o) => o.id === itemId) ?? { id: itemId, label: itemId },
222-
{ unconnected: deps.unconnectedProviders ?? [] },
232+
{ unconnected: liveUnconnected },
223233
)
224234
const readModelLabel = deps.modelLabel
225235
const onModelSelect = (id: string): void => {
@@ -323,12 +333,16 @@ export async function mountRunnerHost(deps: RunnerHostDeps): Promise<RunnerHost>
323333
const refreshModels = (
324334
recentModels: readonly ModelCatalogRef[],
325335
favoriteModels: readonly ModelCatalogRef[],
336+
providers?: ModelCatalogProvidersInput,
337+
unconnected?: readonly ModelCatalogUnconnectedProvider[],
326338
): void => {
339+
if (providers !== undefined) liveProviders = providers
340+
if (unconnected !== undefined) liveUnconnected = unconnected
327341
catalog = buildModelsFirstCatalog({
328-
providers: deps.providers,
342+
providers: liveProviders,
329343
recent: recentModels,
330344
favorites: favoriteModels,
331-
unconnected: deps.unconnectedProviders ?? [],
345+
unconnected: liveUnconnected,
332346
})
333347
host.setModels?.(catalog, describeModel)
334348
}

src/tui/runner.ts

Lines changed: 64 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,13 @@ import { noopAuditStore, permissiveAuthorize } from "@intx/agent/testing";
1313
import { getLogger } from "@intx/log";
1414
import { createOptimizedContextStore, loadRecentTurns } from "../session/optimized-context-store.js";
1515
import { type } from "arktype";
16-
import { buildCodexSource, buildOpenAISource, buildXaiSource, type Config } from "../config/index.js";
16+
import {
17+
buildCodexSource,
18+
buildOpenAISource,
19+
buildXaiSource,
20+
refreshLiveProviderCatalog,
21+
type Config,
22+
} from "../config/index.js";
1723
import {
1824
globalSettingsPath,
1925
loadLocalSettings,
@@ -31,11 +37,13 @@ import {
3137
markLastChangelogVersion,
3238
toggleFavoriteModel,
3339
type ModelRef,
40+
type ResolvedProvider,
3441
type Settings,
3542
type LocalSettings,
3643
type PluginConfig,
3744
} from "../config/settings.js";
38-
import { providerChoices } from "../tui-opentui/provider-setup.js";
45+
import { unconnectedProviderChoices } from "../tui-opentui/provider-setup.js";
46+
import { connectProviderInline } from "../tui-opentui/provider-connect.js";
3947
import type { SessionModeScope } from "../tui-opentui/command-surfaces.js";
4048
import { resolveWaitForApproval, type ToolWatchdogConfig } from "./tool-execution-watchdog.js";
4149
import { createGateRequestApproval } from "./request-approval.js";
@@ -1930,6 +1938,17 @@ export async function runTUI(initialConfig: Config): Promise<number> {
19301938
// Mount OpenTUI before the initial task is sent so gate and stream listeners
19311939
// are registered first. Ctrl+C stays with the shell (interrupt the run);
19321940
// OpenTUI owns the alternate screen and mouse reporting itself.
1941+
// Providers the picker offers as "connect →" rows: known choices minus
1942+
// whatever is already in the live catalog. Recomputed after a live connect
1943+
// so a newly authorized provider drops out of this list immediately.
1944+
const computeUnconnectedProviders = (providers: Config["providers"]) =>
1945+
unconnectedProviderChoices(providers).map((choice) => ({
1946+
name: choice.id,
1947+
label: choice.label,
1948+
modelCount: choice.models.length,
1949+
authKind: choice.oauth !== null ? ("oauth" as const) : ("key" as const),
1950+
}));
1951+
19331952
const host = await mountRunnerHost({
19341953
// An unnamed session shows nothing rather than a placeholder.
19351954
title: runTaskTitle,
@@ -1954,21 +1973,50 @@ export async function runTUI(initialConfig: Config): Promise<number> {
19541973
providers: config.providers,
19551974
recentModels: listRecentModels(config.settings ?? { providers: {} }),
19561975
favoriteModels: listFavoriteModels(config.settings ?? { providers: {} }),
1957-
unconnectedProviders: providerChoices()
1958-
.filter((choice) => !choice.custom)
1959-
.filter((choice) => !config.providers.some((p) => p.name === choice.id))
1960-
.map((choice) => ({
1961-
name: choice.id,
1962-
label: choice.label,
1963-
modelCount: choice.models.length,
1964-
authKind: choice.oauth !== null ? ("oauth" as const) : ("key" as const),
1965-
})),
1976+
unconnectedProviders: computeUnconnectedProviders(config.providers),
19661977
onConnectProvider: (providerName) => {
1967-
// Live inline connect (CL-5499) needs a text-input-capable overlay that
1968-
// does not exist in shell.ts's list-overlay kit yet — see AGENTS report.
1969-
systemRow(
1970-
`Connecting ${providerName} from the running session isn't wired up yet — run /model after restarting, or reconnect via onboarding.`,
1971-
);
1978+
void (async () => {
1979+
let result: Awaited<ReturnType<typeof connectProviderInline>>;
1980+
try {
1981+
result = await connectProviderInline({
1982+
providerId: providerName,
1983+
settingsPath: trueGlobalSettingsPath,
1984+
localSettingsPath: localSettingsFile,
1985+
cwd: config.cwd,
1986+
existing: config.settings ?? null,
1987+
});
1988+
} catch (err) {
1989+
systemRow(
1990+
`Connecting ${providerName} failed: ${err instanceof Error ? err.message : String(err)}`,
1991+
);
1992+
return;
1993+
}
1994+
if (!result.connected) return;
1995+
1996+
const onDisk = await loadSettings(trueGlobalSettingsPath);
1997+
const resolvedForCatalog: ResolvedProvider = {
1998+
apiKey: config.apiKey,
1999+
baseURL: config.baseURL,
2000+
model: config.model,
2001+
providerName: config.providerName,
2002+
...(config.keyless !== undefined ? { keyless: config.keyless } : {}),
2003+
};
2004+
const providers = await refreshLiveProviderCatalog(onDisk, resolvedForCatalog);
2005+
config = { ...config, providers, ...(onDisk !== null ? { settings: onDisk } : {}) };
2006+
liveSubAgentCatalog.current = providers;
2007+
liveSubAgentSettings.current = config.settings;
2008+
host.refreshModels(
2009+
listRecentModels(config.settings ?? { providers: {} }),
2010+
listFavoriteModels(config.settings ?? { providers: {} }),
2011+
providers,
2012+
computeUnconnectedProviders(providers),
2013+
);
2014+
systemRow(`Connected ${result.providerName ?? providerName}. Open /model to pick a model.`);
2015+
})().catch((err: unknown) => {
2016+
tuiLogger.debug("provider connect failed: {error}", {
2017+
error: err instanceof Error ? err.message : String(err),
2018+
});
2019+
});
19722020
},
19732021
modelLabel: () => ({
19742022
profile: config.providerName,

0 commit comments

Comments
 (0)