Files
orcs-code/src/components/ProviderManager.tsx
Kevin Codex b95d2221df Feat/kimi moonshot support (#805)
* feat(provider): first-class Moonshot (Kimi) direct-API support

Moonshot's direct API (api.moonshot.ai/v1) is OpenAI-compatible and works
today via the generic OpenAI shim, including the reasoning_content channel
that Kimi returns alongside the user-visible content. But the UX was rough:
unknown context window triggered the conservative 128k fallback + a warning,
and the provider displayed as "Local OpenAI-compatible".

Makes Moonshot a recognized provider:

- src/utils/model/openaiContextWindows.ts: add the Kimi K2 family and
  moonshot-v1-* variants to both the context-window and max-output tables.
  Values from Moonshot's model card — K2.6 and K2-thinking are 256K,
  K2/K2-instruct are 128K, moonshot-v1 sizes are embedded in the model id.
- src/utils/providerDiscovery.ts: recognize the api.moonshot.ai hostname
  and label it "Moonshot (Kimi)" in the startup banner and provider UI.

Users can now launch with:

  CLAUDE_CODE_USE_OPENAI=1 \
  OPENAI_BASE_URL=https://api.moonshot.ai/v1 \
  OPENAI_API_KEY=sk-... \
  OPENAI_MODEL=kimi-k2.6 \
  openclaude

and get accurate compaction + correct labeling + correct max_tokens out
of the box.

Co-Authored-By: OpenClaude <openclaude@gitlawb.com>

* fix(openai-shim): Moonshot API compatibility — max_tokens + strip store

Moonshot's direct API (api.moonshot.ai and api.moonshot.cn) uses the
classic OpenAI `max_tokens` parameter, not the newer `max_completion_tokens`
that the shim defaults to. It also hasn't published support for `store`
and may reject it on strict-parse — same class of error as Gemini's
"Unknown name 'store': Cannot find field" 400.

- Adds isMoonshotBaseUrl() that recognizes both .ai and .cn hosts.
- Converts max_completion_tokens → max_tokens for Moonshot requests
  (alongside GitHub / Mistral / local providers).
- Strips body.store for Moonshot requests (alongside Mistral / Gemini).

Two shim tests cover both the .ai and .cn hostnames.

Co-Authored-By: OpenClaude <openclaude@gitlawb.com>

* fix: null-safe access on getCachedMCConfig() in external builds

External builds stub src/services/compact/cachedMicrocompact.ts so
getCachedMCConfig() returns null, but two call sites still dereferenced
config.supportedModels directly. The ?. operator was in the wrong place
(config.supportedModels? instead of config?.supportedModels), so the null
config threw "Cannot read properties of null (reading 'supportedModels')"
on every request.

Reproduces with any external-build provider (notably Kimi/Moonshot just
enabled in the sibling commits, but equally DeepSeek, Mistral, Groq,
Ollama, etc.):

  ❯ hey
  ⏺ Cannot read properties of null (reading 'supportedModels')

- prompts.ts: early-return from getFunctionResultClearingSection() when
  config is null, before touching .supportedModels.
- claude.ts: guard the debug-log jsonStringify with ?. so the log line
  never throws.

Co-Authored-By: OpenClaude <openclaude@gitlawb.com>

* fix(startup): show "Moonshot (Kimi)" on the startup banner

The startup-screen provider detector had regex branches for OpenRouter,
DeepSeek, Groq, Together, Azure, etc., but nothing for Moonshot. Remote
Moonshot sessions fell through to the generic "OpenAI" label —
getLocalOpenAICompatibleProviderLabel() only runs for local URLs, and
api.moonshot.ai / api.moonshot.cn are not local.

Adds a Moonshot branch matching /moonshot/ in the base URL OR /kimi/ in
the model id. Now launches with:

  OPENAI_BASE_URL=https://api.moonshot.ai/v1 OPENAI_MODEL=kimi-k2.6

display the Provider row as "Moonshot (Kimi)" instead of "OpenAI".

Co-Authored-By: OpenClaude <openclaude@gitlawb.com>

* refactor(provider): sort preset picker alphabetically; Custom at end

The /provider preset picker was in ad-hoc order (Anthropic, Ollama,
OpenAI, then a jumble of third-party / local / codex / Alibaba / custom /
nvidia / minimax). Hard to scan when you know the provider name you want.

Sorts the list alphabetically by label A→Z. Pins "Custom" to the end —
it's the catch-all / escape hatch so it's scanned last, not shuffled into
the alphabetical run where a user looking for a named provider might
grab it by mistake. First-run-only "Skip for now" stays at the very
bottom, after Custom.

Test churn:
- ProviderManager.test.tsx: four tests hardcoded press counts (1 or 3 'j'
  presses) that broke when targets moved. Replaces them with a
  navigateToPreset(stdin, label) helper driven from a declared
  PRESET_ORDER array, so future list edits only update the array.
- ConsoleOAuthFlow.test.tsx: the 13-row test frame only renders the first
  ~13 providers. "Ollama", "OpenAI", "LM Studio" sentinels moved below
  the fold; swap them for alphabetically-early providers still visible
  in-frame ("Azure OpenAI", "DeepSeek", "Google Gemini"). Test intent
  (picker opened with providers listed) is preserved.

Co-Authored-By: OpenClaude <openclaude@gitlawb.com>

---------

Co-authored-by: OpenClaude <openclaude@gitlawb.com>
2026-04-21 21:20:54 +08:00

1651 lines
51 KiB
TypeScript

import figures from 'figures'
import * as React from 'react'
import { DEFAULT_CODEX_BASE_URL } from '../services/api/providerConfig.js'
import { Box, Text } from '../ink.js'
import { useKeybinding } from '../keybindings/useKeybinding.js'
import { useSetAppState } from '../state/AppState.js'
import type { ProviderProfile } from '../utils/config.js'
import {
clearCodexCredentials,
readCodexCredentialsAsync,
} from '../utils/codexCredentials.js'
import { isBareMode, isEnvTruthy } from '../utils/envUtils.js'
import { getPrimaryModel, hasMultipleModels, parseModelList } from '../utils/providerModels.js'
import {
applySavedProfileToCurrentSession,
buildCodexOAuthProfileEnv,
clearPersistedCodexOAuthProfile,
createProfileFile,
} from '../utils/providerProfile.js'
import {
addProviderProfile,
applyActiveProviderProfileFromConfig,
deleteProviderProfile,
getActiveProviderProfile,
getProviderPresetDefaults,
getProviderProfiles,
setActiveProviderProfile,
type ProviderPreset,
type ProviderProfileInput,
updateProviderProfile,
} from '../utils/providerProfiles.js'
import {
clearGithubModelsToken,
GITHUB_MODELS_HYDRATED_ENV_MARKER,
hydrateGithubModelsTokenFromSecureStorage,
readGithubModelsToken,
readGithubModelsTokenAsync,
} from '../utils/githubModelsCredentials.js'
import {
probeOllamaGenerationReadiness,
type OllamaGenerationReadiness,
} from '../utils/providerDiscovery.js'
import {
rankOllamaModels,
recommendOllamaModel,
} from '../utils/providerRecommendation.js'
import { redactUrlForDisplay } from '../utils/urlRedaction.js'
import { updateSettingsForSource } from '../utils/settings/settings.js'
import {
type OptionWithDescription,
Select,
} from './CustomSelect/index.js'
import { Pane } from './design-system/Pane.js'
import TextInput from './TextInput.js'
import { useCodexOAuthFlow } from './useCodexOAuthFlow.js'
export type ProviderManagerResult = {
action: 'saved' | 'cancelled'
activeProfileId?: string
message?: string
}
type Props = {
mode: 'first-run' | 'manage'
onDone: (result?: ProviderManagerResult) => void
}
type Screen =
| 'menu'
| 'select-preset'
| 'select-ollama-model'
| 'codex-oauth'
| 'form'
| 'select-active'
| 'select-edit'
| 'select-delete'
type DraftField = 'name' | 'baseUrl' | 'model' | 'apiKey'
type ProviderDraft = Record<DraftField, string>
type OllamaSelectionState =
| { state: 'idle' }
| { state: 'loading' }
| {
state: 'ready'
options: OptionWithDescription<string>[]
defaultValue?: string
}
| { state: 'unavailable'; message: string }
const FORM_STEPS: Array<{
key: DraftField
label: string
placeholder: string
helpText: string
optional?: boolean
}> = [
{
key: 'name',
label: 'Provider name',
placeholder: 'e.g. Ollama Home, OpenAI Work',
helpText: 'A short label shown in /provider and startup setup.',
},
{
key: 'baseUrl',
label: 'Base URL',
placeholder: 'e.g. http://localhost:11434/v1',
helpText: 'API base URL used for this provider profile.',
},
{
key: 'model',
label: 'Default model',
placeholder: 'e.g. llama3.1:8b or glm-4.7, glm-4.7-flash',
helpText: 'Model name(s) to use. Separate multiple with commas; first is default.',
},
{
key: 'apiKey',
label: 'API key',
placeholder: 'Leave empty if your provider does not require one',
helpText: 'Optional. Press Enter with empty value to skip.',
optional: true,
},
]
const GITHUB_PROVIDER_ID = '__github_models__'
const GITHUB_PROVIDER_LABEL = 'GitHub Models'
const GITHUB_PROVIDER_DEFAULT_MODEL = 'github:copilot'
const GITHUB_PROVIDER_DEFAULT_BASE_URL = 'https://models.github.ai/inference'
const CODEX_OAUTH_PROVIDER_NAME = 'Codex OAuth'
const CODEX_OAUTH_PROVIDER_MODEL = 'codexplan'
type GithubCredentialSource = 'stored' | 'env' | 'none'
function toDraft(profile: ProviderProfile): ProviderDraft {
return {
name: profile.name,
baseUrl: profile.baseUrl,
model: profile.model,
apiKey: profile.apiKey ?? '',
}
}
function presetToDraft(preset: ProviderPreset): ProviderDraft {
const defaults = getProviderPresetDefaults(preset)
return {
name: defaults.name,
baseUrl: defaults.baseUrl,
model: defaults.model,
apiKey: defaults.apiKey ?? '',
}
}
function profileSummary(profile: ProviderProfile, isActive: boolean): string {
const activeSuffix = isActive ? ' (active)' : ''
const keyInfo = profile.apiKey ? 'key set' : 'no key'
const providerKind =
profile.provider === 'anthropic' ? 'anthropic' : 'openai-compatible'
const models = parseModelList(profile.model)
const modelDisplay =
models.length <= 3
? models.join(', ')
: `${models[0]}, ${models[1]} + ${models.length - 2} more`
return `${providerKind} · ${profile.baseUrl} · ${modelDisplay} · ${keyInfo}${activeSuffix}`
}
function getGithubCredentialSourceFromEnv(
processEnv: NodeJS.ProcessEnv = process.env,
): GithubCredentialSource {
if (processEnv.GITHUB_TOKEN?.trim() || processEnv.GH_TOKEN?.trim()) {
return 'env'
}
return 'none'
}
async function resolveGithubCredentialSource(
processEnv: NodeJS.ProcessEnv = process.env,
): Promise<GithubCredentialSource> {
const envSource = getGithubCredentialSourceFromEnv(processEnv)
if (envSource !== 'none') {
return envSource
}
if (await readGithubModelsTokenAsync()) {
return 'stored'
}
return 'none'
}
function isGithubProviderAvailable(
credentialSource: GithubCredentialSource,
processEnv: NodeJS.ProcessEnv = process.env,
): boolean {
if (isEnvTruthy(processEnv.CLAUDE_CODE_USE_GITHUB)) {
return true
}
return credentialSource !== 'none'
}
function getGithubProviderModel(
processEnv: NodeJS.ProcessEnv = process.env,
): string {
if (isEnvTruthy(processEnv.CLAUDE_CODE_USE_GITHUB)) {
return processEnv.OPENAI_MODEL?.trim() || GITHUB_PROVIDER_DEFAULT_MODEL
}
return GITHUB_PROVIDER_DEFAULT_MODEL
}
function getGithubProviderSummary(
isActive: boolean,
credentialSource: GithubCredentialSource,
processEnv: NodeJS.ProcessEnv = process.env,
): string {
const credentialSummary =
credentialSource === 'stored'
? 'token stored'
: credentialSource === 'env'
? 'token via env'
: 'no token found'
const activeSuffix = isActive ? ' (active)' : ''
return `github-models · ${GITHUB_PROVIDER_DEFAULT_BASE_URL} · ${getGithubProviderModel(processEnv)} · ${credentialSummary}${activeSuffix}`
}
function describeOllamaSelectionIssue(
readiness: OllamaGenerationReadiness,
baseUrl: string,
): string {
if (readiness.state === 'unreachable') {
return `Could not reach Ollama at ${redactUrlForDisplay(baseUrl)}. Start Ollama first, or enter the endpoint manually.`
}
if (readiness.state === 'no_models') {
return 'Ollama is running, but no installed models were found. Pull a chat model such as qwen2.5-coder:7b or llama3.1:8b first, or enter details manually.'
}
if (readiness.state === 'generation_failed') {
const modelHint = readiness.probeModel ?? 'the selected model'
const detailSuffix = readiness.detail
? ` Details: ${readiness.detail}.`
: ''
return `Ollama is reachable and models are installed, but a generation probe failed for ${modelHint}.${detailSuffix} Run "ollama run ${modelHint}" once and retry, or enter details manually.`
}
return ''
}
function findCodexOAuthProfile(
profiles: ProviderProfile[],
profileId?: string,
): ProviderProfile | undefined {
if (!profileId) {
return undefined
}
return profiles.find(profile => profile.id === profileId)
}
function isCodexOAuthProfile(
profile: ProviderProfile | null | undefined,
profileId?: string,
): boolean {
return Boolean(profile && profileId && profile.id === profileId)
}
function CodexOAuthSetup({
onBack,
onConfigured,
}: {
onBack: () => void
onConfigured: (tokens: {
accessToken: string
refreshToken: string
accountId?: string
idToken?: string
apiKey?: string
}, persistCredentials: (options?: { profileId?: string }) => void) => void | Promise<void>
}): React.ReactNode {
const handleAuthenticated = React.useCallback(async (tokens: {
accessToken: string
refreshToken: string
accountId?: string
idToken?: string
apiKey?: string
}, persistCredentials: (options?: { profileId?: string }) => void) => {
await onConfigured(tokens, persistCredentials)
}, [onConfigured])
useKeybinding('confirm:no', onBack, [onBack])
const status = useCodexOAuthFlow({
onAuthenticated: handleAuthenticated,
})
if (status.state === 'error') {
return (
<Box flexDirection="column" gap={1}>
<Text color="error" bold>
Codex OAuth failed
</Text>
<Text>{status.message}</Text>
<Text dimColor>Press Enter or Esc to go back.</Text>
<Select
options={[
{
value: 'back',
label: 'Back',
description: 'Return to provider presets',
},
]}
onChange={onBack}
onCancel={onBack}
visibleOptionCount={1}
/>
</Box>
)
}
return (
<Box flexDirection="column" gap={1}>
<Text color="remember" bold>
Codex OAuth
</Text>
<Text>
Sign in with your ChatGPT account in the browser. OpenClaude will store
the resulting Codex credentials securely and switch this session to the
new Codex login when setup completes.
</Text>
{status.state === 'starting' ? (
<Text dimColor>Starting local callback and preparing your browser...</Text>
) : status.browserOpened === false ? (
<>
<Text color="warning">
Browser did not open automatically. Visit this URL to continue:
</Text>
<Text>{status.authUrl}</Text>
</>
) : status.browserOpened === true ? (
<>
<Text dimColor>
Browser opened. Finish the ChatGPT sign-in there and this setup will
complete automatically.
</Text>
<Text>{status.authUrl}</Text>
</>
) : (
<Text dimColor>Opening your browser...</Text>
)}
<Text dimColor>Press Esc to cancel and go back.</Text>
</Box>
)
}
export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
const setAppState = useSetAppState()
const initialGithubCredentialSource = getGithubCredentialSourceFromEnv()
const initialIsGithubActive = isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB)
const initialHasGithubCredential = initialGithubCredentialSource !== 'none'
// Deferred initialization: useState initializers run synchronously during
// render, so getProviderProfiles() and getActiveProviderProfile() would block
// the UI on first mount (sync file I/O). Use empty initial values and load
// asynchronously in useEffect with queueMicrotask to keep UI responsive.
const [profiles, setProfiles] = React.useState<ProviderProfile[]>([])
const [activeProfileId, setActiveProfileId] = React.useState<string | undefined>()
const [githubProviderAvailable, setGithubProviderAvailable] = React.useState(
() => isGithubProviderAvailable(initialGithubCredentialSource),
)
const [githubCredentialSource, setGithubCredentialSource] = React.useState<GithubCredentialSource>(
() => initialGithubCredentialSource,
)
const [isGithubActive, setIsGithubActive] = React.useState(() => initialIsGithubActive)
const [isGithubCredentialSourceResolved, setIsGithubCredentialSourceResolved] =
React.useState(() => initialHasGithubCredential || initialIsGithubActive)
const githubRefreshEpochRef = React.useRef(0)
const codexRefreshEpochRef = React.useRef(0)
const [screen, setScreen] = React.useState<Screen>(
mode === 'first-run' ? 'select-preset' : 'menu',
)
const [editingProfileId, setEditingProfileId] = React.useState<string | null>(null)
const [draftProvider, setDraftProvider] = React.useState<ProviderProfile['provider']>(
'openai',
)
const [draft, setDraft] = React.useState<ProviderDraft>(() =>
presetToDraft('ollama'),
)
const [formStepIndex, setFormStepIndex] = React.useState(0)
const [cursorOffset, setCursorOffset] = React.useState(0)
const [statusMessage, setStatusMessage] = React.useState<string | undefined>()
const [errorMessage, setErrorMessage] = React.useState<string | undefined>()
const [menuFocusValue, setMenuFocusValue] = React.useState<string | undefined>()
const [hasStoredCodexOAuthCredentials, setHasStoredCodexOAuthCredentials] =
React.useState(false)
const [storedCodexOAuthProfileId, setStoredCodexOAuthProfileId] =
React.useState<string | undefined>()
const [ollamaSelection, setOllamaSelection] = React.useState<OllamaSelectionState>({
state: 'idle',
})
// Deferred initialization: useState initializers run synchronously during
// render, so getProviderProfiles() and getActiveProviderProfile() would block
// the UI (sync file I/O). Defer to queueMicrotask after first render.
// In test environment, skip defer to avoid timing issues with mocks.
const [isInitializing, setIsInitializing] = React.useState(
process.env.NODE_ENV !== 'test',
)
const [isActivating, setIsActivating] = React.useState(false)
const isRefreshingRef = React.useRef(false)
React.useEffect(() => {
// Skip deferred initialization in test environment (mocks are synchronous)
if (process.env.NODE_ENV === 'test') {
setProfiles(getProviderProfiles())
setActiveProfileId(getActiveProviderProfile()?.id)
setIsInitializing(false)
return
}
queueMicrotask(() => {
const profilesData = getProviderProfiles()
const activeId = getActiveProviderProfile()?.id
setProfiles(profilesData)
setActiveProfileId(activeId)
setIsInitializing(false)
})
}, [])
const currentStep = FORM_STEPS[formStepIndex] ?? FORM_STEPS[0]
const currentStepKey = currentStep.key
const currentValue = draft[currentStepKey]
// Memoize menu options to prevent unnecessary re-renders when navigating
// the select menu. Without this, each arrow key press creates a new options
// array reference, causing Select to re-render and feel sluggish.
const hasProfiles = profiles.length > 0
const hasSelectableProviders = hasProfiles || githubProviderAvailable
const menuOptions = React.useMemo(
() => [
{
value: 'add',
label: 'Add provider',
description: 'Create a new provider profile',
},
{
value: 'activate',
label: 'Set active provider',
description: 'Switch the active provider profile',
disabled: !hasSelectableProviders,
},
{
value: 'edit',
label: 'Edit provider',
description: 'Update URL, model, or key',
disabled: !hasProfiles,
},
{
value: 'delete',
label: 'Delete provider',
description: 'Remove a provider profile',
disabled: !hasSelectableProviders,
},
...(hasStoredCodexOAuthCredentials
? [
{
value: 'logout-codex-oauth',
label: 'Log out Codex OAuth',
description: 'Clear securely stored Codex OAuth credentials',
},
]
: []),
{
value: 'done',
label: 'Done',
description: 'Return to chat',
},
],
[hasSelectableProviders, hasProfiles, hasStoredCodexOAuthCredentials],
)
const refreshGithubProviderState = React.useCallback((): void => {
const envCredentialSource = getGithubCredentialSourceFromEnv()
const githubActive = isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB)
const canResolveFromEnv = githubActive || envCredentialSource !== 'none'
if (canResolveFromEnv) {
githubRefreshEpochRef.current += 1
setGithubCredentialSource(envCredentialSource)
setGithubProviderAvailable(isGithubProviderAvailable(envCredentialSource))
setIsGithubActive(githubActive)
setIsGithubCredentialSourceResolved(true)
return
}
setIsGithubCredentialSourceResolved(false)
const refreshEpoch = ++githubRefreshEpochRef.current
void (async () => {
const credentialSource = await resolveGithubCredentialSource()
if (refreshEpoch !== githubRefreshEpochRef.current) {
return
}
setGithubCredentialSource(credentialSource)
setGithubProviderAvailable(isGithubProviderAvailable(credentialSource))
setIsGithubActive(isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB))
setIsGithubCredentialSourceResolved(true)
})()
}, [])
const refreshCodexOAuthCredentialState = React.useCallback((): void => {
if (isBareMode()) {
codexRefreshEpochRef.current += 1
setHasStoredCodexOAuthCredentials(false)
setStoredCodexOAuthProfileId(undefined)
return
}
const refreshEpoch = ++codexRefreshEpochRef.current
void (async () => {
const credentials = await readCodexCredentialsAsync()
if (refreshEpoch !== codexRefreshEpochRef.current) {
return
}
setHasStoredCodexOAuthCredentials(
Boolean(
credentials?.apiKey ||
credentials?.accessToken ||
credentials?.refreshToken ||
credentials?.idToken,
),
)
setStoredCodexOAuthProfileId(credentials?.profileId)
})()
}, [])
React.useEffect(() => {
refreshGithubProviderState()
refreshCodexOAuthCredentialState()
return () => {
githubRefreshEpochRef.current += 1
codexRefreshEpochRef.current += 1
}
}, [refreshCodexOAuthCredentialState, refreshGithubProviderState])
React.useEffect(() => {
if (screen !== 'select-ollama-model') {
return
}
let cancelled = false
setOllamaSelection({ state: 'loading' })
void (async () => {
const readiness = await probeOllamaGenerationReadiness({
baseUrl: draft.baseUrl,
})
if (readiness.state !== 'ready') {
if (!cancelled) {
setOllamaSelection({
state: 'unavailable',
message: describeOllamaSelectionIssue(readiness, draft.baseUrl),
})
}
return
}
const ranked = rankOllamaModels(readiness.models, 'balanced')
const recommended = recommendOllamaModel(readiness.models, 'balanced')
if (!cancelled) {
setOllamaSelection({
state: 'ready',
defaultValue: recommended?.name ?? ranked[0]?.name,
options: ranked.map(model => ({
label: model.name,
value: model.name,
description: model.summary,
})),
})
}
})()
return () => {
cancelled = true
}
}, [draft.baseUrl, screen])
function refreshProfiles(): void {
// Defer sync I/O to next microtask to prevent UI freeze.
// getProviderProfiles() and getActiveProviderProfile() read config files
// synchronously, which can block the main thread on Windows (antivirus, disk cache).
// queueMicrotask ensures the current render completes first.
if (isRefreshingRef.current) return
isRefreshingRef.current = true
queueMicrotask(() => {
const nextProfiles = getProviderProfiles()
setProfiles(nextProfiles)
setActiveProfileId(getActiveProviderProfile()?.id)
refreshGithubProviderState()
refreshCodexOAuthCredentialState()
isRefreshingRef.current = false
})
}
function clearStartupProviderOverrideFromUserSettings(): string | null {
const { error } = updateSettingsForSource('userSettings', {
env: {
CLAUDE_CODE_USE_OPENAI: undefined as any,
CLAUDE_CODE_USE_GEMINI: undefined as any,
CLAUDE_CODE_USE_GITHUB: undefined as any,
CLAUDE_CODE_USE_BEDROCK: undefined as any,
CLAUDE_CODE_USE_VERTEX: undefined as any,
CLAUDE_CODE_USE_FOUNDRY: undefined as any,
},
})
return error ? error.message : null
}
function buildCodexOAuthActivationMessage(options: {
prefix: string
activationWarning: string | null
warnings: string[]
}): string {
if (options.activationWarning) {
return `${options.prefix}. Saved for next startup. Warning: ${options.warnings.join('; ')}.`
}
if (options.warnings.length > 0) {
return `${options.prefix}. OpenClaude switched to it for this session with warnings: ${options.warnings.join('; ')}.`
}
return `${options.prefix}. OpenClaude switched to it for this session.`
}
async function activateCodexOAuthSession(tokens?: {
accessToken: string
refreshToken?: string
accountId?: string
idToken?: string
}): Promise<string | null> {
const oauthEnv = buildCodexOAuthProfileEnv({
accessToken: tokens?.accessToken ?? '',
accountId: tokens?.accountId,
idToken: tokens?.idToken,
})
if (oauthEnv) {
return applySavedProfileToCurrentSession({
profileFile: createProfileFile('codex', oauthEnv),
})
}
const storedCredentials = await readCodexCredentialsAsync()
if (!storedCredentials) {
return 'stored Codex OAuth credentials could not be loaded'
}
const storedEnv = buildCodexOAuthProfileEnv({
accessToken: storedCredentials.accessToken,
accountId: storedCredentials.accountId,
idToken: storedCredentials.idToken,
})
if (!storedEnv) {
return 'stored Codex OAuth credentials are missing a ChatGPT account id'
}
return applySavedProfileToCurrentSession({
profileFile: createProfileFile('codex', storedEnv),
})
}
async function activateSelectedProvider(profileId: string): Promise<void> {
let providerLabel = 'provider'
// Set loading state before sync I/O to keep UI responsive
setIsActivating(true)
setStatusMessage('Activating provider...')
try {
// Defer sync I/O to next microtask - UI renders loading state first.
// setActiveProviderProfile(), activateGithubProvider(), and
// clearStartupProviderOverrideFromUserSettings() all perform sync file writes
// (saveGlobalConfig, saveProfileFile, updateSettingsForSource) which can
// block the main thread on Windows (antivirus, disk cache, NTFS metadata).
await new Promise<void>(resolve => queueMicrotask(resolve))
if (profileId === GITHUB_PROVIDER_ID) {
providerLabel = GITHUB_PROVIDER_LABEL
const githubError = activateGithubProvider()
if (githubError) {
setErrorMessage(`Could not activate GitHub provider: ${githubError}`)
setIsActivating(false)
returnToMenu()
return
}
setAppState(prev => ({
...prev,
mainLoopModel: GITHUB_PROVIDER_DEFAULT_MODEL,
mainLoopModelForSession: null,
}))
refreshProfiles()
setAppState(prev => ({
...prev,
mainLoopModel: GITHUB_PROVIDER_DEFAULT_MODEL,
}))
setStatusMessage(`Active provider: ${GITHUB_PROVIDER_LABEL}`)
setIsActivating(false)
returnToMenu()
return
}
const active = setActiveProviderProfile(profileId)
if (!active) {
setErrorMessage('Could not change active provider.')
setIsActivating(false)
returnToMenu()
return
}
// Update the session model to the new provider's first model.
// persistActiveProviderProfileModel (called by onChangeAppState) will
// not overwrite the multi-model list because it checks if the model
// is already in the profile's comma-separated model list.
const newModel = getPrimaryModel(active.model)
setAppState(prev => ({
...prev,
mainLoopModel: newModel,
}))
providerLabel = active.name
setAppState(prev => ({
...prev,
mainLoopModel: active.model,
mainLoopModelForSession: null,
}))
const settingsOverrideError =
clearStartupProviderOverrideFromUserSettings()
const isActiveCodexOAuth = isCodexOAuthProfile(
active,
storedCodexOAuthProfileId,
)
const activationWarning = isActiveCodexOAuth
? await activateCodexOAuthSession()
: null
refreshProfiles()
setStatusMessage(
isActiveCodexOAuth
? buildCodexOAuthActivationMessage({
prefix: `Active provider: ${active.name}`,
activationWarning,
warnings: [
activationWarning,
settingsOverrideError
? `could not clear startup provider override (${settingsOverrideError})`
: null,
].filter((warning): warning is string => Boolean(warning)),
})
: settingsOverrideError
? `Active provider: ${active.name}. Warning: could not clear startup provider override (${settingsOverrideError}).`
: `Active provider: ${active.name}`,
)
setIsActivating(false)
returnToMenu()
} catch (error) {
refreshProfiles()
setStatusMessage(undefined)
setIsActivating(false)
const detail = error instanceof Error ? error.message : String(error)
setErrorMessage(`Could not finish activating ${providerLabel}: ${detail}`)
returnToMenu()
}
}
function returnToMenu(): void {
setMenuFocusValue('done')
setScreen('menu')
}
function closeWithCancelled(message: string): void {
onDone({ action: 'cancelled', message })
}
function activateGithubProvider(): string | null {
const { error } = updateSettingsForSource('userSettings', {
env: {
CLAUDE_CODE_USE_GITHUB: '1',
OPENAI_MODEL: GITHUB_PROVIDER_DEFAULT_MODEL,
OPENAI_API_KEY: undefined as any,
OPENAI_ORG: undefined as any,
OPENAI_PROJECT: undefined as any,
OPENAI_ORGANIZATION: undefined as any,
OPENAI_BASE_URL: undefined as any,
OPENAI_API_BASE: undefined as any,
CLAUDE_CODE_USE_OPENAI: undefined as any,
CLAUDE_CODE_USE_GEMINI: undefined as any,
CLAUDE_CODE_USE_BEDROCK: undefined as any,
CLAUDE_CODE_USE_VERTEX: undefined as any,
CLAUDE_CODE_USE_FOUNDRY: undefined as any,
},
})
if (error) {
return error.message
}
process.env.CLAUDE_CODE_USE_GITHUB = '1'
process.env.OPENAI_MODEL = GITHUB_PROVIDER_DEFAULT_MODEL
delete process.env.OPENAI_API_KEY
delete process.env.OPENAI_ORG
delete process.env.OPENAI_PROJECT
delete process.env.OPENAI_ORGANIZATION
delete process.env.OPENAI_BASE_URL
delete process.env.OPENAI_API_BASE
delete process.env.CLAUDE_CODE_USE_OPENAI
delete process.env.CLAUDE_CODE_USE_GEMINI
delete process.env.CLAUDE_CODE_USE_BEDROCK
delete process.env.CLAUDE_CODE_USE_VERTEX
delete process.env.CLAUDE_CODE_USE_FOUNDRY
delete process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED
delete process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID
delete process.env[GITHUB_MODELS_HYDRATED_ENV_MARKER]
hydrateGithubModelsTokenFromSecureStorage()
return null
}
function deleteGithubProvider(): string | null {
const storedTokenBeforeClear = readGithubModelsToken()?.trim()
const cleared = clearGithubModelsToken()
if (!cleared.success) {
return cleared.warning ?? 'Could not clear GitHub credentials.'
}
const { error } = updateSettingsForSource('userSettings', {
env: {
CLAUDE_CODE_USE_GITHUB: undefined as any,
OPENAI_MODEL: undefined as any,
OPENAI_BASE_URL: undefined as any,
OPENAI_API_BASE: undefined as any,
},
})
if (error) {
return error.message
}
const hydratedTokenInSession = process.env.GITHUB_TOKEN?.trim()
if (
process.env[GITHUB_MODELS_HYDRATED_ENV_MARKER] === '1' &&
hydratedTokenInSession &&
(!storedTokenBeforeClear || hydratedTokenInSession === storedTokenBeforeClear)
) {
delete process.env.GITHUB_TOKEN
}
delete process.env.CLAUDE_CODE_USE_GITHUB
delete process.env[GITHUB_MODELS_HYDRATED_ENV_MARKER]
delete process.env.OPENAI_MODEL
delete process.env.OPENAI_API_KEY
delete process.env.OPENAI_ORG
delete process.env.OPENAI_PROJECT
delete process.env.OPENAI_ORGANIZATION
delete process.env.OPENAI_BASE_URL
delete process.env.OPENAI_API_BASE
// Restore active provider profile immediately when one exists.
applyActiveProviderProfileFromConfig()
return null
}
function startCreateFromPreset(preset: ProviderPreset): void {
const defaults = getProviderPresetDefaults(preset)
const nextDraft = {
name: defaults.name,
baseUrl: defaults.baseUrl,
model: defaults.model,
apiKey: defaults.apiKey ?? '',
}
setEditingProfileId(null)
setDraftProvider(defaults.provider ?? 'openai')
setDraft(nextDraft)
setFormStepIndex(0)
setCursorOffset(nextDraft.name.length)
setErrorMessage(undefined)
if (preset === 'ollama') {
setOllamaSelection({ state: 'loading' })
setScreen('select-ollama-model')
return
}
setScreen('form')
}
function startEditProfile(profileId: string): void {
const existing = profiles.find(profile => profile.id === profileId)
if (!existing) {
return
}
const nextDraft = toDraft(existing)
setEditingProfileId(profileId)
setDraftProvider(existing.provider ?? 'openai')
setDraft(nextDraft)
setFormStepIndex(0)
setCursorOffset(nextDraft.name.length)
setErrorMessage(undefined)
setScreen('form')
}
function persistDraft(nextDraft: ProviderDraft = draft): void {
const payload: ProviderProfileInput = {
provider: draftProvider,
name: nextDraft.name,
baseUrl: nextDraft.baseUrl,
model: nextDraft.model,
apiKey: nextDraft.apiKey,
}
const saved = editingProfileId
? updateProviderProfile(editingProfileId, payload)
: addProviderProfile(payload, { makeActive: true })
if (!saved) {
setErrorMessage('Could not save provider. Fill all required fields.')
return
}
const isActiveSavedProfile = getActiveProviderProfile()?.id === saved.id
if (isActiveSavedProfile) {
setAppState(prev => ({
...prev,
mainLoopModel: saved.model,
mainLoopModelForSession: null,
}))
}
const settingsOverrideError = isActiveSavedProfile
? clearStartupProviderOverrideFromUserSettings()
: null
refreshProfiles()
const successMessage =
editingProfileId
? `Updated provider: ${saved.name}`
: `Added provider: ${saved.name} (now active)`
setStatusMessage(
settingsOverrideError
? `${successMessage}. Warning: could not clear startup provider override (${settingsOverrideError}).`
: successMessage,
)
if (mode === 'first-run') {
onDone({
action: 'saved',
activeProfileId: saved.id,
message: `Provider configured: ${saved.name}`,
})
return
}
setEditingProfileId(null)
setFormStepIndex(0)
setErrorMessage(undefined)
returnToMenu()
}
function renderOllamaSelection(): React.ReactNode {
if (ollamaSelection.state === 'loading' || ollamaSelection.state === 'idle') {
return (
<Box flexDirection="column" gap={1}>
<Text color="remember" bold>
Checking Ollama
</Text>
<Text dimColor>Looking for installed Ollama models...</Text>
</Box>
)
}
if (ollamaSelection.state === 'unavailable') {
return (
<Box flexDirection="column" gap={1}>
<Text color="remember" bold>
Ollama setup
</Text>
<Text dimColor>{ollamaSelection.message}</Text>
<Select
options={[
{
value: 'manual',
label: 'Enter manually',
description: 'Fill in the base URL and model yourself',
},
{
value: 'back',
label: 'Back',
description: 'Choose another provider preset',
},
]}
onChange={(value: string) => {
if (value === 'manual') {
setFormStepIndex(0)
setCursorOffset(draft.name.length)
setScreen('form')
return
}
setScreen('select-preset')
}}
onCancel={() => setScreen('select-preset')}
visibleOptionCount={2}
/>
</Box>
)
}
return (
<Box flexDirection="column" gap={1}>
<Text color="remember" bold>
Choose an Ollama model
</Text>
<Text dimColor>
Pick one of the installed Ollama models to save into a local provider
profile.
</Text>
<Select
options={ollamaSelection.options}
defaultValue={ollamaSelection.defaultValue}
defaultFocusValue={ollamaSelection.defaultValue}
inlineDescriptions
visibleOptionCount={Math.min(8, ollamaSelection.options.length)}
onChange={(value: string) => {
const nextDraft = {
...draft,
model: value,
}
setDraft(nextDraft)
persistDraft(nextDraft)
}}
onCancel={() => setScreen('select-preset')}
/>
</Box>
)
}
function handleFormSubmit(value: string): void {
const trimmed = value.trim()
if (!currentStep.optional && trimmed.length === 0) {
setErrorMessage(`${currentStep.label} is required.`)
return
}
const nextDraft = {
...draft,
[currentStepKey]: trimmed,
}
setDraft(nextDraft)
setErrorMessage(undefined)
if (formStepIndex < FORM_STEPS.length - 1) {
const nextIndex = formStepIndex + 1
const nextKey = FORM_STEPS[nextIndex]?.key ?? 'name'
setFormStepIndex(nextIndex)
setCursorOffset(nextDraft[nextKey].length)
return
}
persistDraft(nextDraft)
}
function handleBackFromForm(): void {
setErrorMessage(undefined)
if (formStepIndex > 0) {
const nextIndex = formStepIndex - 1
const nextKey = FORM_STEPS[nextIndex]?.key ?? 'name'
setFormStepIndex(nextIndex)
setCursorOffset(draft[nextKey].length)
return
}
if (mode === 'first-run') {
setScreen('select-preset')
return
}
returnToMenu()
}
useKeybinding('confirm:no', handleBackFromForm, {
context: 'Settings',
isActive: screen === 'form',
})
function renderPresetSelection(): React.ReactNode {
const canUseCodexOAuth = !isBareMode()
// Providers sorted alphabetically by label. `Custom` is pinned to the end
// because it's the catch-all / escape hatch — users scanning the list
// should always find known providers first. `Skip for now` (first-run
// only) comes last, after Custom.
const options = [
{
value: 'dashscope-intl',
label: 'Alibaba Coding Plan',
description: 'Alibaba DashScope International endpoint',
},
{
value: 'dashscope-cn',
label: 'Alibaba Coding Plan (China)',
description: 'Alibaba DashScope China endpoint',
},
{
value: 'anthropic',
label: 'Anthropic',
description: 'Native Claude API (x-api-key auth)',
},
{
value: 'azure-openai',
label: 'Azure OpenAI',
description: 'Azure OpenAI endpoint (model=deployment name)',
},
...(canUseCodexOAuth
? [
{
value: 'codex-oauth',
label: 'Codex OAuth',
description:
'Sign in with ChatGPT in your browser and store Codex credentials securely',
},
]
: []),
{
value: 'deepseek',
label: 'DeepSeek',
description: 'DeepSeek OpenAI-compatible endpoint',
},
{
value: 'gemini',
label: 'Google Gemini',
description: 'Gemini OpenAI-compatible endpoint',
},
{
value: 'groq',
label: 'Groq',
description: 'Groq OpenAI-compatible endpoint',
},
{
value: 'lmstudio',
label: 'LM Studio',
description: 'Local LM Studio endpoint',
},
{
value: 'minimax',
label: 'MiniMax',
description: 'MiniMax API endpoint',
},
{
value: 'mistral',
label: 'Mistral',
description: 'Mistral OpenAI-compatible endpoint',
},
{
value: 'moonshotai',
label: 'Moonshot AI',
description: 'Kimi OpenAI-compatible endpoint',
},
{
value: 'nvidia-nim',
label: 'NVIDIA NIM',
description: 'NVIDIA NIM endpoint',
},
{
value: 'ollama',
label: 'Ollama',
description: 'Local or remote Ollama endpoint',
},
{
value: 'openai',
label: 'OpenAI',
description: 'OpenAI API with API key',
},
{
value: 'openrouter',
label: 'OpenRouter',
description: 'OpenRouter OpenAI-compatible endpoint',
},
{
value: 'together',
label: 'Together AI',
description: 'Together chat/completions endpoint',
},
{
value: 'custom',
label: 'Custom',
description: 'Any OpenAI-compatible provider',
},
...(mode === 'first-run'
? [
{
value: 'skip',
label: 'Skip for now',
description: 'Continue with current defaults',
},
]
: []),
]
return (
<Box flexDirection="column" gap={1}>
<Text color="remember" bold>
{mode === 'first-run' ? 'Set up provider' : 'Choose provider preset'}
</Text>
<Text dimColor>
Pick a preset, then confirm base URL, model, and API key.
</Text>
<Select
options={options}
onChange={(value: string) => {
if (value === 'skip') {
closeWithCancelled('Provider setup skipped')
return
}
if (value === 'codex-oauth') {
setScreen('codex-oauth')
return
}
startCreateFromPreset(value as ProviderPreset)
}}
onCancel={() => {
if (mode === 'first-run') {
closeWithCancelled('Provider setup skipped')
return
}
returnToMenu()
}}
visibleOptionCount={Math.min(13, options.length)}
/>
</Box>
)
}
function renderForm(): React.ReactNode {
return (
<Box flexDirection="column" gap={1}>
<Text color="remember" bold>
{editingProfileId ? 'Edit provider profile' : 'Create provider profile'}
</Text>
<Text dimColor>{currentStep.helpText}</Text>
<Text dimColor>
Provider type:{' '}
{draftProvider === 'anthropic'
? 'Anthropic native API'
: 'OpenAI-compatible API'}
</Text>
<Text dimColor>
Step {formStepIndex + 1} of {FORM_STEPS.length}: {currentStep.label}
</Text>
<Box flexDirection="row" gap={1}>
<Text>{figures.pointer}</Text>
<TextInput
value={currentValue}
onChange={value =>
setDraft(prev => ({
...prev,
[currentStepKey]: value,
}))
}
onSubmit={handleFormSubmit}
focus={true}
showCursor={true}
placeholder={`${currentStep.placeholder}${figures.ellipsis}`}
mask={currentStepKey === 'apiKey' ? '*' : undefined}
columns={80}
cursorOffset={cursorOffset}
onChangeCursorOffset={setCursorOffset}
/>
</Box>
{errorMessage && <Text color="error">{errorMessage}</Text>}
<Text dimColor>
Press Enter to continue. Press Esc to go back.
</Text>
</Box>
)
}
function renderMenu(): React.ReactNode {
// Use memoized menuOptions from component scope
const hasProfiles = profiles.length > 0
const hasSelectableProviders = hasProfiles || githubProviderAvailable
return (
<Box flexDirection="column" gap={1}>
<Text color="remember" bold>
Provider manager
</Text>
<Text dimColor>
Active profile controls base URL, model, and API key used by this session.
</Text>
{statusMessage && <Text>{statusMessage}</Text>}
<Box flexDirection="column">
{profiles.length === 0 && !githubProviderAvailable ? (
isGithubCredentialSourceResolved ? (
<Text dimColor>No provider profiles configured yet.</Text>
) : (
<Text dimColor>Checking GitHub Models credentials...</Text>
)
) : (
<>
{profiles.map(profile => (
<Text key={profile.id} dimColor>
- {profile.name}: {profileSummary(profile, profile.id === activeProfileId)}
</Text>
))}
{githubProviderAvailable ? (
<Text dimColor>
- {GITHUB_PROVIDER_LABEL}:{' '}
{getGithubProviderSummary(
isGithubActive,
githubCredentialSource,
)}
</Text>
) : null}
</>
)}
</Box>
<Select
options={menuOptions}
onChange={(value: string) => {
setErrorMessage(undefined)
switch (value) {
case 'add':
setScreen('select-preset')
break
case 'activate':
if (hasSelectableProviders) {
setScreen('select-active')
}
break
case 'edit':
if (hasProfiles) {
setScreen('select-edit')
}
break
case 'delete':
if (hasSelectableProviders) {
setScreen('select-delete')
}
break
case 'logout-codex-oauth': {
const cleared = clearCodexCredentials()
if (!cleared.success) {
setErrorMessage(
cleared.warning ??
'Could not clear Codex OAuth credentials.',
)
break
}
setHasStoredCodexOAuthCredentials(false)
setStoredCodexOAuthProfileId(undefined)
const codexProfile = findCodexOAuthProfile(
getProviderProfiles(),
storedCodexOAuthProfileId,
)
let settingsOverrideError: string | null = null
if (codexProfile) {
const result = deleteProviderProfile(codexProfile.id)
if (!result.removed) {
setErrorMessage(
'Codex OAuth credentials were cleared, but the Codex profile could not be removed.',
)
refreshProfiles()
break
}
clearPersistedCodexOAuthProfile()
settingsOverrideError = result.activeProfileId
? clearStartupProviderOverrideFromUserSettings()
: null
}
refreshProfiles()
setStatusMessage(
settingsOverrideError
? `Codex OAuth logged out. Warning: could not clear startup provider override (${settingsOverrideError}).`
: 'Codex OAuth logged out.',
)
break
}
default:
closeWithCancelled('Provider manager closed')
break
}
}}
onCancel={() => closeWithCancelled('Provider manager closed')}
defaultFocusValue={menuFocusValue}
visibleOptionCount={menuOptions.length}
/>
</Box>
)
}
function renderProfileSelection(
title: string,
emptyMessage: string,
onSelect: (profileId: string) => void,
options?: { includeGithub?: boolean },
): React.ReactNode {
const includeGithub = options?.includeGithub ?? false
const selectOptions = profiles.map(profile => ({
value: profile.id,
label:
profile.id === activeProfileId
? `${profile.name} (active)`
: profile.name,
description: `${profile.provider === 'anthropic' ? 'anthropic' : 'openai-compatible'} · ${profile.baseUrl} · ${profile.model}`,
}))
if (includeGithub && githubProviderAvailable) {
selectOptions.push({
value: GITHUB_PROVIDER_ID,
label: isGithubActive
? `${GITHUB_PROVIDER_LABEL} (active)`
: GITHUB_PROVIDER_LABEL,
description: `github-models · ${GITHUB_PROVIDER_DEFAULT_BASE_URL} · ${getGithubProviderModel()}`,
})
}
if (selectOptions.length === 0) {
return (
<Box flexDirection="column" gap={1}>
<Text color="remember" bold>
{title}
</Text>
<Text dimColor>{emptyMessage}</Text>
<Select
options={[
{
value: 'back',
label: 'Back',
description: 'Return to provider manager',
},
]}
onChange={() => returnToMenu()}
onCancel={() => returnToMenu()}
visibleOptionCount={1}
/>
</Box>
)
}
return (
<Box flexDirection="column" gap={1}>
<Text color="remember" bold>
{title}
</Text>
<Select
options={selectOptions}
onChange={onSelect}
onCancel={() => returnToMenu()}
visibleOptionCount={Math.min(10, Math.max(2, selectOptions.length))}
/>
</Box>
)
}
let content: React.ReactNode
switch (screen) {
case 'select-preset':
content = renderPresetSelection()
break
case 'select-ollama-model':
content = renderOllamaSelection()
break
case 'codex-oauth':
content = (
<CodexOAuthSetup
onBack={() => setScreen('select-preset')}
onConfigured={async (tokens, persistCredentials) => {
const payload: ProviderProfileInput = {
provider: 'openai',
name: CODEX_OAUTH_PROVIDER_NAME,
baseUrl: DEFAULT_CODEX_BASE_URL,
model: CODEX_OAUTH_PROVIDER_MODEL,
apiKey: '',
}
const existing = findCodexOAuthProfile(
getProviderProfiles(),
storedCodexOAuthProfileId,
)
const saved = existing
? updateProviderProfile(existing.id, payload)
: addProviderProfile(payload, { makeActive: true })
if (!saved) {
setErrorMessage(
'Codex OAuth login finished, but the provider profile could not be saved.',
)
returnToMenu()
return
}
const active =
existing && activeProfileId !== saved.id
? setActiveProviderProfile(saved.id)
: saved
if (!active) {
setErrorMessage(
'Codex OAuth login finished, but the provider could not be set as the startup provider.',
)
returnToMenu()
return
}
persistCredentials({ profileId: saved.id })
const settingsOverrideError =
clearStartupProviderOverrideFromUserSettings()
const activationWarning = await activateCodexOAuthSession(tokens)
setHasStoredCodexOAuthCredentials(true)
setStoredCodexOAuthProfileId(saved.id)
refreshProfiles()
const warnings = [
activationWarning,
settingsOverrideError
? `could not clear startup provider override (${settingsOverrideError})`
: null,
].filter((warning): warning is string => Boolean(warning))
const message = buildCodexOAuthActivationMessage({
prefix: 'Codex OAuth configured',
activationWarning,
warnings,
})
if (mode === 'first-run') {
onDone({
action: 'saved',
activeProfileId: active.id,
message,
})
return
}
setStatusMessage(message)
setErrorMessage(undefined)
returnToMenu()
}}
/>
)
break
case 'form':
content = renderForm()
break
case 'select-active':
content = renderProfileSelection(
'Set active provider',
'No providers available. Add one first.',
profileId => {
void activateSelectedProvider(profileId)
},
{ includeGithub: true },
)
break
case 'select-edit':
content = renderProfileSelection(
'Edit provider',
'No providers available. Add one first.',
profileId => {
startEditProfile(profileId)
},
)
break
case 'select-delete':
content = renderProfileSelection(
'Delete provider',
'No providers available. Add one first.',
profileId => {
if (profileId === GITHUB_PROVIDER_ID) {
const githubDeleteError = deleteGithubProvider()
if (githubDeleteError) {
setErrorMessage(`Could not delete GitHub provider: ${githubDeleteError}`)
} else {
refreshProfiles()
setStatusMessage('GitHub provider deleted')
}
returnToMenu()
return
}
const deletedCodexOAuthProfile =
findCodexOAuthProfile(
profiles,
storedCodexOAuthProfileId,
)?.id === profileId
const result = deleteProviderProfile(profileId)
if (!result.removed) {
setErrorMessage('Could not delete provider.')
} else {
if (deletedCodexOAuthProfile) {
const cleared = clearCodexCredentials()
if (!cleared.success) {
setErrorMessage(
cleared.warning ??
'Provider deleted, but Codex OAuth credentials could not be cleared.',
)
} else {
setStoredCodexOAuthProfileId(undefined)
}
clearPersistedCodexOAuthProfile()
}
const settingsOverrideError = result.activeProfileId
? clearStartupProviderOverrideFromUserSettings()
: null
refreshProfiles()
setStatusMessage(
settingsOverrideError
? `Provider deleted. Warning: could not clear startup provider override (${settingsOverrideError}).`
: 'Provider deleted',
)
}
returnToMenu()
},
{ includeGithub: true },
)
break
case 'menu':
default:
content = renderMenu()
break
}
return (
<Pane color="permission">
{isInitializing ? (
<Box flexDirection="column" gap={1}>
<Text color="remember" bold>Loading providers...</Text>
<Text dimColor>Reading provider profiles from disk.</Text>
</Box>
) : isActivating ? (
<Box flexDirection="column" gap={1}>
<Text color="remember" bold>Activating provider...</Text>
<Text dimColor>Please wait while the provider is being configured.</Text>
</Box>
) : (
content
)}
</Pane>
)
}