Files
codeg/src/components/settings/acp-agent-settings.tsx
T
2026-03-19 21:33:07 +08:00

6180 lines
222 KiB
TypeScript

"use client"
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type PointerEvent,
type ReactNode,
} from "react"
import { Reorder, useDragControls } from "motion/react"
import { useLocale, useTranslations } from "next-intl"
import { useSearchParams } from "next/navigation"
import {
AlertCircle,
CheckCircle2,
ChevronDown,
ChevronRight,
Download,
Eye,
EyeOff,
GripVertical,
Loader2,
Minus,
RefreshCw,
Save,
Trash2,
Wrench,
} from "lucide-react"
import { openUrl } from "@tauri-apps/plugin-opener"
import { toast } from "sonner"
import { AgentIcon } from "@/components/agent-icon"
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Collapsible, CollapsibleContent } from "@/components/ui/collapsible"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import { Textarea } from "@/components/ui/textarea"
import { cn } from "@/lib/utils"
import {
acpClearBinaryCache,
acpDetectAgentLocalVersion,
acpDownloadAgentBinary,
acpListAgents,
acpPreflight,
acpPrepareNpxAgent,
acpReorderAgents,
acpUninstallAgent,
acpUpdateAgentPreferences,
} from "@/lib/tauri"
import type {
AcpAgentInfo,
AgentType,
CheckStatus,
FixAction,
PreflightResult,
} from "@/lib/types"
interface AgentCheckState {
result?: PreflightResult
error?: string
}
interface AgentDraft {
enabled: boolean
envText: string
configText: string
apiBaseUrl: string
apiKey: string
model: string
geminiAuthMode: GeminiAuthMode
geminiApiKey: string
googleApiKey: string
googleCloudProject: string
googleCloudLocation: string
googleApplicationCredentials: string
codexAuthMode: CodexAuthMode
codexModelProvider: string
codexProviderOptions: string[]
codexReasoningEffort: CodexReasoningEffort
codexSupportsWebsockets: boolean
claudeMainModel: string
claudeReasoningModel: string
claudeDefaultHaikuModel: string
claudeDefaultSonnetModel: string
claudeDefaultOpusModel: string
codexAuthJsonText: string
codexConfigTomlText: string
openCodeAuthJsonText: string
openClawGatewayUrl: string
openClawGatewayToken: string
openClawSessionKey: string
}
type RunningActionKind =
| "download_binary"
| "upgrade_binary"
| "install_npx"
| "upgrade_npx"
| "uninstall_binary"
| "uninstall_npx"
| "redownload_binary"
type UiFixAction =
| FixAction
| {
label: string
kind:
| "download_binary"
| "upgrade_binary"
| "install_npx"
| "upgrade_npx"
| "uninstall_binary"
| "uninstall_npx"
payload: string
}
interface UiCheckItem {
check_id: string
label: string
status: CheckStatus
message: string
fixes: UiFixAction[]
}
type AcpTranslator = (
key: string,
values?: Record<string, string | number>
) => string
let acpTranslator: AcpTranslator | null = null
function acpText(
key: string,
fallback: string,
values?: Record<string, string | number>
): string {
if (!acpTranslator) return fallback
return acpTranslator(key, values)
}
function statusTone(status: CheckStatus): string {
if (status === "pass") return "text-green-500"
if (status === "warn") return "text-yellow-500"
return "text-red-500"
}
function summarizeChecks(checks: UiCheckItem[]): CheckStatus | "unchecked" {
if (checks.length === 0) return "unchecked"
if (checks.some((check) => check.status === "fail")) return "fail"
if (checks.some((check) => check.status === "warn")) return "warn"
return "pass"
}
function envMapToText(env: Record<string, string>): string {
return Object.entries(env)
.map(([key, value]) => `${key}=${value}`)
.join("\n")
}
function parseEnvText(envText: string): Record<string, string> {
const map: Record<string, string> = {}
for (const rawLine of envText.split(/\r?\n/)) {
const line = rawLine.trim()
if (!line || line.startsWith("#")) continue
const idx = line.indexOf("=")
if (idx <= 0) continue
const key = line.slice(0, idx).trim()
const value = line.slice(idx + 1).trim()
if (!key) continue
map[key] = value
}
return map
}
function patchEnvText(
envText: string,
patch: Record<string, string | undefined>
): string {
const envMap = parseEnvText(envText)
for (const [key, value] of Object.entries(patch)) {
const trimmed = value?.trim() ?? ""
if (!trimmed) {
delete envMap[key]
} else {
envMap[key] = trimmed
}
}
return envMapToText(envMap)
}
interface ImportantEnvKeys {
apiBaseUrl: string[]
apiKey: string[]
model: string[]
}
const CLAUDE_MODEL_ENV_KEYS = {
claudeMainModel: "ANTHROPIC_MODEL",
claudeReasoningModel: "ANTHROPIC_REASONING_MODEL",
claudeDefaultHaikuModel: "ANTHROPIC_DEFAULT_HAIKU_MODEL",
claudeDefaultSonnetModel: "ANTHROPIC_DEFAULT_SONNET_MODEL",
claudeDefaultOpusModel: "ANTHROPIC_DEFAULT_OPUS_MODEL",
} as const
const GEMINI_AUTH_MODES = [
"custom",
"login_google",
"gemini_api_key",
"vertex_adc",
"vertex_service_account",
"vertex_api_key",
] as const
type GeminiAuthMode = (typeof GEMINI_AUTH_MODES)[number]
const GEMINI_ENV_KEYS = {
baseUrl: "GOOGLE_GEMINI_BASE_URL",
legacyBaseUrl: "GEMINI_BASE_URL",
geminiApiKey: "GEMINI_API_KEY",
legacyGeminiApiKey: "GOOGLE_GEMINI_API_KEY",
googleApiKey: "GOOGLE_API_KEY",
cloudProject: "GOOGLE_CLOUD_PROJECT",
cloudProjectLegacy: "GOOGLE_CLOUD_PROJECT_ID",
cloudLocation: "GOOGLE_CLOUD_LOCATION",
applicationCredentials: "GOOGLE_APPLICATION_CREDENTIALS",
model: "GEMINI_MODEL",
} as const
const OPENCLAW_ENV_KEYS = {
gatewayUrl: "OPENCLAW_GATEWAY_URL",
gatewayToken: "OPENCLAW_GATEWAY_TOKEN",
sessionKey: "OPENCLAW_SESSION_KEY",
} as const
type ClaudeModelKey = keyof typeof CLAUDE_MODEL_ENV_KEYS
type ImportantConfigKey = "apiBaseUrl" | "apiKey" | "model" | ClaudeModelKey
type ImportantDraftPatch = Partial<Pick<AgentDraft, ImportantConfigKey>>
interface ConfigParseResult {
config: Record<string, unknown>
error: string | null
}
function importantEnvKeysByAgent(agentType: AgentType): ImportantEnvKeys {
if (agentType === "claude_code") {
return {
apiBaseUrl: ["ANTHROPIC_BASE_URL", "OPENAI_BASE_URL", "API_BASE_URL"],
apiKey: ["ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY", "OPENAI_API_KEY"],
model: ["ANTHROPIC_MODEL", "OPENAI_MODEL", "MODEL"],
}
}
if (agentType === "gemini") {
return {
apiBaseUrl: ["GOOGLE_GEMINI_BASE_URL", "GEMINI_BASE_URL", "API_BASE_URL"],
apiKey: [
GEMINI_ENV_KEYS.geminiApiKey,
GEMINI_ENV_KEYS.googleApiKey,
GEMINI_ENV_KEYS.legacyGeminiApiKey,
"API_KEY",
],
model: ["GEMINI_MODEL", "MODEL"],
}
}
return {
apiBaseUrl: ["OPENAI_BASE_URL", "API_BASE_URL"],
apiKey: ["OPENAI_API_KEY", "API_KEY"],
model: ["OPENAI_MODEL", "MODEL"],
}
}
function parseConfigJsonText(configText: string): ConfigParseResult {
const trimmed = configText.trim()
if (!trimmed) return { config: {}, error: null }
try {
const parsed = JSON.parse(trimmed) as unknown
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return {
config: {},
error: acpText(
"errors.nativeJsonMustBeObject",
"Native JSON config must be an object"
),
}
}
return { config: parsed as Record<string, unknown>, error: null }
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
return {
config: {},
error: acpText(
"errors.nativeJsonInvalid",
"Native JSON config format error: {message}",
{ message }
),
}
}
}
function asObjectRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null
return value as Record<string, unknown>
}
function parseOpenCodeAuthJsonText(authJsonText: string): {
authObject: Record<string, unknown> | null
error: string | null
} {
const trimmed = authJsonText.trim()
if (!trimmed) return { authObject: {}, error: null }
try {
const parsed = JSON.parse(trimmed) as unknown
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return {
authObject: null,
error: acpText(
"errors.openCodeAuthMustBeObject",
"OpenCode auth.json must be a JSON object"
),
}
}
return { authObject: parsed as Record<string, unknown>, error: null }
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
return {
authObject: null,
error: acpText(
"errors.openCodeAuthInvalid",
"OpenCode auth.json format error: {message}",
{ message }
),
}
}
}
function patchOpenCodeAuthJsonText(
authJsonText: string,
mutator: (authObject: Record<string, unknown>) => void
): { authJsonText: string; recoveredFromInvalid: boolean } {
const parsed = parseOpenCodeAuthJsonText(authJsonText)
const authObject = parsed.error
? {}
: (JSON.parse(JSON.stringify(parsed.authObject ?? {})) as Record<
string,
unknown
>)
mutator(authObject)
return {
authJsonText:
Object.keys(authObject).length === 0
? ""
: JSON.stringify(authObject, null, 2),
recoveredFromInvalid: Boolean(parsed.error),
}
}
function envFromConfig(
config: Record<string, unknown>
): Record<string, string> {
const raw = config.env
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
return {}
}
const map: Record<string, string> = {}
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
if (typeof value !== "string") continue
const trimmedKey = key.trim()
const trimmedValue = value.trim()
if (!trimmedKey || !trimmedValue) continue
map[trimmedKey] = trimmedValue
}
return map
}
function pickFirstString(
source: Record<string, unknown>,
keys: string[]
): string | null {
for (const key of keys) {
const value = source[key]
if (typeof value !== "string") continue
const trimmed = value.trim()
if (trimmed) return trimmed
}
return null
}
function findEnvValue(env: Record<string, string>, keys: string[]): string {
for (const key of keys) {
const value = env[key]
if (!value) continue
const trimmed = value.trim()
if (trimmed) return trimmed
}
return ""
}
function extractImportantConfigValues(
agentType: AgentType,
env: Record<string, string>,
configText: string
): {
apiBaseUrl: string
apiKey: string
model: string
claudeMainModel: string
claudeReasoningModel: string
claudeDefaultHaikuModel: string
claudeDefaultSonnetModel: string
claudeDefaultOpusModel: string
configError: string | null
} {
const parseResult = parseConfigJsonText(configText)
const config = parseResult.config
const keys = importantEnvKeysByAgent(agentType)
const configEnv = envFromConfig(config)
const mergedEnv = { ...env, ...configEnv }
const apiBaseUrl =
pickFirstString(config, ["apiBaseUrl", "api_base_url"]) ??
findEnvValue(mergedEnv, keys.apiBaseUrl)
const apiKey =
pickFirstString(config, ["apiKey", "api_key"]) ??
findEnvValue(mergedEnv, keys.apiKey)
const model =
pickFirstString(config, ["model", "model_name"]) ??
findEnvValue(mergedEnv, keys.model)
const claudeMainModel = findEnvValue(mergedEnv, [
CLAUDE_MODEL_ENV_KEYS.claudeMainModel,
])
const claudeReasoningModel = findEnvValue(mergedEnv, [
CLAUDE_MODEL_ENV_KEYS.claudeReasoningModel,
])
const claudeDefaultHaikuModel = findEnvValue(mergedEnv, [
CLAUDE_MODEL_ENV_KEYS.claudeDefaultHaikuModel,
])
const claudeDefaultSonnetModel = findEnvValue(mergedEnv, [
CLAUDE_MODEL_ENV_KEYS.claudeDefaultSonnetModel,
])
const claudeDefaultOpusModel = findEnvValue(mergedEnv, [
CLAUDE_MODEL_ENV_KEYS.claudeDefaultOpusModel,
])
return {
apiBaseUrl: apiBaseUrl ?? "",
apiKey: apiKey ?? "",
model: model ?? "",
claudeMainModel: agentType === "claude_code" ? (claudeMainModel ?? "") : "",
claudeReasoningModel:
agentType === "claude_code" ? claudeReasoningModel : "",
claudeDefaultHaikuModel:
agentType === "claude_code" ? claudeDefaultHaikuModel : "",
claudeDefaultSonnetModel:
agentType === "claude_code" ? claudeDefaultSonnetModel : "",
claudeDefaultOpusModel:
agentType === "claude_code" ? claudeDefaultOpusModel : "",
configError: parseResult.error,
}
}
interface GeminiImportantValues {
authMode: GeminiAuthMode
apiBaseUrl: string
geminiApiKey: string
googleApiKey: string
googleCloudProject: string
googleCloudLocation: string
googleApplicationCredentials: string
model: string
}
function inferGeminiAuthMode(values: {
apiBaseUrl: string
geminiApiKey: string
googleApiKey: string
googleCloudProject: string
googleCloudLocation: string
googleApplicationCredentials: string
}): GeminiAuthMode {
if (values.apiBaseUrl.trim()) return "custom"
if (values.geminiApiKey.trim()) return "gemini_api_key"
if (values.googleApiKey.trim()) return "vertex_api_key"
if (values.googleApplicationCredentials.trim())
return "vertex_service_account"
if (values.googleCloudProject.trim() || values.googleCloudLocation.trim()) {
return "vertex_adc"
}
return "login_google"
}
function extractGeminiImportantValues(
env: Record<string, string>,
configText: string
): GeminiImportantValues {
const parseResult = parseConfigJsonText(configText)
const config = parseResult.config
const configEnv = envFromConfig(config)
const mergedEnv = { ...env, ...configEnv }
const apiBaseUrl = findEnvValue(mergedEnv, [
GEMINI_ENV_KEYS.baseUrl,
GEMINI_ENV_KEYS.legacyBaseUrl,
"API_BASE_URL",
])
const geminiApiKey = findEnvValue(mergedEnv, [
GEMINI_ENV_KEYS.geminiApiKey,
GEMINI_ENV_KEYS.legacyGeminiApiKey,
])
const googleApiKey = findEnvValue(mergedEnv, [GEMINI_ENV_KEYS.googleApiKey])
const googleCloudProject = findEnvValue(mergedEnv, [
GEMINI_ENV_KEYS.cloudProject,
GEMINI_ENV_KEYS.cloudProjectLegacy,
])
const googleCloudLocation = findEnvValue(mergedEnv, [
GEMINI_ENV_KEYS.cloudLocation,
])
const googleApplicationCredentials = findEnvValue(mergedEnv, [
GEMINI_ENV_KEYS.applicationCredentials,
])
const model = findEnvValue(mergedEnv, [GEMINI_ENV_KEYS.model, "MODEL"])
return {
authMode: inferGeminiAuthMode({
apiBaseUrl,
geminiApiKey,
googleApiKey,
googleCloudProject,
googleCloudLocation,
googleApplicationCredentials,
}),
apiBaseUrl,
geminiApiKey,
googleApiKey,
googleCloudProject,
googleCloudLocation,
googleApplicationCredentials,
model: model ?? "",
}
}
interface OpenClawImportantValues {
gatewayUrl: string
gatewayToken: string
sessionKey: string
}
function extractOpenClawImportantValues(
env: Record<string, string>,
configText: string
): OpenClawImportantValues {
const parseResult = parseConfigJsonText(configText)
const config = parseResult.config
const configEnv = envFromConfig(config)
const mergedEnv = { ...env, ...configEnv }
return {
gatewayUrl: findEnvValue(mergedEnv, [OPENCLAW_ENV_KEYS.gatewayUrl]),
gatewayToken: findEnvValue(mergedEnv, [OPENCLAW_ENV_KEYS.gatewayToken]),
sessionKey: findEnvValue(mergedEnv, [OPENCLAW_ENV_KEYS.sessionKey]),
}
}
function patchGeminiConfigText(
configText: string,
patch: {
apiBaseUrl?: string
model?: string
geminiApiKey?: string
googleApiKey?: string
googleCloudProject?: string
googleCloudLocation?: string
googleApplicationCredentials?: string
}
): {
configText: string
recoveredFromInvalid: boolean
} {
const parseResult = parseConfigJsonText(configText)
const config = parseResult.error ? {} : { ...parseResult.config }
const env =
typeof config.env === "object" && config.env && !Array.isArray(config.env)
? { ...(config.env as Record<string, unknown>) }
: {}
const assignOrRemoveEnv = (key: string, value: string | undefined) => {
if (typeof value !== "string") return
const trimmed = value.trim()
if (!trimmed) {
delete env[key]
return
}
env[key] = trimmed
}
if (typeof patch.model === "string") {
delete config.model
delete config.model_name
}
assignOrRemoveEnv(GEMINI_ENV_KEYS.baseUrl, patch.apiBaseUrl)
if (typeof patch.apiBaseUrl === "string") {
assignOrRemoveEnv(GEMINI_ENV_KEYS.legacyBaseUrl, "")
}
assignOrRemoveEnv(GEMINI_ENV_KEYS.geminiApiKey, patch.geminiApiKey)
assignOrRemoveEnv(GEMINI_ENV_KEYS.googleApiKey, patch.googleApiKey)
if (typeof patch.geminiApiKey === "string") {
assignOrRemoveEnv(GEMINI_ENV_KEYS.legacyGeminiApiKey, "")
}
if (typeof patch.googleCloudProject === "string") {
const project = patch.googleCloudProject.trim()
if (!project) {
delete env[GEMINI_ENV_KEYS.cloudProject]
delete env[GEMINI_ENV_KEYS.cloudProjectLegacy]
} else {
env[GEMINI_ENV_KEYS.cloudProject] = project
delete env[GEMINI_ENV_KEYS.cloudProjectLegacy]
}
}
assignOrRemoveEnv(GEMINI_ENV_KEYS.cloudLocation, patch.googleCloudLocation)
assignOrRemoveEnv(
GEMINI_ENV_KEYS.applicationCredentials,
patch.googleApplicationCredentials
)
if (Object.keys(env).length === 0) {
delete config.env
} else {
config.env = env
}
return {
configText:
Object.keys(config).length === 0 ? "" : JSON.stringify(config, null, 2),
recoveredFromInvalid: Boolean(parseResult.error),
}
}
function patchGeminiEnvText(
envText: string,
patch: {
apiBaseUrl?: string
geminiApiKey?: string
googleApiKey?: string
googleCloudProject?: string
googleCloudLocation?: string
googleApplicationCredentials?: string
model?: string
}
): string {
const envPatch: Record<string, string | undefined> = {}
if (typeof patch.apiBaseUrl === "string") {
envPatch[GEMINI_ENV_KEYS.baseUrl] = patch.apiBaseUrl
envPatch[GEMINI_ENV_KEYS.legacyBaseUrl] = ""
}
if (typeof patch.geminiApiKey === "string") {
envPatch[GEMINI_ENV_KEYS.geminiApiKey] = patch.geminiApiKey
envPatch[GEMINI_ENV_KEYS.legacyGeminiApiKey] = ""
}
if (typeof patch.googleApiKey === "string") {
envPatch[GEMINI_ENV_KEYS.googleApiKey] = patch.googleApiKey
}
if (typeof patch.googleCloudProject === "string") {
envPatch[GEMINI_ENV_KEYS.cloudProject] = patch.googleCloudProject
envPatch[GEMINI_ENV_KEYS.cloudProjectLegacy] = ""
}
if (typeof patch.googleCloudLocation === "string") {
envPatch[GEMINI_ENV_KEYS.cloudLocation] = patch.googleCloudLocation
}
if (typeof patch.googleApplicationCredentials === "string") {
envPatch[GEMINI_ENV_KEYS.applicationCredentials] =
patch.googleApplicationCredentials
}
if (typeof patch.model === "string") {
envPatch[GEMINI_ENV_KEYS.model] = patch.model
}
return patchEnvText(envText, envPatch)
}
function patchGeminiAuthMode(
current: GeminiImportantValues,
mode: GeminiAuthMode
) {
const next = {
...current,
authMode: mode,
}
if (mode === "login_google") {
next.apiBaseUrl = ""
next.geminiApiKey = ""
next.googleApiKey = ""
next.googleCloudProject = ""
next.googleCloudLocation = ""
next.googleApplicationCredentials = ""
return next
}
if (mode === "custom") {
next.googleApiKey = ""
next.googleCloudProject = ""
next.googleCloudLocation = ""
next.googleApplicationCredentials = ""
return next
}
if (mode === "gemini_api_key") {
next.apiBaseUrl = ""
next.googleApiKey = ""
next.googleCloudProject = ""
next.googleCloudLocation = ""
next.googleApplicationCredentials = ""
return next
}
if (mode === "vertex_api_key") {
next.apiBaseUrl = ""
next.geminiApiKey = ""
next.googleApplicationCredentials = ""
return next
}
if (mode === "vertex_service_account") {
next.apiBaseUrl = ""
next.geminiApiKey = ""
next.googleApiKey = ""
return next
}
next.apiBaseUrl = ""
next.geminiApiKey = ""
next.googleApiKey = ""
next.googleApplicationCredentials = ""
return next
}
function geminiAuthModeLabel(mode: GeminiAuthMode): string {
if (mode === "custom") return acpText("gemini.mode.custom", "Custom Endpoint")
if (mode === "login_google")
return acpText("gemini.mode.loginGoogle", "Google Login (OAuth)")
if (mode === "gemini_api_key") return "Gemini API Key"
if (mode === "vertex_adc") return "Vertex AI (ADC)"
if (mode === "vertex_service_account")
return acpText(
"gemini.mode.vertexServiceAccount",
"Vertex AI (Service Account)"
)
return "Vertex AI API Key"
}
function geminiAuthModeHint(mode: GeminiAuthMode): string {
if (mode === "custom") {
return acpText(
"gemini.hint.custom",
"Fill API URL, API Key and Model, mapped to GOOGLE_GEMINI_BASE_URL / GEMINI_API_KEY / GEMINI_MODEL."
)
}
if (mode === "login_google") {
return acpText(
"gemini.hint.loginGoogle",
"Run gemini in terminal and complete Google login first; API key is not required."
)
}
if (mode === "gemini_api_key") {
return acpText(
"gemini.hint.geminiApiKey",
"Fill GEMINI_API_KEY when using Gemini API."
)
}
if (mode === "vertex_adc") {
return acpText(
"gemini.hint.vertexAdc",
"Use gcloud ADC; GOOGLE_CLOUD_PROJECT and GOOGLE_CLOUD_LOCATION are recommended."
)
}
if (mode === "vertex_service_account") {
return acpText(
"gemini.hint.vertexServiceAccount",
"Set service account JSON path to GOOGLE_APPLICATION_CREDENTIALS."
)
}
return acpText(
"gemini.hint.vertexApiKey",
"Fill GOOGLE_API_KEY when using Vertex AI API key."
)
}
function normalizeConfigText(configText: string): string {
const parseResult = parseConfigJsonText(configText)
if (parseResult.error) return configText.trim()
if (Object.keys(parseResult.config).length === 0) return ""
return JSON.stringify(parseResult.config, null, 2)
}
interface OpenCodeProviderView {
id: string
name: string
api: string
npm: string
baseUrl: string
apiKey: string
modelCount: number
modelIds: string[]
models: Record<string, OpenCodeModelView>
}
interface OpenCodeModelView {
id: string
name: string
extraFieldCount: number
}
interface OpenCodeConfigView {
model: string
smallModel: string
enabledProviders: string[]
disabledProviders: string[]
providerIds: string[]
providers: Record<string, OpenCodeProviderView>
}
const OPENCODE_PROVIDER_NPM_OPTIONS = [
{
value: "@ai-sdk/openai-compatible",
label: "@ai-sdk/openai-compatible",
},
{
value: "@ai-sdk/cerebras",
label: "@ai-sdk/cerebras",
},
] as const
function buildOpenCodeNpmOptions(currentValue: string): string[] {
const next = new Set<string>(
OPENCODE_PROVIDER_NPM_OPTIONS.map((v) => v.value)
)
const current = currentValue.trim()
if (current) next.add(current)
return Array.from(next)
}
function extractOpenCodeConfigValues(
configText: string,
authJsonText: string
): OpenCodeConfigView {
const parseResult = parseConfigJsonText(configText)
const config = parseResult.error ? {} : parseResult.config
const authParsed = parseOpenCodeAuthJsonText(authJsonText)
const authObject = authParsed.authObject ?? {}
const providerRoot = asObjectRecord(config.provider) ?? {}
const providerIds = Object.keys(providerRoot)
const providers: Record<string, OpenCodeProviderView> = {}
const knownModelKeys = new Set(["id", "name"])
for (const providerId of providerIds) {
const rawProvider = asObjectRecord(providerRoot[providerId]) ?? {}
const options = asObjectRecord(rawProvider.options) ?? {}
const models = asObjectRecord(rawProvider.models) ?? {}
const modelIds = Object.keys(models)
const providerModels: Record<string, OpenCodeModelView> = {}
for (const modelId of modelIds) {
const rawModel = asObjectRecord(models[modelId]) ?? {}
providerModels[modelId] = {
// OpenCode uses `provider.models.<model_id>` as the true model id.
id: modelId,
name:
pickFirstString(rawModel, ["name"]) ??
pickFirstString(rawModel, ["id"]) ??
"",
extraFieldCount: Object.keys(rawModel).filter(
(key) => !knownModelKeys.has(key)
).length,
}
}
const authEntry = asObjectRecord(authObject[providerId]) ?? {}
const authKey = pickFirstString(authEntry, ["key"]) ?? ""
providers[providerId] = {
id: providerId,
name: pickFirstString(rawProvider, ["name"]) ?? "",
api: pickFirstString(rawProvider, ["api"]) ?? "",
npm: pickFirstString(rawProvider, ["npm"]) ?? "",
baseUrl: pickFirstString(options, ["baseURL", "baseUrl"]) ?? "",
apiKey: pickFirstString(options, ["apiKey", "api_key"]) ?? authKey,
modelCount: modelIds.length,
modelIds,
models: providerModels,
}
}
return {
model: pickFirstString(config, ["model"]) ?? "",
smallModel:
pickFirstString(config, ["small_model", "smallModel", "small-model"]) ??
"",
enabledProviders: Array.isArray(config.enabled_providers)
? config.enabled_providers
.filter((item): item is string => typeof item === "string")
.map((item) => item.trim())
.filter(Boolean)
: [],
disabledProviders: Array.isArray(config.disabled_providers)
? config.disabled_providers
.filter((item): item is string => typeof item === "string")
.map((item) => item.trim())
.filter(Boolean)
: [],
providerIds,
providers,
}
}
function patchOpenCodeConfigText(
configText: string,
mutator: (config: Record<string, unknown>) => void
): {
configText: string
recoveredFromInvalid: boolean
} {
const parseResult = parseConfigJsonText(configText)
const config = parseResult.error
? {}
: (JSON.parse(JSON.stringify(parseResult.config)) as Record<
string,
unknown
>)
mutator(config)
return {
configText:
Object.keys(config).length === 0 ? "" : JSON.stringify(config, null, 2),
recoveredFromInvalid: Boolean(parseResult.error),
}
}
interface CodexTomlImportantValues {
model: string
modelProvider: string
modelReasoningEffort: CodexReasoningEffort
providerNames: string[]
providerBaseUrls: Record<string, string>
providerSupportsWebsockets: Record<string, boolean>
featureResponsesWebsocketsV2: boolean
}
interface CodexImportantValues {
apiBaseUrl: string
apiKey: string | null
model: string
modelProvider: string
reasoningEffort: CodexReasoningEffort
providerOptions: string[]
supportsWebsockets: boolean
}
const CODEX_DEFAULT_MODEL_PROVIDER = "codeg"
const CODEX_AUTH_MODES = ["api_key", "chatgpt_subscription"] as const
type CodexAuthMode = (typeof CODEX_AUTH_MODES)[number]
type CodexReasoningEffort = "low" | "medium" | "high" | "xhigh"
const CODEX_REASONING_EFFORT_OPTIONS: ReadonlyArray<{
value: CodexReasoningEffort
label: string
description: string
}> = [
{
value: "low",
label: "Low",
description: "Fast responses with lighter reasoning",
},
{
value: "medium",
label: "Medium",
description: "Balances speed and reasoning depth for everyday tasks",
},
{
value: "high",
label: "High",
description: "Greater reasoning depth for complex problems",
},
{
value: "xhigh",
label: "Extra High",
description: "Extra high reasoning depth for complex problems",
},
]
const CODEX_DEFAULT_REASONING_EFFORT: CodexReasoningEffort = "high"
function normalizeCodexReasoningEffort(
value: string
): CodexReasoningEffort | null {
const normalized = value.trim().toLowerCase()
if (
normalized === "low" ||
normalized === "medium" ||
normalized === "high" ||
normalized === "xhigh"
) {
return normalized
}
return null
}
function buildCodexProviderOptions(
activeProvider: string,
providerNames: string[]
): string[] {
const result: string[] = []
const seen = new Set<string>()
for (const raw of [
activeProvider,
...providerNames,
CODEX_DEFAULT_MODEL_PROVIDER,
]) {
const provider = raw.trim()
if (!provider || seen.has(provider)) continue
seen.add(provider)
result.push(provider)
}
return result
}
function parseTomlStringLiteral(raw: string): string | null {
const text = raw.trim()
if (!text) return null
if (text.startsWith('"')) {
let escaped = false
for (let i = 1; i < text.length; i += 1) {
const ch = text[i]
if (escaped) {
escaped = false
continue
}
if (ch === "\\") {
escaped = true
continue
}
if (ch === '"') {
const literal = text.slice(0, i + 1)
try {
return JSON.parse(literal) as string
} catch {
return literal.slice(1, -1)
}
}
}
return null
}
if (text.startsWith("'")) {
const end = text.indexOf("'", 1)
if (end <= 0) return null
return text.slice(1, end)
}
return null
}
function parseTomlStringAssignment(
rawLine: string
): { key: string; value: string } | null {
const key = parseTomlAssignmentKey(rawLine)
if (!key) return null
const line = rawLine.trim()
const equalsIndex = line.indexOf("=")
const valueText = line.slice(equalsIndex + 1)
const value = parseTomlStringLiteral(valueText)
if (value === null) return null
return { key, value: value.trim() }
}
function parseTomlAssignmentKey(rawLine: string): string | null {
const line = rawLine.trim()
if (!line || line.startsWith("#")) return null
const equalsIndex = line.indexOf("=")
if (equalsIndex <= 0) return null
const key = line.slice(0, equalsIndex).trim()
if (!/^[A-Za-z0-9_.-]+$/.test(key)) return null
return key
}
function parseTomlBooleanAssignment(
rawLine: string
): { key: string; value: boolean } | null {
const key = parseTomlAssignmentKey(rawLine)
if (!key) return null
const line = rawLine.trim()
const equalsIndex = line.indexOf("=")
const valueText = line.slice(equalsIndex + 1).trim()
const boolMatch = valueText.match(/^(true|false)(?:\s+#.*)?$/)
if (!boolMatch) return null
return { key, value: boolMatch[1] === "true" }
}
function extractCodexTomlImportantValues(
configTomlText: string
): CodexTomlImportantValues {
const providerBaseUrls: Record<string, string> = {}
const providerSupportsWebsockets: Record<string, boolean> = {}
const providerNames = new Set<string>()
let model = ""
let modelProvider = ""
let modelReasoningEffort: CodexReasoningEffort =
CODEX_DEFAULT_REASONING_EFFORT
let featureResponsesWebsocketsV2 = false
let currentProviderSection: string | null = null
let inFeaturesSection = false
for (const rawLine of configTomlText.split(/\r?\n/)) {
const line = rawLine.trim()
if (!line || line.startsWith("#")) continue
const sectionMatch = line.match(
/^\[\s*model_providers\.([A-Za-z0-9_-]+)\s*\]$/
)
if (sectionMatch) {
currentProviderSection = sectionMatch[1]
inFeaturesSection = false
if (currentProviderSection.trim()) {
providerNames.add(currentProviderSection.trim())
}
continue
}
if (line.match(/^\[\s*features\s*\]$/)) {
inFeaturesSection = true
currentProviderSection = null
continue
}
if (line.startsWith("[") && line.endsWith("]")) {
currentProviderSection = null
inFeaturesSection = false
continue
}
const assignment = parseTomlStringAssignment(rawLine)
if (assignment) {
if (assignment.key === "model") {
model = assignment.value
continue
}
if (assignment.key === "model_provider") {
modelProvider = assignment.value
continue
}
if (assignment.key === "model_reasoning_effort") {
modelReasoningEffort =
normalizeCodexReasoningEffort(assignment.value) ??
CODEX_DEFAULT_REASONING_EFFORT
continue
}
}
const boolAssignment = parseTomlBooleanAssignment(rawLine)
if (boolAssignment) {
if (
currentProviderSection &&
boolAssignment.key === "supports_websockets"
) {
providerSupportsWebsockets[currentProviderSection] =
boolAssignment.value
providerNames.add(currentProviderSection.trim())
continue
}
if (
inFeaturesSection &&
boolAssignment.key === "responses_websockets_v2"
) {
featureResponsesWebsocketsV2 = boolAssignment.value
continue
}
const dottedProviderWebsocketMatch = boolAssignment.key.match(
/^model_providers\.([A-Za-z0-9_-]+)\.supports_websockets$/
)
if (dottedProviderWebsocketMatch && dottedProviderWebsocketMatch[1]) {
const providerName = dottedProviderWebsocketMatch[1].trim()
providerNames.add(providerName)
providerSupportsWebsockets[providerName] = boolAssignment.value
continue
}
if (boolAssignment.key === "features.responses_websockets_v2") {
featureResponsesWebsocketsV2 = boolAssignment.value
continue
}
}
if (!assignment) continue
const rawAssignmentKey = parseTomlAssignmentKey(rawLine)
const dottedProviderMatch = rawAssignmentKey?.match(
/^model_providers\.([A-Za-z0-9_-]+)\./
)
if (dottedProviderMatch && dottedProviderMatch[1]) {
providerNames.add(dottedProviderMatch[1].trim())
}
if (
currentProviderSection &&
assignment.key === "base_url" &&
assignment.value
) {
providerBaseUrls[currentProviderSection] = assignment.value
providerNames.add(currentProviderSection.trim())
continue
}
const dottedMatch = assignment.key.match(
/^model_providers\.([A-Za-z0-9_-]+)\.base_url$/
)
if (dottedMatch && assignment.value) {
providerBaseUrls[dottedMatch[1]] = assignment.value
providerNames.add(dottedMatch[1].trim())
}
}
if (modelProvider.trim()) {
providerNames.add(modelProvider.trim())
}
providerNames.add(CODEX_DEFAULT_MODEL_PROVIDER)
for (const providerName of Object.keys(providerBaseUrls)) {
if (providerName.trim()) {
providerNames.add(providerName.trim())
}
}
return {
model,
modelProvider,
modelReasoningEffort,
providerNames: Array.from(providerNames),
providerBaseUrls,
providerSupportsWebsockets,
featureResponsesWebsocketsV2,
}
}
function parseCodexAuthJsonObject(authJsonText: string): {
authObject: Record<string, unknown> | null
error: string | null
} {
const trimmed = authJsonText.trim()
if (!trimmed) return { authObject: {}, error: null }
try {
const parsed = JSON.parse(trimmed) as unknown
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return {
authObject: null,
error: acpText(
"errors.authMustBeObject",
"auth.json must be a JSON object"
),
}
}
return { authObject: parsed as Record<string, unknown>, error: null }
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
return {
authObject: null,
error: acpText(
"errors.authInvalid",
"auth.json format error: {message}",
{
message,
}
),
}
}
}
function parseCodexAuthJsonText(authJsonText: string): string | null {
return parseCodexAuthJsonObject(authJsonText).error
}
function inferCodexAuthMode(authJsonText: string): CodexAuthMode {
const { authObject } = parseCodexAuthJsonObject(authJsonText)
if (authObject) {
// 官网订阅:auth_mode 为 chatgpt,或没有 OPENAI_API_KEY,或值为 null
if (
authObject.auth_mode === "chatgpt" ||
!("OPENAI_API_KEY" in authObject) ||
authObject.OPENAI_API_KEY === null
) {
return "chatgpt_subscription"
}
}
return "api_key"
}
function extractCodexImportantValues(
authJsonText: string,
configTomlText: string
): CodexImportantValues {
const parsedAuth = parseCodexAuthJsonObject(authJsonText)
const authObject = parsedAuth.authObject ?? {}
const toml = extractCodexTomlImportantValues(configTomlText)
const hasExplicitProvider = Boolean(toml.modelProvider.trim())
const activeProvider = hasExplicitProvider
? toml.modelProvider.trim()
: CODEX_DEFAULT_MODEL_PROVIDER
const providerBaseUrl = hasExplicitProvider
? (toml.providerBaseUrls[activeProvider] ?? "")
: (toml.providerBaseUrls[CODEX_DEFAULT_MODEL_PROVIDER] ??
toml.providerBaseUrls.openai ??
"")
const providerSupportsWebsockets =
toml.providerSupportsWebsockets[activeProvider] ??
(activeProvider === CODEX_DEFAULT_MODEL_PROVIDER
? toml.featureResponsesWebsocketsV2
: false)
return {
apiBaseUrl: providerBaseUrl,
apiKey:
parsedAuth.error === null
? (pickFirstString(authObject, [
"OPENAI_API_KEY",
"OPENAI_API_TOKEN",
"API_KEY",
]) ?? "")
: null,
model: toml.model,
modelProvider: activeProvider,
reasoningEffort: toml.modelReasoningEffort,
providerOptions: buildCodexProviderOptions(
activeProvider,
toml.providerNames
),
supportsWebsockets: providerSupportsWebsockets,
}
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
}
function findTomlRootEndIndex(lines: string[]): number {
for (let i = 0; i < lines.length; i += 1) {
if (/^\[.*\]$/.test(lines[i].trim())) return i
}
return lines.length
}
function findTomlRootAssignmentIndex(lines: string[], key: string): number {
const rootEnd = findTomlRootEndIndex(lines)
for (let i = 0; i < rootEnd; i += 1) {
const assignmentKey = parseTomlAssignmentKey(lines[i])
if (assignmentKey === key) return i
}
return -1
}
function preferredTomlRootInsertionIndex(lines: string[], key: string): number {
if (key === "model") {
const providerIndex = findTomlRootAssignmentIndex(lines, "model_provider")
return providerIndex >= 0 ? providerIndex : 0
}
if (key === "model_reasoning_effort") {
const modelIndex = findTomlRootAssignmentIndex(lines, "model")
return modelIndex >= 0 ? modelIndex + 1 : 0
}
return findTomlRootEndIndex(lines)
}
function updateTomlRootStringKey(
configTomlText: string,
key: string,
value: string
): string {
const lineText = `${key} = ${JSON.stringify(value)}`
const lines = configTomlText.split(/\r?\n/)
const assignmentIndex = findTomlRootAssignmentIndex(lines, key)
const nextValue = value.trim()
if (!nextValue) {
if (assignmentIndex >= 0) {
lines.splice(assignmentIndex, 1)
}
return lines.join("\n").trim()
}
const insertAt = preferredTomlRootInsertionIndex(lines, key)
if (assignmentIndex >= 0) {
lines[assignmentIndex] = lineText
} else {
lines.splice(Math.max(0, insertAt), 0, lineText)
}
return lines.join("\n").trim()
}
function updateTomlRootBooleanKey(
configTomlText: string,
key: string,
value: boolean
): string {
const lineText = `${key} = ${value ? "true" : "false"}`
const lines = configTomlText.split(/\r?\n/)
const assignmentIndex = findTomlRootAssignmentIndex(lines, key)
if (assignmentIndex >= 0) {
lines[assignmentIndex] = lineText
} else {
lines.splice(0, 0, lineText)
}
return lines.join("\n").trim()
}
function findTomlSectionRange(
lines: string[],
sectionName: string
): { start: number; end: number } | null {
const headerText = `[${sectionName}]`
let sectionStart = -1
let sectionEnd = lines.length
for (let i = 0; i < lines.length; i += 1) {
const trimmed = lines[i].trim()
if (sectionStart < 0) {
if (trimmed === headerText) {
sectionStart = i
}
continue
}
if (/^\[.*\]$/.test(trimmed)) {
sectionEnd = i
break
}
}
if (sectionStart < 0) return null
return { start: sectionStart, end: sectionEnd }
}
function upsertTomlSectionBooleanKey(
configTomlText: string,
sectionName: string,
key: string,
value: boolean | null
): string {
const lines = configTomlText.split(/\r?\n/)
const section = findTomlSectionRange(lines, sectionName)
if (section) {
let assignmentIndex = -1
for (let i = section.start + 1; i < section.end; i += 1) {
const assignmentKey = parseTomlAssignmentKey(lines[i])
if (assignmentKey === key) {
assignmentIndex = i
break
}
}
if (value === null) {
if (assignmentIndex >= 0) {
lines.splice(assignmentIndex, 1)
}
const refreshedSection = findTomlSectionRange(lines, sectionName)
if (refreshedSection) {
const hasEntries = lines
.slice(refreshedSection.start + 1, refreshedSection.end)
.some((rawLine) => {
const line = rawLine.trim()
return line !== "" && !line.startsWith("#")
})
if (!hasEntries) {
const before = lines.slice(0, refreshedSection.start)
const after = lines.slice(refreshedSection.end)
while (before.length > 0 && before[before.length - 1].trim() === "") {
before.pop()
}
while (after.length > 0 && after[0].trim() === "") {
after.shift()
}
const merged =
before.length > 0 && after.length > 0
? [...before, "", ...after]
: [...before, ...after]
return merged.join("\n").trim()
}
}
return lines.join("\n").trim()
}
const lineText = `${key} = ${value ? "true" : "false"}`
if (assignmentIndex >= 0) {
lines[assignmentIndex] = lineText
} else {
lines.splice(section.end, 0, lineText)
}
return lines.join("\n").trim()
}
if (value === null) {
return configTomlText.trim()
}
const lineText = `${key} = ${value ? "true" : "false"}`
const insertAt = findTomlRootEndIndex(lines)
const prefixBlank =
insertAt > 0 && lines[insertAt - 1].trim() !== "" ? [""] : []
const suffixBlank =
insertAt < lines.length && lines[insertAt].trim() !== "" ? [""] : []
lines.splice(
insertAt,
0,
...prefixBlank,
`[${sectionName}]`,
lineText,
...suffixBlank
)
return lines.join("\n").trim()
}
function patchCodexProviderBaseUrl(
configTomlText: string,
provider: string,
apiBaseUrl: string
): string {
const trimmedProvider = provider.trim()
if (!trimmedProvider) return configTomlText.trim()
const nextApiBaseUrl = apiBaseUrl.trim()
const lines = configTomlText.split(/\r?\n/)
const sectionPattern = new RegExp(
`^\\[\\s*model_providers\\.${escapeRegExp(trimmedProvider)}\\s*\\]$`
)
let sectionStart = -1
let sectionEnd = lines.length
for (let i = 0; i < lines.length; i += 1) {
const trimmed = lines[i].trim()
if (sectionStart < 0) {
if (sectionPattern.test(trimmed)) {
sectionStart = i
}
continue
}
if (/^\[.*\]$/.test(trimmed)) {
sectionEnd = i
break
}
}
if (sectionStart >= 0) {
let baseUrlIndex = -1
for (let i = sectionStart + 1; i < sectionEnd; i += 1) {
const assignment = parseTomlStringAssignment(lines[i])
if (!assignment || assignment.key !== "base_url") continue
baseUrlIndex = i
break
}
if (!nextApiBaseUrl) {
if (baseUrlIndex >= 0) {
lines.splice(baseUrlIndex, 1)
}
return lines.join("\n").trim()
}
const lineText = `base_url = ${JSON.stringify(nextApiBaseUrl)}`
if (baseUrlIndex >= 0) {
lines[baseUrlIndex] = lineText
} else {
lines.splice(sectionEnd, 0, lineText)
}
return lines.join("\n").trim()
}
if (!nextApiBaseUrl) return configTomlText.trim()
const appended = configTomlText.trimEnd()
const sectionText = `[model_providers.${trimmedProvider}]\nbase_url = ${JSON.stringify(nextApiBaseUrl)}`
if (!appended) return sectionText
return `${appended}\n\n${sectionText}`.trim()
}
function patchCodexProviderField(
configTomlText: string,
provider: string,
key: string,
lineText: string
): string {
const trimmedProvider = provider.trim()
if (!trimmedProvider) return configTomlText.trim()
const lines = configTomlText.split(/\r?\n/)
const sectionPattern = new RegExp(
`^\\[\\s*model_providers\\.${escapeRegExp(trimmedProvider)}\\s*\\]$`
)
let sectionStart = -1
let sectionEnd = lines.length
for (let i = 0; i < lines.length; i += 1) {
const trimmed = lines[i].trim()
if (sectionStart < 0) {
if (sectionPattern.test(trimmed)) {
sectionStart = i
}
continue
}
if (/^\[.*\]$/.test(trimmed)) {
sectionEnd = i
break
}
}
if (sectionStart >= 0) {
let fieldIndex = -1
for (let i = sectionStart + 1; i < sectionEnd; i += 1) {
const assignmentKey = parseTomlAssignmentKey(lines[i])
if (assignmentKey !== key) continue
fieldIndex = i
break
}
if (fieldIndex >= 0) {
lines[fieldIndex] = lineText
} else {
let insertAt = sectionEnd
while (insertAt > sectionStart + 1 && lines[insertAt - 1].trim() === "") {
insertAt -= 1
}
lines.splice(insertAt, 0, lineText)
}
return lines.join("\n").trim()
}
const appended = configTomlText.trimEnd()
const sectionText = `[model_providers.${trimmedProvider}]\n${lineText}`
if (!appended) return sectionText
return `${appended}\n\n${sectionText}`.trim()
}
function ensureCodexProviderDefaults(
configTomlText: string,
provider: string
): string {
if (provider.trim() !== CODEX_DEFAULT_MODEL_PROVIDER) {
return configTomlText
}
let next = configTomlText
const current = extractCodexTomlImportantValues(next)
const codegBaseUrl =
current.providerBaseUrls[CODEX_DEFAULT_MODEL_PROVIDER] ?? ""
next = patchCodexProviderField(
next,
CODEX_DEFAULT_MODEL_PROVIDER,
"base_url",
`base_url = ${JSON.stringify(codegBaseUrl)}`
)
next = patchCodexProviderField(
next,
CODEX_DEFAULT_MODEL_PROVIDER,
"name",
'name = "codeg"'
)
next = patchCodexProviderField(
next,
CODEX_DEFAULT_MODEL_PROVIDER,
"wire_api",
'wire_api = "responses"'
)
next = patchCodexProviderField(
next,
CODEX_DEFAULT_MODEL_PROVIDER,
"requires_openai_auth",
"requires_openai_auth = true"
)
return next
}
function patchCodexAuthJsonText(
authJsonText: string,
patch: { apiKey?: string }
): {
authJsonText: string
recoveredFromInvalid: boolean
} {
const parsed = parseCodexAuthJsonObject(authJsonText)
const authObject =
parsed.error === null && parsed.authObject ? { ...parsed.authObject } : {}
if (typeof patch.apiKey === "string") {
const apiKey = patch.apiKey.trim()
if (apiKey) {
authObject.OPENAI_API_KEY = apiKey
delete authObject.API_KEY
} else {
delete authObject.OPENAI_API_KEY
delete authObject.OPENAI_API_TOKEN
delete authObject.API_KEY
}
}
return {
authJsonText:
Object.keys(authObject).length === 0
? ""
: JSON.stringify(authObject, null, 2),
recoveredFromInvalid: Boolean(parsed.error),
}
}
function patchCodexConfigTomlText(
configTomlText: string,
patch: {
apiBaseUrl?: string
model?: string
modelProvider?: string
modelReasoningEffort?: string
supportsWebsockets?: boolean
}
): string {
let nextTomlText = configTomlText
if (typeof patch.modelProvider === "string") {
const modelProvider = patch.modelProvider.trim()
if (modelProvider) {
nextTomlText = updateTomlRootStringKey(
nextTomlText,
"model_provider",
modelProvider
)
nextTomlText = ensureCodexProviderDefaults(nextTomlText, modelProvider)
}
}
if (typeof patch.model === "string") {
nextTomlText = updateTomlRootStringKey(nextTomlText, "model", patch.model)
}
if (typeof patch.modelReasoningEffort === "string") {
const reasoningEffort =
normalizeCodexReasoningEffort(patch.modelReasoningEffort) ??
CODEX_DEFAULT_REASONING_EFFORT
nextTomlText = updateTomlRootStringKey(
nextTomlText,
"model_reasoning_effort",
reasoningEffort
)
}
if (typeof patch.apiBaseUrl === "string") {
const tomlValues = extractCodexTomlImportantValues(nextTomlText)
const modelProvider =
patch.modelProvider?.trim() ||
tomlValues.modelProvider.trim() ||
CODEX_DEFAULT_MODEL_PROVIDER
if (!tomlValues.modelProvider.trim() && patch.apiBaseUrl.trim()) {
nextTomlText = updateTomlRootStringKey(
nextTomlText,
"model_provider",
modelProvider
)
}
nextTomlText = patchCodexProviderBaseUrl(
nextTomlText,
modelProvider,
patch.apiBaseUrl
)
nextTomlText = ensureCodexProviderDefaults(nextTomlText, modelProvider)
}
if (typeof patch.supportsWebsockets === "boolean") {
const tomlValues = extractCodexTomlImportantValues(nextTomlText)
const modelProvider =
patch.modelProvider?.trim() ||
tomlValues.modelProvider.trim() ||
CODEX_DEFAULT_MODEL_PROVIDER
if (!tomlValues.modelProvider.trim()) {
nextTomlText = updateTomlRootStringKey(
nextTomlText,
"model_provider",
modelProvider
)
}
nextTomlText = patchCodexProviderField(
nextTomlText,
modelProvider,
"supports_websockets",
`supports_websockets = ${patch.supportsWebsockets ? "true" : "false"}`
)
nextTomlText = ensureCodexProviderDefaults(nextTomlText, modelProvider)
}
const normalizedTomlValues = extractCodexTomlImportantValues(nextTomlText)
if (normalizedTomlValues.model.trim()) {
nextTomlText = updateTomlRootStringKey(
nextTomlText,
"model",
normalizedTomlValues.model
)
}
nextTomlText = updateTomlRootStringKey(
nextTomlText,
"model_reasoning_effort",
normalizedTomlValues.modelReasoningEffort
)
const activeProvider =
normalizedTomlValues.modelProvider.trim() || CODEX_DEFAULT_MODEL_PROVIDER
const shouldEnableFeature = Boolean(
normalizedTomlValues.providerSupportsWebsockets[activeProvider]
)
nextTomlText = upsertTomlSectionBooleanKey(
nextTomlText,
"features",
"responses_websockets_v2",
shouldEnableFeature ? true : null
)
nextTomlText = updateTomlRootBooleanKey(
nextTomlText,
"disable_response_storage",
true
)
const trimmed = nextTomlText.trim()
return trimmed ? `${trimmed}\n` : ""
}
function patchImportantConfigText(
agentType: AgentType,
configText: string,
patch: ImportantDraftPatch
): {
configText: string
recoveredFromInvalid: boolean
} {
const parseResult = parseConfigJsonText(configText)
const config = parseResult.error ? {} : { ...parseResult.config }
const assignOrRemove = (key: string, value: string | undefined) => {
const trimmed = value?.trim() ?? ""
if (!trimmed) {
delete config[key]
return
}
config[key] = trimmed
}
assignOrRemove("apiBaseUrl", patch.apiBaseUrl)
assignOrRemove("apiKey", patch.apiKey)
if (agentType === "claude_code") {
const env =
typeof config.env === "object" && config.env && !Array.isArray(config.env)
? { ...(config.env as Record<string, unknown>) }
: {}
const assignEnv = (key: string, value: string | undefined) => {
const trimmed = value?.trim() ?? ""
if (!trimmed) {
delete env[key]
return
}
env[key] = trimmed
}
assignEnv(CLAUDE_MODEL_ENV_KEYS.claudeMainModel, patch.claudeMainModel)
assignEnv(
CLAUDE_MODEL_ENV_KEYS.claudeReasoningModel,
patch.claudeReasoningModel
)
assignEnv(
CLAUDE_MODEL_ENV_KEYS.claudeDefaultHaikuModel,
patch.claudeDefaultHaikuModel
)
assignEnv(
CLAUDE_MODEL_ENV_KEYS.claudeDefaultSonnetModel,
patch.claudeDefaultSonnetModel
)
assignEnv(
CLAUDE_MODEL_ENV_KEYS.claudeDefaultOpusModel,
patch.claudeDefaultOpusModel
)
if (Object.keys(env).length === 0) {
delete config.env
} else {
config.env = env
}
} else {
assignOrRemove("model", patch.model)
}
return {
configText:
Object.keys(config).length === 0 ? "" : JSON.stringify(config, null, 2),
recoveredFromInvalid: Boolean(parseResult.error),
}
}
function patchEnvByImportantKey(
agentType: AgentType,
envText: string,
key: ImportantConfigKey,
value: string
): string {
const keys = importantEnvKeysByAgent(agentType)
if (key === "apiBaseUrl") {
return patchEnvText(envText, { [keys.apiBaseUrl[0]]: value })
}
if (key === "apiKey") {
return patchEnvText(envText, { [keys.apiKey[0]]: value })
}
if (key === "model") {
return patchEnvText(envText, { [keys.model[0]]: value })
}
return patchEnvText(envText, { [CLAUDE_MODEL_ENV_KEYS[key]]: value })
}
function applyImportantFieldToDraft(
draft: AgentDraft,
key: ImportantConfigKey,
value: string
): AgentDraft {
if (key === "apiBaseUrl") return { ...draft, apiBaseUrl: value }
if (key === "apiKey") return { ...draft, apiKey: value }
if (key === "model") return { ...draft, model: value }
if (key === "claudeMainModel") return { ...draft, claudeMainModel: value }
if (key === "claudeReasoningModel") {
return { ...draft, claudeReasoningModel: value }
}
if (key === "claudeDefaultHaikuModel") {
return { ...draft, claudeDefaultHaikuModel: value }
}
if (key === "claudeDefaultSonnetModel") {
return { ...draft, claudeDefaultSonnetModel: value }
}
return { ...draft, claudeDefaultOpusModel: value }
}
function buildImportantPatchFromDraft(draft: AgentDraft): ImportantDraftPatch {
return {
apiBaseUrl: draft.apiBaseUrl,
apiKey: draft.apiKey,
model: draft.model,
claudeMainModel: draft.claudeMainModel,
claudeReasoningModel: draft.claudeReasoningModel,
claudeDefaultHaikuModel: draft.claudeDefaultHaikuModel,
claudeDefaultSonnetModel: draft.claudeDefaultSonnetModel,
claudeDefaultOpusModel: draft.claudeDefaultOpusModel,
}
}
function buildAgentDraft(agent: AcpAgentInfo): AgentDraft {
const configText =
typeof agent.config_json === "string" && agent.config_json.trim()
? agent.config_json
: ""
const openCodeAuthJsonText = agent.opencode_auth_json ?? ""
const codexAuthJsonText = agent.codex_auth_json ?? ""
const codexConfigTomlText =
agent.agent_type === "codex"
? updateTomlRootBooleanKey(
agent.codex_config_toml ?? "",
"disable_response_storage",
true
)
: (agent.codex_config_toml ?? "")
const important = extractImportantConfigValues(
agent.agent_type,
agent.env,
configText
)
const geminiImportant = extractGeminiImportantValues(agent.env, configText)
const openClawImportant = extractOpenClawImportantValues(
agent.env,
configText
)
const codexImportant = extractCodexImportantValues(
codexAuthJsonText,
codexConfigTomlText
)
const openCodeImportant = extractOpenCodeConfigValues(
configText,
openCodeAuthJsonText
)
return {
enabled: agent.enabled,
envText: envMapToText(agent.env),
configText,
apiBaseUrl:
agent.agent_type === "codex"
? codexImportant.apiBaseUrl
: agent.agent_type === "gemini"
? geminiImportant.apiBaseUrl
: important.apiBaseUrl,
apiKey:
agent.agent_type === "codex"
? (codexImportant.apiKey ?? "")
: agent.agent_type === "gemini"
? geminiImportant.geminiApiKey || geminiImportant.googleApiKey
: important.apiKey,
model:
agent.agent_type === "codex"
? codexImportant.model
: agent.agent_type === "gemini"
? geminiImportant.model
: agent.agent_type === "open_code"
? openCodeImportant.model
: important.model,
geminiAuthMode: geminiImportant.authMode,
geminiApiKey: geminiImportant.geminiApiKey,
googleApiKey: geminiImportant.googleApiKey,
googleCloudProject: geminiImportant.googleCloudProject,
googleCloudLocation: geminiImportant.googleCloudLocation,
googleApplicationCredentials: geminiImportant.googleApplicationCredentials,
codexAuthMode:
agent.agent_type === "codex"
? inferCodexAuthMode(codexAuthJsonText)
: "api_key",
codexModelProvider: codexImportant.modelProvider,
codexProviderOptions: codexImportant.providerOptions,
codexReasoningEffort: codexImportant.reasoningEffort,
codexSupportsWebsockets: codexImportant.supportsWebsockets,
claudeMainModel: important.claudeMainModel,
claudeReasoningModel: important.claudeReasoningModel,
claudeDefaultHaikuModel: important.claudeDefaultHaikuModel,
claudeDefaultSonnetModel: important.claudeDefaultSonnetModel,
claudeDefaultOpusModel: important.claudeDefaultOpusModel,
codexAuthJsonText,
codexConfigTomlText,
openCodeAuthJsonText,
openClawGatewayUrl: openClawImportant.gatewayUrl,
openClawGatewayToken: openClawImportant.gatewayToken,
openClawSessionKey: openClawImportant.sessionKey,
}
}
function compareVersion(a: string, b: string): number {
const toParts = (value: string): number[] => {
const normalized = value.trim().replace(/^[^\d]*/, "")
return normalized.split(".").map((part) => Number.parseInt(part, 10) || 0)
}
const left = toParts(a)
const right = toParts(b)
const len = Math.max(left.length, right.length)
for (let i = 0; i < len; i += 1) {
const lv = left[i] ?? 0
const rv = right[i] ?? 0
if (lv !== rv) return lv > rv ? 1 : -1
}
return 0
}
function hasComparableVersion(
value: string | null | undefined
): value is string {
return Boolean(value && /\d/.test(value) && value.includes("."))
}
function buildVersionCheck(agent: AcpAgentInfo): UiCheckItem | null {
if (agent.distribution_type !== "binary" && agent.distribution_type !== "npx")
return null
const remoteVersion = agent.registry_version ?? "unknown"
const localVersion =
agent.installed_version ?? acpText("version.notInstalled", "Not installed")
const versionText = acpText(
"version.remoteLocal",
"Remote: {remoteVersion} · Local: {localVersion}",
{ remoteVersion, localVersion }
)
const installAction: RunningActionKind =
agent.distribution_type === "binary" ? "download_binary" : "install_npx"
const upgradeAction: RunningActionKind =
agent.distribution_type === "binary" ? "upgrade_binary" : "upgrade_npx"
const uninstallAction: RunningActionKind =
agent.distribution_type === "binary" ? "uninstall_binary" : "uninstall_npx"
if (!agent.available) {
return {
check_id: "version_status",
label: acpText("version.statusLabel", "Version Status"),
status: "fail",
message: acpText(
"version.platformUnsupported",
"{versionText}. Current platform does not support this agent.",
{ versionText }
),
fixes: [],
}
}
if (!agent.installed_version) {
return {
check_id: "version_status",
label: acpText("version.statusLabel", "Version Status"),
status: "fail",
message: acpText(
"version.clickInstall",
"{versionText}. Click Install on the right.",
{ versionText }
),
fixes: [
{
label: acpText("actions.install", "Install"),
kind: installAction,
payload: agent.agent_type,
},
],
}
}
if (
agent.registry_version &&
hasComparableVersion(agent.registry_version) &&
!hasComparableVersion(agent.installed_version)
) {
return {
check_id: "version_status",
label: acpText("version.statusLabel", "Version Status"),
status: "warn",
message: acpText(
"version.localUnrecognized",
"{versionText}. Local version is not comparable; try upgrade to overwrite install.",
{ versionText }
),
fixes: [
{
label: acpText("actions.upgrade", "Upgrade"),
kind: upgradeAction,
payload: agent.agent_type,
},
{
label: acpText("actions.uninstall", "Uninstall"),
kind: uninstallAction,
payload: agent.agent_type,
},
],
}
}
if (
hasComparableVersion(agent.registry_version) &&
hasComparableVersion(agent.installed_version) &&
compareVersion(agent.installed_version, agent.registry_version) < 0
) {
return {
check_id: "version_status",
label: acpText("version.statusLabel", "Version Status"),
status: "warn",
message: acpText(
"version.upgradeAvailable",
"{versionText}. Upgrade available.",
{ versionText }
),
fixes: [
{
label: acpText("actions.upgrade", "Upgrade"),
kind: upgradeAction,
payload: agent.agent_type,
},
{
label: acpText("actions.uninstall", "Uninstall"),
kind: uninstallAction,
payload: agent.agent_type,
},
],
}
}
if (!agent.registry_version) {
return {
check_id: "version_status",
label: acpText("version.statusLabel", "Version Status"),
status: "warn",
message: acpText(
"version.remoteUnavailable",
"{versionText}. Remote version is currently unavailable.",
{ versionText }
),
fixes: [
{
label: acpText("actions.uninstall", "Uninstall"),
kind: uninstallAction,
payload: agent.agent_type,
},
],
}
}
return {
check_id: "version_status",
label: acpText("version.statusLabel", "Version Status"),
status: "pass",
message: acpText("version.latest", "{versionText}. Already latest.", {
versionText,
}),
fixes: [
{
label: acpText("actions.uninstall", "Uninstall"),
kind: uninstallAction,
payload: agent.agent_type,
},
],
}
}
function getAgentChecks(
agent: AcpAgentInfo,
current?: AgentCheckState
): UiCheckItem[] {
const versionCheck = buildVersionCheck(agent)
const remoteChecks: UiCheckItem[] = (current?.result?.checks ?? []).map(
(check) => ({
...check,
fixes: [...check.fixes],
})
)
return versionCheck ? [versionCheck, ...remoteChecks] : remoteChecks
}
interface AgentReorderItemProps {
agent: AcpAgentInfo
selected: boolean
reordering: boolean
dragging: AgentType | null
onDragStart: (agentType: AgentType) => void
onDragEnd: () => void
onSelect: (agentType: AgentType) => void
children: (
startDrag: (event: PointerEvent<HTMLButtonElement>) => void
) => ReactNode
}
function AgentReorderItem({
agent,
selected,
reordering,
dragging,
onDragStart,
onDragEnd,
onSelect,
children,
}: AgentReorderItemProps) {
const dragControls = useDragControls()
const startDrag = useCallback(
(event: PointerEvent<HTMLButtonElement>) => {
event.preventDefault()
event.stopPropagation()
dragControls.start(event)
},
[dragControls]
)
return (
<Reorder.Item
as="section"
value={agent}
data-agent-type={agent.agent_type}
drag={reordering ? false : "y"}
dragListener={false}
dragControls={dragControls}
dragMomentum={false}
layout="position"
className={cn(
"rounded-lg border bg-card p-3 transition-colors cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40",
selected && "border-primary/60 bg-primary/5",
dragging === agent.agent_type && "border-primary/60 bg-primary/5"
)}
tabIndex={0}
onDragStart={() => {
onDragStart(agent.agent_type)
}}
onDragEnd={onDragEnd}
onClick={() => {
onSelect(agent.agent_type)
}}
onKeyDown={(event) => {
if (event.target !== event.currentTarget) return
if (event.key !== "Enter" && event.key !== " ") return
event.preventDefault()
onSelect(agent.agent_type)
}}
>
{children(startDrag)}
</Reorder.Item>
)
}
export function AcpAgentSettings() {
const locale = useLocale()
const t = useTranslations("AcpAgentSettings")
const rawTranslator = t as unknown as AcpTranslator
acpTranslator = (key, values) => rawTranslator(key, values)
const searchParams = useSearchParams()
const [agents, setAgents] = useState<AcpAgentInfo[]>([])
const [loadingAgents, setLoadingAgents] = useState(true)
const [loadingError, setLoadingError] = useState<string | null>(null)
const [checkState, setCheckState] = useState<
Partial<Record<AgentType, AgentCheckState>>
>({})
const [checking, setChecking] = useState<Partial<Record<AgentType, boolean>>>(
{}
)
const [busyBinaryAction, setBusyBinaryAction] = useState<
Partial<Record<AgentType, boolean>>
>({})
const [runningActionKind, setRunningActionKind] = useState<
Partial<Record<AgentType, RunningActionKind>>
>({})
const [saving, setSaving] = useState<Partial<Record<AgentType, boolean>>>({})
const [uninstallConfirmAgent, setUninstallConfirmAgent] =
useState<AcpAgentInfo | null>(null)
const [expandedChecks, setExpandedChecks] = useState<Record<string, boolean>>(
{}
)
const [selectedAgentType, setSelectedAgentType] = useState<AgentType | null>(
null
)
const [drafts, setDrafts] = useState<Partial<Record<AgentType, AgentDraft>>>(
{}
)
const [configErrors, setConfigErrors] = useState<
Partial<Record<AgentType, string | null>>
>({})
const [showApiKeys, setShowApiKeys] = useState<
Partial<Record<AgentType, boolean>>
>({})
const [openCodeProviderId, setOpenCodeProviderId] = useState("")
const [openCodeNewProviderId, setOpenCodeNewProviderId] = useState("")
const [openCodeNewModelIds, setOpenCodeNewModelIds] = useState<
Record<string, string>
>({})
const [openCodeModelIdDrafts, setOpenCodeModelIdDrafts] = useState<
Record<string, string>
>({})
const [openCodeModelConfigExpanded, setOpenCodeModelConfigExpanded] =
useState<Record<string, boolean>>({})
const [openCodeDeleteProviderId, setOpenCodeDeleteProviderId] = useState<
string | null
>(null)
const [dragging, setDragging] = useState<AgentType | null>(null)
const [reordering, setReordering] = useState(false)
const pendingOrderRef = useRef<AgentType[] | null>(null)
const busyActionRef = useRef<Set<AgentType>>(new Set())
const handledSearchAgentRef = useRef<string | null>(null)
const agentListRef = useRef<HTMLDivElement | null>(null)
const sortedAgents = useMemo(
() =>
[...agents].sort(
(a, b) => a.sort_order - b.sort_order || a.name.localeCompare(b.name)
),
[agents]
)
const selectedAgent = useMemo(
() =>
sortedAgents.find((agent) => agent.agent_type === selectedAgentType) ??
null,
[selectedAgentType, sortedAgents]
)
const agentTypesKey = useMemo(
() =>
[...new Set(agents.map((agent) => agent.agent_type))].sort().join(","),
[agents]
)
const requestedAgentType = useMemo(
() => searchParams.get("agent"),
[searchParams]
)
const refreshAgents = useCallback(async () => {
setLoadingAgents(true)
setLoadingError(null)
try {
const next = await acpListAgents()
setAgents(next)
setDrafts((prev) => {
const updated = { ...prev }
for (const agent of next) {
if (!updated[agent.agent_type]) {
updated[agent.agent_type] = buildAgentDraft(agent)
}
}
return updated
})
setConfigErrors((prev) => {
const updated = { ...prev }
for (const agent of next) {
if (typeof updated[agent.agent_type] !== "undefined") continue
const configText =
typeof agent.config_json === "string" ? agent.config_json : ""
updated[agent.agent_type] = parseConfigJsonText(configText).error
}
return updated
})
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
setLoadingError(message)
} finally {
setLoadingAgents(false)
}
}, [])
const runPreflight = useCallback(
async (agentType: AgentType, forceRefresh?: boolean) => {
setChecking((prev) => ({ ...prev, [agentType]: true }))
try {
const [resultState, versionState] = await Promise.allSettled([
acpPreflight(agentType, forceRefresh),
acpDetectAgentLocalVersion(agentType),
])
if (versionState.status === "fulfilled") {
setAgents((prev) => {
if (versionState.value === null) return prev
let changed = false
const next = prev.map((agent) => {
if (agent.agent_type !== agentType) return agent
if (agent.installed_version === versionState.value) return agent
changed = true
return { ...agent, installed_version: versionState.value }
})
return changed ? next : prev
})
}
if (resultState.status === "fulfilled") {
setCheckState((prev) => ({
...prev,
[agentType]: { result: resultState.value },
}))
} else {
const message =
resultState.reason instanceof Error
? resultState.reason.message
: String(resultState.reason)
setCheckState((prev) => ({
...prev,
[agentType]: { error: message },
}))
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
setCheckState((prev) => ({ ...prev, [agentType]: { error: message } }))
} finally {
setChecking((prev) => ({ ...prev, [agentType]: false }))
}
},
[]
)
const runAllPreflight = useCallback(
async (agentTypes: AgentType[]) => {
if (agentTypes.length === 0) return
setChecking((prev) => {
const next = { ...prev }
for (const agentType of agentTypes) {
next[agentType] = true
}
return next
})
await Promise.all(agentTypes.map((agentType) => runPreflight(agentType)))
},
[runPreflight]
)
useEffect(() => {
refreshAgents().catch((err) => {
console.error("[Settings] refresh agents failed:", err)
})
}, [refreshAgents])
useEffect(() => {
if (loadingAgents || !agentTypesKey) return
const agentTypes = agentTypesKey.split(",") as AgentType[]
runAllPreflight(agentTypes).catch((err) => {
console.error("[Settings] run all preflight failed:", err)
})
}, [agentTypesKey, loadingAgents, runAllPreflight])
useEffect(() => {
if (!requestedAgentType) {
handledSearchAgentRef.current = null
return
}
if (sortedAgents.length === 0) {
return
}
if (handledSearchAgentRef.current === requestedAgentType) {
return
}
const matched = sortedAgents.find(
(agent) => agent.agent_type === requestedAgentType
)
if (matched) {
setSelectedAgentType(matched.agent_type)
}
handledSearchAgentRef.current = requestedAgentType
}, [requestedAgentType, sortedAgents])
useEffect(() => {
if (!selectedAgentType) return
const container = agentListRef.current
if (!container) return
const selected = container.querySelector<HTMLElement>(
`[data-agent-type="${selectedAgentType}"]`
)
if (!selected) return
selected.scrollIntoView({ block: "nearest", behavior: "smooth" })
}, [selectedAgentType, sortedAgents])
useEffect(() => {
if (sortedAgents.length === 0) {
setSelectedAgentType(null)
return
}
setSelectedAgentType((prev) => {
if (prev && sortedAgents.some((agent) => agent.agent_type === prev)) {
return prev
}
return sortedAgents[0].agent_type
})
}, [sortedAgents])
const persistPreferences = useCallback(
async (
agentType: AgentType,
enabled: boolean,
envText: string,
configText: string,
options?: {
openCodeAuthJsonText?: string
codexAuthJsonText?: string
codexConfigTomlText?: string
}
) => {
const parsedConfig = parseConfigJsonText(configText)
if (parsedConfig.error) {
throw new Error(parsedConfig.error)
}
const openCodeAuthJsonText = options?.openCodeAuthJsonText
const codexAuthJsonText = options?.codexAuthJsonText
const codexConfigTomlText = options?.codexConfigTomlText
if (agentType === "codex" && typeof codexAuthJsonText === "string") {
const authError = parseCodexAuthJsonText(codexAuthJsonText)
if (authError) {
throw new Error(authError)
}
}
const parsedEnv = parseEnvText(envText)
const normalizedConfig = normalizeConfigText(configText)
const configForPersist =
agentType === "open_code" && !normalizedConfig ? "{}" : normalizedConfig
setSaving((prev) => ({ ...prev, [agentType]: true }))
try {
await acpUpdateAgentPreferences(agentType, {
enabled,
env: parsedEnv,
config_json: configForPersist || null,
opencode_auth_json:
typeof openCodeAuthJsonText === "string"
? openCodeAuthJsonText
: null,
codex_auth_json:
typeof codexAuthJsonText === "string" ? codexAuthJsonText : null,
codex_config_toml:
typeof codexConfigTomlText === "string"
? codexConfigTomlText
: null,
})
setAgents((prev) =>
prev.map((agent) =>
agent.agent_type === agentType
? {
...agent,
enabled,
env: parsedEnv,
config_json: configForPersist || null,
opencode_auth_json:
typeof openCodeAuthJsonText === "string"
? openCodeAuthJsonText
: agent.opencode_auth_json,
codex_auth_json:
typeof codexAuthJsonText === "string"
? codexAuthJsonText
: agent.codex_auth_json,
codex_config_toml:
typeof codexConfigTomlText === "string"
? codexConfigTomlText
: agent.codex_config_toml,
}
: agent
)
)
} finally {
setSaving((prev) => ({ ...prev, [agentType]: false }))
}
},
[]
)
const runBinaryAction = useCallback(
async (
agent: AcpAgentInfo,
mode: "download" | "upgrade",
kind?: RunningActionKind
) => {
if (busyActionRef.current.has(agent.agent_type)) return
busyActionRef.current.add(agent.agent_type)
setBusyBinaryAction((prev) => ({ ...prev, [agent.agent_type]: true }))
setRunningActionKind((prev) => ({
...prev,
[agent.agent_type]:
kind ?? (mode === "download" ? "download_binary" : "upgrade_binary"),
}))
try {
if (mode === "upgrade") {
await acpClearBinaryCache(agent.agent_type)
}
await acpDownloadAgentBinary(agent.agent_type)
await runPreflight(agent.agent_type)
const detectedVersion = await acpDetectAgentLocalVersion(
agent.agent_type
)
setAgents((prev) =>
prev.map((item) =>
item.agent_type === agent.agent_type
? { ...item, installed_version: detectedVersion }
: item
)
)
toast.success(
t("toasts.agentActionCompleted", {
name: agent.name,
action:
mode === "upgrade" ? t("actions.upgrade") : t("actions.install"),
}),
{
description: detectedVersion
? t("toasts.localVersion", { version: detectedVersion })
: t("toasts.installCompletedVersionLater"),
}
)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
toast.error(
t("toasts.agentActionFailed", {
name: agent.name,
action:
mode === "upgrade" ? t("actions.upgrade") : t("actions.install"),
}),
{
description: message,
}
)
throw err
} finally {
busyActionRef.current.delete(agent.agent_type)
setBusyBinaryAction((prev) => ({ ...prev, [agent.agent_type]: false }))
setRunningActionKind((prev) => ({
...prev,
[agent.agent_type]: undefined,
}))
}
},
[runPreflight, t]
)
const runNpxAction = useCallback(
async (agent: AcpAgentInfo, mode: "install" | "upgrade") => {
if (busyActionRef.current.has(agent.agent_type)) return
busyActionRef.current.add(agent.agent_type)
setBusyBinaryAction((prev) => ({ ...prev, [agent.agent_type]: true }))
setRunningActionKind((prev) => ({
...prev,
[agent.agent_type]: mode === "install" ? "install_npx" : "upgrade_npx",
}))
try {
const installedVersion = await acpPrepareNpxAgent(
agent.agent_type,
agent.registry_version
)
setAgents((prev) =>
prev.map((item) =>
item.agent_type === agent.agent_type
? { ...item, installed_version: installedVersion }
: item
)
)
await runPreflight(agent.agent_type)
const detectedVersion = await acpDetectAgentLocalVersion(
agent.agent_type
)
if (detectedVersion && detectedVersion !== installedVersion) {
setAgents((prev) =>
prev.map((item) =>
item.agent_type === agent.agent_type
? { ...item, installed_version: detectedVersion }
: item
)
)
}
const finalVersion = detectedVersion ?? installedVersion
toast.success(
t("toasts.agentActionCompleted", {
name: agent.name,
action:
mode === "upgrade" ? t("actions.upgrade") : t("actions.install"),
}),
{
description: finalVersion
? t("toasts.localVersion", { version: finalVersion })
: t("toasts.installCompletedVersionLater"),
}
)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
toast.error(
t("toasts.agentActionFailed", {
name: agent.name,
action:
mode === "upgrade" ? t("actions.upgrade") : t("actions.install"),
}),
{
description: message,
}
)
throw err
} finally {
busyActionRef.current.delete(agent.agent_type)
setBusyBinaryAction((prev) => ({ ...prev, [agent.agent_type]: false }))
setRunningActionKind((prev) => ({
...prev,
[agent.agent_type]: undefined,
}))
}
},
[runPreflight, t]
)
const runUninstallAction = useCallback(
async (agent: AcpAgentInfo) => {
if (busyActionRef.current.has(agent.agent_type)) return
busyActionRef.current.add(agent.agent_type)
setBusyBinaryAction((prev) => ({ ...prev, [agent.agent_type]: true }))
setRunningActionKind((prev) => ({
...prev,
[agent.agent_type]:
agent.distribution_type === "binary"
? "uninstall_binary"
: "uninstall_npx",
}))
try {
await acpUninstallAgent(agent.agent_type)
setAgents((prev) =>
prev.map((item) =>
item.agent_type === agent.agent_type
? { ...item, installed_version: null }
: item
)
)
await runPreflight(agent.agent_type)
toast.success(t("toasts.uninstallCompleted", { name: agent.name }), {
description: t("toasts.localVersionRemoved"),
})
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
toast.error(t("toasts.uninstallFailed", { name: agent.name }), {
description: message,
})
throw err
} finally {
busyActionRef.current.delete(agent.agent_type)
setBusyBinaryAction((prev) => ({ ...prev, [agent.agent_type]: false }))
setRunningActionKind((prev) => ({
...prev,
[agent.agent_type]: undefined,
}))
}
},
[runPreflight, t]
)
const handleFixAction = async (agent: AcpAgentInfo, action: UiFixAction) => {
if (
busyBinaryAction[agent.agent_type] ||
busyActionRef.current.has(agent.agent_type)
) {
return
}
if (action.kind === "open_url") {
await openUrl(action.payload)
return
}
if (action.kind === "download_binary") {
await runBinaryAction(agent, "download")
return
}
if (action.kind === "upgrade_binary") {
await runBinaryAction(agent, "upgrade")
return
}
if (action.kind === "install_npx") {
await runNpxAction(agent, "install")
return
}
if (action.kind === "upgrade_npx") {
await runNpxAction(agent, "upgrade")
return
}
if (action.kind === "uninstall_binary" || action.kind === "uninstall_npx") {
setUninstallConfirmAgent(agent)
return
}
if (action.kind === "redownload_binary") {
await runBinaryAction(agent, "upgrade", "redownload_binary")
return
}
await runPreflight(agent.agent_type)
}
const confirmUninstall = useCallback(() => {
if (!uninstallConfirmAgent) return
const target = uninstallConfirmAgent
runUninstallAction(target)
.catch((err) => {
console.error("[Settings] uninstall action failed:", err)
})
.finally(() => {
setUninstallConfirmAgent(null)
})
}, [runUninstallAction, uninstallConfirmAgent])
const persistReorder = useCallback(
async (order: AgentType[]) => {
if (order.length === 0) return
setReordering(true)
try {
await acpReorderAgents(order)
} catch (err) {
console.error("[Settings] reorder agents failed:", err)
const message = err instanceof Error ? err.message : String(err)
toast.error(t("toasts.saveAgentOrderFailed"), {
description: message,
})
await refreshAgents()
} finally {
setReordering(false)
}
},
[refreshAgents, t]
)
const handleReorder = useCallback((next: AcpAgentInfo[]) => {
const reordered = next.map((agent, index) => ({
...agent,
sort_order: index,
}))
setAgents(reordered)
pendingOrderRef.current = reordered.map((agent) => agent.agent_type)
}, [])
const renderCheck = (agent: AcpAgentInfo, check: UiCheckItem) => {
const checkKey = `${agent.agent_type}:${check.check_id}`
const expanded = expandedChecks[checkKey] ?? check.status !== "pass"
return (
<div
key={check.check_id}
className="rounded-md border bg-muted/20 px-3 py-2 space-y-2"
>
<button
type="button"
className="w-full flex items-center justify-between gap-2 text-left"
onClick={() => {
setExpandedChecks((prev) => ({
...prev,
[checkKey]: !expanded,
}))
}}
>
<div className="min-w-0 flex items-center gap-1.5">
{expanded ? (
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
) : (
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
)}
<span className="text-xs font-medium truncate">{check.label}</span>
</div>
<span
className={`text-[11px] font-semibold shrink-0 ${statusTone(check.status)}`}
>
{check.status.toUpperCase()}
</span>
</button>
{expanded && (
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 text-[11px] text-muted-foreground break-words">
{check.message}
</div>
{check.fixes.length > 0 && (
<div className="flex flex-wrap gap-1.5 justify-end max-w-[220px] shrink-0">
{check.fixes.map((fix, index) => (
<Button
key={`${fix.label}-${index}`}
size="xs"
variant="outline"
className="h-6 bg-muted/30 hover:bg-muted/50 disabled:bg-muted/30 disabled:opacity-100"
disabled={
Boolean(busyBinaryAction[agent.agent_type]) &&
[
"download_binary",
"upgrade_binary",
"install_npx",
"upgrade_npx",
"uninstall_binary",
"uninstall_npx",
"redownload_binary",
].includes(fix.kind)
}
onClick={() => {
handleFixAction(agent, fix).catch((err) => {
console.error("[Settings] fix action failed:", err)
})
}}
>
{runningActionKind[agent.agent_type] === fix.kind ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : fix.kind === "download_binary" ||
fix.kind === "install_npx" ? (
<Download className="h-3 w-3" />
) : fix.kind === "upgrade_binary" ||
fix.kind === "upgrade_npx" ||
fix.kind === "redownload_binary" ? (
<Wrench className="h-3 w-3" />
) : fix.kind === "uninstall_binary" ||
fix.kind === "uninstall_npx" ? (
<Trash2 className="h-3 w-3" />
) : null}
{fix.label}
</Button>
))}
</div>
)}
</div>
)}
</div>
)
}
const selectedCurrent = selectedAgent
? checkState[selectedAgent.agent_type]
: undefined
const selectedDraft = selectedAgent
? (drafts[selectedAgent.agent_type] ?? buildAgentDraft(selectedAgent))
: null
const selectedConfigError = selectedAgent
? (configErrors[selectedAgent.agent_type] ?? null)
: null
const selectedIsSaving = selectedAgent
? Boolean(saving[selectedAgent.agent_type])
: false
const selectedAgentKind = selectedAgent?.agent_type ?? null
const selectedCodexAuthJsonText = selectedDraft?.codexAuthJsonText ?? ""
const selectedConfigText = selectedDraft?.configText ?? ""
const selectedOpenCodeAuthJsonText = selectedDraft?.openCodeAuthJsonText ?? ""
const selectedCodexAuthError = useMemo(() => {
if (selectedAgentKind !== "codex" || !locale) return null
return parseCodexAuthJsonText(selectedCodexAuthJsonText)
}, [locale, selectedAgentKind, selectedCodexAuthJsonText])
const selectedCodexReasoningEffortOption =
selectedAgent?.agent_type === "codex" && selectedDraft
? (CODEX_REASONING_EFFORT_OPTIONS.find(
(option) => option.value === selectedDraft.codexReasoningEffort
) ?? null)
: null
const selectedOpenCodeConfig = useMemo(() => {
if (selectedAgentKind !== "open_code" || !locale) return null
return extractOpenCodeConfigValues(
selectedConfigText,
selectedOpenCodeAuthJsonText
)
}, [
locale,
selectedAgentKind,
selectedConfigText,
selectedOpenCodeAuthJsonText,
])
const selectedChecks = useMemo(() => {
if (!selectedAgent || !locale) return []
return getAgentChecks(selectedAgent, selectedCurrent)
}, [locale, selectedAgent, selectedCurrent])
useEffect(() => {
if (!selectedAgent || selectedChecks.length === 0) return
setExpandedChecks((prev) => {
let next = prev
for (const check of selectedChecks) {
const key = `${selectedAgent.agent_type}:${check.check_id}`
if (typeof next[key] !== "undefined") continue
if (next === prev) next = { ...prev }
next[key] = check.status !== "pass"
}
return next
})
}, [selectedAgent, selectedChecks])
useEffect(() => {
if (!selectedOpenCodeConfig) {
if (openCodeProviderId) setOpenCodeProviderId("")
return
}
if (!openCodeProviderId) return
if (selectedOpenCodeConfig.providerIds.includes(openCodeProviderId)) {
return
}
setOpenCodeProviderId("")
}, [openCodeProviderId, selectedOpenCodeConfig])
useEffect(() => {
if (!openCodeDeleteProviderId) return
if (!selectedOpenCodeConfig) {
setOpenCodeDeleteProviderId(null)
return
}
if (
!selectedOpenCodeConfig.providerIds.includes(openCodeDeleteProviderId)
) {
setOpenCodeDeleteProviderId(null)
}
}, [openCodeDeleteProviderId, selectedOpenCodeConfig])
const updateSelectedDraft = useCallback(
(updater: (current: AgentDraft) => AgentDraft) => {
if (!selectedAgent || !selectedDraft) return
setDrafts((prev) => {
const current = prev[selectedAgent.agent_type] ?? selectedDraft
return {
...prev,
[selectedAgent.agent_type]: updater(current),
}
})
},
[selectedAgent, selectedDraft]
)
const handleConfigTextChange = useCallback(
(nextText: string) => {
if (!selectedAgent || !selectedDraft) return
const parseResult = parseConfigJsonText(nextText)
setConfigErrors((prev) => ({
...prev,
[selectedAgent.agent_type]: parseResult.error,
}))
if (parseResult.error) {
updateSelectedDraft((current) => ({
...current,
configText: nextText,
}))
return
}
if (selectedAgent.agent_type === "open_code") {
const openCode = extractOpenCodeConfigValues(
nextText,
selectedDraft.openCodeAuthJsonText
)
updateSelectedDraft((current) => ({
...current,
configText: nextText,
model: openCode.model,
}))
return
}
const important = extractImportantConfigValues(
selectedAgent.agent_type,
parseEnvText(selectedDraft.envText),
nextText
)
const geminiImportant =
selectedAgent.agent_type === "gemini"
? extractGeminiImportantValues(
parseEnvText(selectedDraft.envText),
nextText
)
: null
updateSelectedDraft((current) => ({
...current,
configText: nextText,
apiBaseUrl: geminiImportant
? geminiImportant.apiBaseUrl
: important.apiBaseUrl,
apiKey: important.apiKey,
model: geminiImportant ? geminiImportant.model : important.model,
geminiAuthMode: geminiImportant
? geminiImportant.authMode
: current.geminiAuthMode,
geminiApiKey: geminiImportant
? geminiImportant.geminiApiKey
: current.geminiApiKey,
googleApiKey: geminiImportant
? geminiImportant.googleApiKey
: current.googleApiKey,
googleCloudProject: geminiImportant
? geminiImportant.googleCloudProject
: current.googleCloudProject,
googleCloudLocation: geminiImportant
? geminiImportant.googleCloudLocation
: current.googleCloudLocation,
googleApplicationCredentials: geminiImportant
? geminiImportant.googleApplicationCredentials
: current.googleApplicationCredentials,
claudeMainModel: important.claudeMainModel,
claudeReasoningModel: important.claudeReasoningModel,
claudeDefaultHaikuModel: important.claudeDefaultHaikuModel,
claudeDefaultSonnetModel: important.claudeDefaultSonnetModel,
claudeDefaultOpusModel: important.claudeDefaultOpusModel,
}))
},
[selectedAgent, selectedDraft, updateSelectedDraft]
)
const handleImportantConfigChange = useCallback(
(key: ImportantConfigKey, value: string) => {
if (!selectedAgent || !selectedDraft) return
const nextDraft = applyImportantFieldToDraft(selectedDraft, key, value)
const nextJson = patchImportantConfigText(
selectedAgent.agent_type,
selectedDraft.configText,
buildImportantPatchFromDraft(nextDraft)
)
if (nextJson.recoveredFromInvalid) {
toast.warning(t("warnings.nativeJsonRecoveredStructured"))
}
setConfigErrors((prev) => ({
...prev,
[selectedAgent.agent_type]: null,
}))
updateSelectedDraft((current) => {
const nextCurrent = applyImportantFieldToDraft(current, key, value)
return {
...nextCurrent,
envText: patchEnvByImportantKey(
selectedAgent.agent_type,
current.envText,
key,
value
),
configText: nextJson.configText,
}
})
},
[selectedAgent, selectedDraft, t, updateSelectedDraft]
)
const handleGeminiFieldChange = useCallback(
(
key:
| "apiBaseUrl"
| "model"
| "geminiApiKey"
| "googleApiKey"
| "googleCloudProject"
| "googleCloudLocation"
| "googleApplicationCredentials",
value: string
) => {
if (
!selectedAgent ||
!selectedDraft ||
selectedAgent.agent_type !== "gemini"
)
return
const nextValues = {
authMode: selectedDraft.geminiAuthMode,
apiBaseUrl: selectedDraft.apiBaseUrl,
geminiApiKey: selectedDraft.geminiApiKey,
googleApiKey: selectedDraft.googleApiKey,
googleCloudProject: selectedDraft.googleCloudProject,
googleCloudLocation: selectedDraft.googleCloudLocation,
googleApplicationCredentials:
selectedDraft.googleApplicationCredentials,
model: selectedDraft.model,
}
nextValues[key] = value
const normalizedValues = patchGeminiAuthMode(
nextValues,
nextValues.authMode
)
const nextConfig = patchGeminiConfigText(selectedDraft.configText, {
apiBaseUrl: normalizedValues.apiBaseUrl,
model: normalizedValues.model,
geminiApiKey: normalizedValues.geminiApiKey,
googleApiKey: normalizedValues.googleApiKey,
googleCloudProject: normalizedValues.googleCloudProject,
googleCloudLocation: normalizedValues.googleCloudLocation,
googleApplicationCredentials:
normalizedValues.googleApplicationCredentials,
})
if (nextConfig.recoveredFromInvalid) {
toast.warning(t("warnings.nativeJsonRecoveredStructured"))
}
setConfigErrors((prev) => ({
...prev,
[selectedAgent.agent_type]: null,
}))
updateSelectedDraft((current) => {
const nextEnvText = patchGeminiEnvText(current.envText, {
apiBaseUrl: normalizedValues.apiBaseUrl,
model: normalizedValues.model,
geminiApiKey: normalizedValues.geminiApiKey,
googleApiKey: normalizedValues.googleApiKey,
googleCloudProject: normalizedValues.googleCloudProject,
googleCloudLocation: normalizedValues.googleCloudLocation,
googleApplicationCredentials:
normalizedValues.googleApplicationCredentials,
})
return {
...current,
apiBaseUrl: normalizedValues.apiBaseUrl,
model: normalizedValues.model,
apiKey:
normalizedValues.geminiApiKey || normalizedValues.googleApiKey,
geminiAuthMode: normalizedValues.authMode,
geminiApiKey: normalizedValues.geminiApiKey,
googleApiKey: normalizedValues.googleApiKey,
googleCloudProject: normalizedValues.googleCloudProject,
googleCloudLocation: normalizedValues.googleCloudLocation,
googleApplicationCredentials:
normalizedValues.googleApplicationCredentials,
envText: nextEnvText,
configText: nextConfig.configText,
}
})
},
[selectedAgent, selectedDraft, t, updateSelectedDraft]
)
const handleGeminiAuthModeChange = useCallback(
(nextMode: GeminiAuthMode) => {
if (
!selectedAgent ||
!selectedDraft ||
selectedAgent.agent_type !== "gemini"
)
return
const patched = patchGeminiAuthMode(
{
authMode: selectedDraft.geminiAuthMode,
apiBaseUrl: selectedDraft.apiBaseUrl,
geminiApiKey: selectedDraft.geminiApiKey,
googleApiKey: selectedDraft.googleApiKey,
googleCloudProject: selectedDraft.googleCloudProject,
googleCloudLocation: selectedDraft.googleCloudLocation,
googleApplicationCredentials:
selectedDraft.googleApplicationCredentials,
model: selectedDraft.model,
},
nextMode
)
const nextConfig = patchGeminiConfigText(selectedDraft.configText, {
apiBaseUrl: patched.apiBaseUrl,
model: patched.model,
geminiApiKey: patched.geminiApiKey,
googleApiKey: patched.googleApiKey,
googleCloudProject: patched.googleCloudProject,
googleCloudLocation: patched.googleCloudLocation,
googleApplicationCredentials: patched.googleApplicationCredentials,
})
if (nextConfig.recoveredFromInvalid) {
toast.warning(t("warnings.nativeJsonRecoveredStructured"))
}
setConfigErrors((prev) => ({
...prev,
[selectedAgent.agent_type]: null,
}))
updateSelectedDraft((current) => ({
...current,
geminiAuthMode: patched.authMode,
apiBaseUrl: patched.apiBaseUrl,
apiKey: patched.geminiApiKey || patched.googleApiKey,
geminiApiKey: patched.geminiApiKey,
googleApiKey: patched.googleApiKey,
googleCloudProject: patched.googleCloudProject,
googleCloudLocation: patched.googleCloudLocation,
googleApplicationCredentials: patched.googleApplicationCredentials,
envText: patchGeminiEnvText(current.envText, {
apiBaseUrl: patched.apiBaseUrl,
model: patched.model,
geminiApiKey: patched.geminiApiKey,
googleApiKey: patched.googleApiKey,
googleCloudProject: patched.googleCloudProject,
googleCloudLocation: patched.googleCloudLocation,
googleApplicationCredentials: patched.googleApplicationCredentials,
}),
configText: nextConfig.configText,
}))
},
[selectedAgent, selectedDraft, t, updateSelectedDraft]
)
const handleOpenClawFieldChange = useCallback(
(
key: "openClawGatewayUrl" | "openClawGatewayToken" | "openClawSessionKey",
value: string
) => {
if (
!selectedAgent ||
!selectedDraft ||
selectedAgent.agent_type !== "open_claw"
)
return
const envKeyMap: Record<string, string> = {
openClawGatewayUrl: OPENCLAW_ENV_KEYS.gatewayUrl,
openClawGatewayToken: OPENCLAW_ENV_KEYS.gatewayToken,
openClawSessionKey: OPENCLAW_ENV_KEYS.sessionKey,
}
updateSelectedDraft((current) => ({
...current,
[key]: value,
envText: patchEnvText(current.envText, {
[envKeyMap[key]]: value,
}),
}))
},
[selectedAgent, selectedDraft, updateSelectedDraft]
)
const handleOpenCodeConfigPatch = useCallback(
(mutator: (config: Record<string, unknown>) => void) => {
if (
!selectedAgent ||
!selectedDraft ||
selectedAgent.agent_type !== "open_code"
)
return
const nextConfig = patchOpenCodeConfigText(
selectedDraft.configText,
mutator
)
if (nextConfig.recoveredFromInvalid) {
toast.warning(t("warnings.nativeJsonRecoveredOpenCode"))
}
setConfigErrors((prev) => ({
...prev,
[selectedAgent.agent_type]: null,
}))
const parsed = extractOpenCodeConfigValues(
nextConfig.configText,
selectedDraft.openCodeAuthJsonText
)
updateSelectedDraft((current) => ({
...current,
configText: nextConfig.configText,
model: parsed.model,
}))
},
[selectedAgent, selectedDraft, t, updateSelectedDraft]
)
const handleOpenCodeFieldChange = useCallback(
(key: "model" | "small_model", value: string) => {
handleOpenCodeConfigPatch((config) => {
const trimmed = value.trim()
if (!trimmed) {
delete config[key]
return
}
config[key] = trimmed
})
},
[handleOpenCodeConfigPatch]
)
const handleOpenCodeAddProvider = useCallback(() => {
if (!selectedOpenCodeConfig) return
const providerId = openCodeNewProviderId.trim()
if (!providerId) return
if (!/^[A-Za-z0-9_.-]+$/.test(providerId)) {
toast.error(t("errors.providerIdPattern"))
return
}
if (selectedOpenCodeConfig.providerIds.includes(providerId)) {
toast.error(t("errors.providerExists", { providerId }))
return
}
handleOpenCodeConfigPatch((config) => {
const providerRoot = asObjectRecord(config.provider) ?? {}
if (!asObjectRecord(config.provider)) {
config.provider = providerRoot
}
providerRoot[providerId] = {
options: {},
models: {},
}
})
setOpenCodeProviderId(providerId)
setOpenCodeNewProviderId("")
}, [
handleOpenCodeConfigPatch,
openCodeNewProviderId,
selectedOpenCodeConfig,
t,
])
const handleOpenCodeRemoveProvider = useCallback(
(providerId: string) => {
if (
!selectedAgent ||
!selectedDraft ||
selectedAgent.agent_type !== "open_code"
) {
return null
}
const targetId = providerId.trim()
if (!targetId) return null
const nextConfig = patchOpenCodeConfigText(
selectedDraft.configText,
(config) => {
const providerRoot = asObjectRecord(config.provider)
if (providerRoot) {
delete providerRoot[targetId]
if (Object.keys(providerRoot).length === 0) {
delete config.provider
}
}
const enabledProviders = Array.isArray(config.enabled_providers)
? config.enabled_providers
.filter((item): item is string => typeof item === "string")
.filter((item) => item !== targetId)
: []
if (enabledProviders.length > 0) {
config.enabled_providers = enabledProviders
} else {
delete config.enabled_providers
}
const disabledProviders = Array.isArray(config.disabled_providers)
? config.disabled_providers
.filter((item): item is string => typeof item === "string")
.filter((item) => item !== targetId)
: []
if (disabledProviders.length > 0) {
config.disabled_providers = disabledProviders
} else {
delete config.disabled_providers
}
}
)
if (nextConfig.recoveredFromInvalid) {
toast.warning(t("warnings.nativeJsonRecoveredOpenCode"))
}
const nextAuth = patchOpenCodeAuthJsonText(
selectedDraft.openCodeAuthJsonText,
(authObject) => {
delete authObject[targetId]
}
)
if (nextAuth.recoveredFromInvalid) {
toast.warning(t("warnings.openCodeAuthRecovered"))
}
const nextOpenCode = extractOpenCodeConfigValues(
nextConfig.configText,
nextAuth.authJsonText
)
const nextDraft = {
...selectedDraft,
configText: nextConfig.configText,
openCodeAuthJsonText: nextAuth.authJsonText,
model: nextOpenCode.model,
}
setConfigErrors((prev) => ({
...prev,
[selectedAgent.agent_type]: null,
}))
setDrafts((prev) => ({
...prev,
[selectedAgent.agent_type]: nextDraft,
}))
setOpenCodeProviderId((current) => (current === targetId ? "" : current))
setOpenCodeNewModelIds((prev) => {
if (typeof prev[targetId] === "undefined") return prev
const next = { ...prev }
delete next[targetId]
return next
})
setOpenCodeModelConfigExpanded((prev) => {
if (typeof prev[targetId] === "undefined") return prev
const next = { ...prev }
delete next[targetId]
return next
})
setOpenCodeModelIdDrafts((prev) => {
const prefix = `${targetId}:`
const keys = Object.keys(prev).filter((key) => key.startsWith(prefix))
if (keys.length === 0) return prev
const next = { ...prev }
for (const key of keys) {
delete next[key]
}
return next
})
return {
enabled: nextDraft.enabled,
envText: nextDraft.envText,
configText: nextDraft.configText,
openCodeAuthJsonText: nextDraft.openCodeAuthJsonText,
}
},
[selectedAgent, selectedDraft, t]
)
const confirmOpenCodeProviderDelete = useCallback(() => {
const providerId = openCodeDeleteProviderId?.trim()
if (!providerId) return
const removed = handleOpenCodeRemoveProvider(providerId)
setOpenCodeDeleteProviderId(null)
if (
!removed ||
!selectedAgent ||
selectedAgent.agent_type !== "open_code"
) {
return
}
persistPreferences(
selectedAgent.agent_type,
removed.enabled,
removed.envText,
removed.configText,
{
openCodeAuthJsonText: removed.openCodeAuthJsonText,
}
)
.then(() => {
toast.success(t("toasts.providerDeleted", { providerId }), {
description: t("toasts.openCodeConfigSynced"),
})
})
.catch((err) => {
console.error("[Settings] remove opencode provider failed:", err)
const message = err instanceof Error ? err.message : String(err)
toast.error(t("toasts.providerDeleteFailed", { providerId }), {
description: message,
})
})
}, [
handleOpenCodeRemoveProvider,
openCodeDeleteProviderId,
persistPreferences,
selectedAgent,
t,
])
const handleOpenCodeProviderStatusChange = useCallback(
(providerId: string, enabled: boolean) => {
const targetId = providerId.trim()
if (!targetId) return
handleOpenCodeConfigPatch((config) => {
const enabledProviders = Array.isArray(config.enabled_providers)
? config.enabled_providers
.filter((item): item is string => typeof item === "string")
.map((item) => item.trim())
.filter(Boolean)
: []
const disabledProviders = Array.isArray(config.disabled_providers)
? config.disabled_providers
.filter((item): item is string => typeof item === "string")
.map((item) => item.trim())
.filter(Boolean)
: []
const nextEnabled = new Set(enabledProviders)
const nextDisabled = new Set(disabledProviders)
if (enabled) {
nextEnabled.add(targetId)
nextDisabled.delete(targetId)
} else {
nextDisabled.add(targetId)
nextEnabled.delete(targetId)
}
const enabledArray = Array.from(nextEnabled)
const disabledArray = Array.from(nextDisabled)
if (enabledArray.length > 0) {
config.enabled_providers = enabledArray
} else {
delete config.enabled_providers
}
if (disabledArray.length > 0) {
config.disabled_providers = disabledArray
} else {
delete config.disabled_providers
}
})
},
[handleOpenCodeConfigPatch]
)
const handleOpenCodeProviderFieldChange = useCallback(
(
providerId: string,
key: "name" | "api" | "npm" | "baseURL" | "apiKey",
value: string
) => {
const targetId = providerId.trim()
if (!targetId) return
handleOpenCodeConfigPatch((config) => {
const providerRoot = asObjectRecord(config.provider) ?? {}
if (!asObjectRecord(config.provider)) {
config.provider = providerRoot
}
const currentProvider = asObjectRecord(providerRoot[targetId]) ?? {}
if (!asObjectRecord(providerRoot[targetId])) {
providerRoot[targetId] = currentProvider
}
const trimmed = value.trim()
if (key === "baseURL" || key === "apiKey") {
const options = asObjectRecord(currentProvider.options) ?? {}
if (!asObjectRecord(currentProvider.options)) {
currentProvider.options = options
}
if (trimmed) {
options[key] = trimmed
} else {
delete options[key]
}
if (Object.keys(options).length === 0) {
delete currentProvider.options
}
return
}
if (trimmed) {
currentProvider[key] = trimmed
} else {
delete currentProvider[key]
}
})
if (key === "apiKey" && selectedDraft) {
const nextAuth = patchOpenCodeAuthJsonText(
selectedDraft.openCodeAuthJsonText,
(authObject) => {
const entry = asObjectRecord(authObject[targetId]) ?? {}
if (!asObjectRecord(authObject[targetId])) {
authObject[targetId] = entry
}
const trimmed = value.trim()
if (!trimmed) {
delete entry.key
if (entry.type === "api") delete entry.type
if (Object.keys(entry).length === 0) {
delete authObject[targetId]
}
return
}
entry.type = "api"
entry.key = trimmed
}
)
updateSelectedDraft((current) => ({
...current,
openCodeAuthJsonText: nextAuth.authJsonText,
}))
}
},
[handleOpenCodeConfigPatch, selectedDraft, updateSelectedDraft]
)
const handleOpenCodeModelDraftChange = useCallback(
(providerId: string, value: string) => {
const targetId = providerId.trim()
if (!targetId) return
setOpenCodeNewModelIds((prev) => ({
...prev,
[targetId]: value,
}))
},
[]
)
const handleOpenCodeAddModel = useCallback(
(providerId: string) => {
const targetProviderId = providerId.trim()
if (!targetProviderId || !selectedOpenCodeConfig) return
const nextModelId = (openCodeNewModelIds[targetProviderId] ?? "").trim()
if (!nextModelId) return
const targetProvider = selectedOpenCodeConfig.providers[targetProviderId]
if (!targetProvider) return
if (targetProvider.modelIds.includes(nextModelId)) {
toast.error(t("errors.modelExists", { modelId: nextModelId }))
return
}
handleOpenCodeConfigPatch((config) => {
const providerRoot = asObjectRecord(config.provider) ?? {}
if (!asObjectRecord(config.provider)) {
config.provider = providerRoot
}
const currentProvider =
asObjectRecord(providerRoot[targetProviderId]) ?? {}
if (!asObjectRecord(providerRoot[targetProviderId])) {
providerRoot[targetProviderId] = currentProvider
}
const modelsRoot = asObjectRecord(currentProvider.models) ?? {}
if (!asObjectRecord(currentProvider.models)) {
currentProvider.models = modelsRoot
}
modelsRoot[nextModelId] = {
name: nextModelId,
}
})
setOpenCodeNewModelIds((prev) => ({
...prev,
[targetProviderId]: "",
}))
},
[handleOpenCodeConfigPatch, openCodeNewModelIds, selectedOpenCodeConfig, t]
)
const handleOpenCodeRemoveModel = useCallback(
(providerId: string, modelId: string) => {
const targetProviderId = providerId.trim()
const targetModelId = modelId.trim()
if (!targetProviderId || !targetModelId) return
handleOpenCodeConfigPatch((config) => {
const providerRoot = asObjectRecord(config.provider)
if (!providerRoot) return
const currentProvider = asObjectRecord(providerRoot[targetProviderId])
if (!currentProvider) return
const modelsRoot = asObjectRecord(currentProvider.models)
if (!modelsRoot) return
delete modelsRoot[targetModelId]
if (Object.keys(modelsRoot).length === 0) {
delete currentProvider.models
}
})
const draftKey = `${targetProviderId}:${targetModelId}`
setOpenCodeModelIdDrafts((prev) => {
if (typeof prev[draftKey] === "undefined") return prev
const next = { ...prev }
delete next[draftKey]
return next
})
},
[handleOpenCodeConfigPatch]
)
const handleOpenCodeModelIdDraftChange = useCallback(
(providerId: string, modelId: string, value: string) => {
const targetProviderId = providerId.trim()
const targetModelId = modelId.trim()
if (!targetProviderId || !targetModelId) return
const draftKey = `${targetProviderId}:${targetModelId}`
setOpenCodeModelIdDrafts((prev) => ({
...prev,
[draftKey]: value,
}))
},
[]
)
const handleOpenCodeModelIdCommit = useCallback(
(providerId: string, modelId: string) => {
const targetProviderId = providerId.trim()
const targetModelId = modelId.trim()
if (!targetProviderId || !targetModelId || !selectedOpenCodeConfig) return
const draftKey = `${targetProviderId}:${targetModelId}`
const rawDraft = openCodeModelIdDrafts[draftKey]
if (typeof rawDraft !== "string") return
const nextModelId = rawDraft.trim()
if (!nextModelId || nextModelId === targetModelId) {
setOpenCodeModelIdDrafts((prev) => {
const next = { ...prev }
delete next[draftKey]
return next
})
return
}
if (!/^[A-Za-z0-9_.:-]+$/.test(nextModelId)) {
toast.error(t("errors.modelIdPattern"))
return
}
const targetProvider = selectedOpenCodeConfig.providers[targetProviderId]
if (!targetProvider) return
if (targetProvider.modelIds.includes(nextModelId)) {
toast.error(t("errors.modelExists", { modelId: nextModelId }))
return
}
handleOpenCodeConfigPatch((config) => {
const providerRoot = asObjectRecord(config.provider) ?? {}
if (!asObjectRecord(config.provider)) {
config.provider = providerRoot
}
const currentProvider =
asObjectRecord(providerRoot[targetProviderId]) ?? {}
if (!asObjectRecord(providerRoot[targetProviderId])) {
providerRoot[targetProviderId] = currentProvider
}
const modelsRoot = asObjectRecord(currentProvider.models) ?? {}
if (!asObjectRecord(currentProvider.models)) {
currentProvider.models = modelsRoot
}
const currentModel = asObjectRecord(modelsRoot[targetModelId]) ?? {}
if (!asObjectRecord(modelsRoot[targetModelId])) return
delete currentModel.id
modelsRoot[nextModelId] = currentModel
delete modelsRoot[targetModelId]
})
setOpenCodeModelIdDrafts((prev) => {
const next = { ...prev }
delete next[draftKey]
return next
})
},
[
handleOpenCodeConfigPatch,
openCodeModelIdDrafts,
selectedOpenCodeConfig,
t,
]
)
const handleOpenCodeModelFieldChange = useCallback(
(providerId: string, modelId: string, value: string) => {
const targetProviderId = providerId.trim()
const targetModelId = modelId.trim()
if (!targetProviderId || !targetModelId) return
handleOpenCodeConfigPatch((config) => {
const providerRoot = asObjectRecord(config.provider) ?? {}
if (!asObjectRecord(config.provider)) {
config.provider = providerRoot
}
const currentProvider =
asObjectRecord(providerRoot[targetProviderId]) ?? {}
if (!asObjectRecord(providerRoot[targetProviderId])) {
providerRoot[targetProviderId] = currentProvider
}
const modelsRoot = asObjectRecord(currentProvider.models) ?? {}
if (!asObjectRecord(currentProvider.models)) {
currentProvider.models = modelsRoot
}
const currentModel = asObjectRecord(modelsRoot[targetModelId]) ?? {}
if (!asObjectRecord(modelsRoot[targetModelId])) {
modelsRoot[targetModelId] = currentModel
}
const trimmed = value.trim()
if (trimmed) {
currentModel.name = trimmed
} else {
delete currentModel.name
}
// Cleanup legacy schema written by earlier versions.
delete currentModel.id
})
},
[handleOpenCodeConfigPatch]
)
const handleCodexAuthJsonTextChange = useCallback(
(nextText: string) => {
if (!selectedAgent || selectedAgent.agent_type !== "codex") return
const important = extractCodexImportantValues(
nextText,
selectedDraft?.codexConfigTomlText ?? ""
)
updateSelectedDraft((current) => ({
...current,
codexAuthMode: inferCodexAuthMode(nextText),
codexAuthJsonText: nextText,
apiBaseUrl: important.apiBaseUrl,
apiKey: important.apiKey ?? current.apiKey,
model: important.model,
codexModelProvider: important.modelProvider,
codexProviderOptions: important.providerOptions,
codexReasoningEffort: important.reasoningEffort,
codexSupportsWebsockets: important.supportsWebsockets,
}))
},
[selectedAgent, selectedDraft, updateSelectedDraft]
)
const handleCodexConfigTomlTextChange = useCallback(
(nextText: string) => {
if (!selectedAgent || selectedAgent.agent_type !== "codex") return
const important = extractCodexImportantValues(
selectedDraft?.codexAuthJsonText ?? "",
nextText
)
updateSelectedDraft((current) => ({
...current,
codexConfigTomlText: nextText,
apiBaseUrl: important.apiBaseUrl,
apiKey: important.apiKey ?? current.apiKey,
model: important.model,
codexModelProvider: important.modelProvider,
codexProviderOptions: important.providerOptions,
codexReasoningEffort: important.reasoningEffort,
codexSupportsWebsockets: important.supportsWebsockets,
}))
},
[selectedAgent, selectedDraft, updateSelectedDraft]
)
const handleCodexModelProviderChange = useCallback(
(nextProvider: string) => {
if (
!selectedAgent ||
!selectedDraft ||
selectedAgent.agent_type !== "codex"
)
return
const trimmedProvider = nextProvider.trim()
if (!trimmedProvider) return
const nextToml = patchCodexConfigTomlText(
selectedDraft.codexConfigTomlText,
{
modelProvider: trimmedProvider,
modelReasoningEffort: selectedDraft.codexReasoningEffort,
}
)
const synced = extractCodexImportantValues(
selectedDraft.codexAuthJsonText,
nextToml
)
updateSelectedDraft((current) => ({
...current,
apiBaseUrl: synced.apiBaseUrl,
apiKey: synced.apiKey ?? current.apiKey,
model: synced.model,
codexModelProvider: synced.modelProvider,
codexProviderOptions: synced.providerOptions,
codexReasoningEffort: synced.reasoningEffort,
codexSupportsWebsockets: synced.supportsWebsockets,
codexConfigTomlText: nextToml,
}))
},
[selectedAgent, selectedDraft, updateSelectedDraft]
)
const handleCodexAuthModeChange = useCallback(
(nextMode: CodexAuthMode) => {
if (
!selectedAgent ||
!selectedDraft ||
selectedAgent.agent_type !== "codex"
)
return
const nextAuthJsonText =
nextMode === "chatgpt_subscription"
? "{}"
: JSON.stringify({ OPENAI_API_KEY: "" }, null, 2)
const nextConfigTomlText =
nextMode === "chatgpt_subscription"
? ""
: selectedDraft.codexConfigTomlText
const nextEnvText =
nextMode === "chatgpt_subscription"
? patchEnvText(selectedDraft.envText, {
OPENAI_API_KEY: "",
OPENAI_BASE_URL: "",
})
: selectedDraft.envText
const synced = extractCodexImportantValues(
nextAuthJsonText,
nextConfigTomlText
)
updateSelectedDraft((current) => ({
...current,
codexAuthMode: nextMode,
codexAuthJsonText: nextAuthJsonText,
codexConfigTomlText: nextConfigTomlText,
envText: nextEnvText,
apiBaseUrl:
nextMode === "chatgpt_subscription" ? "" : synced.apiBaseUrl,
apiKey:
nextMode === "chatgpt_subscription" ? "" : (synced.apiKey ?? ""),
model: synced.model,
codexModelProvider: synced.modelProvider,
codexProviderOptions: synced.providerOptions,
codexReasoningEffort: synced.reasoningEffort,
codexSupportsWebsockets: synced.supportsWebsockets,
}))
},
[selectedAgent, selectedDraft, updateSelectedDraft]
)
const handleCodexImportantConfigChange = useCallback(
(
key: "apiBaseUrl" | "apiKey" | "model" | "reasoningEffort",
value: string
) => {
if (
!selectedAgent ||
!selectedDraft ||
selectedAgent.agent_type !== "codex"
)
return
const nextAuth =
key === "apiKey"
? patchCodexAuthJsonText(selectedDraft.codexAuthJsonText, {
apiKey: value,
})
: {
authJsonText: selectedDraft.codexAuthJsonText,
recoveredFromInvalid: false,
}
const nextToml =
key === "apiBaseUrl"
? patchCodexConfigTomlText(selectedDraft.codexConfigTomlText, {
apiBaseUrl: value,
modelProvider: selectedDraft.codexModelProvider,
modelReasoningEffort: selectedDraft.codexReasoningEffort,
})
: key === "model"
? patchCodexConfigTomlText(selectedDraft.codexConfigTomlText, {
model: value,
modelReasoningEffort: selectedDraft.codexReasoningEffort,
})
: key === "reasoningEffort"
? patchCodexConfigTomlText(selectedDraft.codexConfigTomlText, {
modelReasoningEffort: value,
})
: selectedDraft.codexConfigTomlText
if (nextAuth.recoveredFromInvalid) {
toast.warning(t("warnings.authRecoveredStructured"))
}
const synced = extractCodexImportantValues(
nextAuth.authJsonText,
nextToml
)
updateSelectedDraft((current) => ({
...(key === "reasoningEffort"
? {
...current,
codexReasoningEffort:
normalizeCodexReasoningEffort(value) ??
CODEX_DEFAULT_REASONING_EFFORT,
}
: applyImportantFieldToDraft(current, key, value)),
apiBaseUrl: synced.apiBaseUrl,
apiKey: synced.apiKey ?? current.apiKey,
model: synced.model,
codexModelProvider: synced.modelProvider,
codexProviderOptions: synced.providerOptions,
codexReasoningEffort: synced.reasoningEffort,
codexSupportsWebsockets: synced.supportsWebsockets,
codexAuthJsonText: nextAuth.authJsonText,
codexConfigTomlText: nextToml,
}))
},
[selectedAgent, selectedDraft, t, updateSelectedDraft]
)
const handleCodexSupportsWebsocketsChange = useCallback(
(enabled: boolean) => {
if (
!selectedAgent ||
!selectedDraft ||
selectedAgent.agent_type !== "codex"
)
return
const nextToml = patchCodexConfigTomlText(
selectedDraft.codexConfigTomlText,
{
modelProvider: selectedDraft.codexModelProvider,
supportsWebsockets: enabled,
}
)
const synced = extractCodexImportantValues(
selectedDraft.codexAuthJsonText,
nextToml
)
updateSelectedDraft((current) => ({
...current,
apiBaseUrl: synced.apiBaseUrl,
apiKey: synced.apiKey ?? current.apiKey,
model: synced.model,
codexModelProvider: synced.modelProvider,
codexProviderOptions: synced.providerOptions,
codexReasoningEffort: synced.reasoningEffort,
codexSupportsWebsockets: synced.supportsWebsockets,
codexConfigTomlText: nextToml,
}))
},
[selectedAgent, selectedDraft, updateSelectedDraft]
)
if (loadingAgents) {
return (
<div className="h-full flex items-center justify-center text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
{t("loadingAgents")}
</div>
)
}
return (
<div className="h-full flex flex-col">
<div className="flex items-center justify-between gap-3 pb-4">
<div>
<h2 className="text-base font-semibold">{t("title")}</h2>
<p className="text-xs text-muted-foreground mt-1">
{t("description")}
</p>
</div>
</div>
{loadingError && (
<div className="mb-3 rounded-md border border-red-500/30 bg-red-500/5 px-3 py-2 text-xs text-red-400">
{loadingError}
</div>
)}
<div className="flex-1 min-h-0 grid gap-3 lg:grid-cols-[minmax(240px,320px)_1fr]">
<div className="min-h-0 min-w-0 rounded-lg border bg-card flex flex-col overflow-hidden">
<div className="border-b px-3 py-2 text-xs font-medium text-muted-foreground">
{t("agentList")}
</div>
<Reorder.Group
as="div"
axis="y"
values={sortedAgents}
onReorder={handleReorder}
ref={agentListRef}
className="flex-1 min-h-0 overflow-y-auto space-y-2 p-2"
>
{sortedAgents.map((agent) => {
const current = checkState[agent.agent_type]
const isChecking = Boolean(checking[agent.agent_type])
const draft = drafts[agent.agent_type] ?? buildAgentDraft(agent)
const allChecks = getAgentChecks(agent, current)
const summary = summarizeChecks(allChecks)
const displaySummary: CheckStatus | "unchecked" | "checking" =
isChecking ? "checking" : summary
const statusLabel =
displaySummary === "unchecked"
? t("status.unchecked")
: displaySummary === "checking"
? "Checking"
: displaySummary.toUpperCase()
const statusToneClass = !draft.enabled
? "border-muted-foreground/30 bg-muted/30 text-muted-foreground"
: displaySummary === "pass"
? "border-green-500/40 bg-green-500/10 text-green-600 dark:text-green-400"
: displaySummary === "fail"
? "border-red-500/40 bg-red-500/10 text-red-500"
: displaySummary === "warn"
? "border-yellow-500/40 bg-yellow-500/10 text-yellow-600 dark:text-yellow-400"
: displaySummary === "checking"
? "border-blue-500/40 bg-blue-500/10 text-blue-600 dark:text-blue-400"
: "border-muted-foreground/30 bg-muted/30 text-muted-foreground"
return (
<AgentReorderItem
key={agent.agent_type}
agent={agent}
selected={selectedAgentType === agent.agent_type}
reordering={reordering}
dragging={dragging}
onDragStart={(agentType) => {
setDragging(agentType)
}}
onDragEnd={() => {
const order = pendingOrderRef.current
pendingOrderRef.current = null
setDragging(null)
if (order && !reordering) {
persistReorder(order).catch((err) => {
console.error("[Settings] reorder agents failed:", err)
})
}
}}
onSelect={(agentType) => {
setSelectedAgentType(agentType)
}}
>
{(startDrag) => (
<div className="flex items-center justify-between gap-2 overflow-hidden">
<div className="min-w-0 flex items-center gap-2">
<button
type="button"
className="text-muted-foreground cursor-grab active:cursor-grabbing rounded p-0.5 hover:bg-muted"
title={t("actions.dragSort")}
aria-label={t("actions.dragSortAgent", {
name: agent.name,
})}
onPointerDown={startDrag}
onClick={(event) => {
event.stopPropagation()
}}
disabled={reordering}
>
<GripVertical className="h-3.5 w-3.5" />
</button>
<AgentIcon
agentType={agent.agent_type}
className="h-4 w-4"
/>
<span className="text-sm font-medium truncate">
{agent.name}
</span>
{draft.enabled && (
<span
className="h-2 w-2 rounded-full bg-emerald-500 shrink-0"
aria-label={t("status.agentEnabledAria", {
name: agent.name,
})}
title={t("status.enabled")}
/>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
<Badge
variant="outline"
className={cn(
"h-6 px-2 inline-flex items-center gap-1 text-xs leading-none",
statusToneClass
)}
>
<span>{statusLabel}</span>
{displaySummary === "checking" && (
<Loader2 className="h-3.5 w-3.5 animate-spin shrink-0" />
)}
{!isChecking && (
<button
type="button"
className="inline-flex h-4 w-4 items-center justify-center rounded hover:bg-black/10 dark:hover:bg-white/10"
title={t("actions.refreshCheck")}
aria-label={t("actions.refreshCheckAgent", {
name: agent.name,
})}
onClick={(event) => {
event.stopPropagation()
runPreflight(agent.agent_type, true).catch(
(err) => {
console.error(
"[Settings] single preflight failed:",
err
)
}
)
}}
>
<RefreshCw className="h-3 w-3 shrink-0" />
</button>
)}
</Badge>
</div>
</div>
)}
</AgentReorderItem>
)
})}
</Reorder.Group>
</div>
<div className="min-h-0 min-w-0 rounded-lg border bg-card">
{selectedAgent && selectedDraft ? (
<div className="h-full flex flex-col">
<div className="border-b px-4 py-3">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0 flex items-center gap-2">
<AgentIcon
agentType={selectedAgent.agent_type}
className="h-5 w-5"
/>
<h3 className="text-sm font-semibold truncate">
{selectedAgent.name}
</h3>
<Badge variant="outline" className="shrink-0">
{selectedAgent.distribution_type}
</Badge>
</div>
<div className="flex items-center shrink-0">
<button
type="button"
role="switch"
aria-checked={selectedDraft.enabled}
aria-label={t("status.agentEnabledSwitch", {
name: selectedAgent.name,
})}
title={
selectedDraft.enabled
? t("actions.clickDisable", {
name: selectedAgent.name,
})
: t("actions.clickEnable", {
name: selectedAgent.name,
})
}
disabled={selectedIsSaving}
className={cn(
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors",
selectedDraft.enabled
? "bg-primary"
: "bg-muted-foreground/30",
selectedIsSaving && "cursor-not-allowed opacity-60"
)}
onClick={() => {
const nextEnabled = !selectedDraft.enabled
const nextDraft = {
...selectedDraft,
enabled: nextEnabled,
}
setDrafts((prev) => ({
...prev,
[selectedAgent.agent_type]: nextDraft,
}))
persistPreferences(
selectedAgent.agent_type,
nextEnabled,
nextDraft.envText,
nextDraft.configText,
selectedAgent.agent_type === "open_code"
? {
openCodeAuthJsonText:
nextDraft.openCodeAuthJsonText,
}
: undefined
).catch((err) => {
console.error(
"[Settings] persist enabled failed:",
err
)
const message =
err instanceof Error ? err.message : String(err)
toast.error(t("toasts.saveAgentSwitchFailed"), {
description: message,
})
})
}}
>
<span
className={cn(
"inline-block h-4 w-4 rounded-full bg-background shadow-sm transition-transform",
selectedDraft.enabled
? "translate-x-4"
: "translate-x-0.5"
)}
/>
</button>
</div>
</div>
<p className="mt-2 text-xs text-muted-foreground">
{selectedAgent.description}
</p>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-4">
<div className="space-y-2">
{selectedCurrent?.error && (
<div className="rounded-md border border-red-500/30 bg-red-500/5 px-3 py-2 text-xs text-red-400 flex items-start gap-2">
<AlertCircle className="h-3.5 w-3.5 mt-0.5 shrink-0" />
<span className="break-all">{selectedCurrent.error}</span>
</div>
)}
<div className="text-[11px] text-muted-foreground flex items-center gap-1">
<CheckCircle2 className="h-3 w-3" />
{t("preflight.count", { count: selectedChecks.length })}
</div>
{selectedChecks.length > 0 ? (
selectedChecks.map((check) =>
renderCheck(selectedAgent, check)
)
) : (
<div className="text-xs text-muted-foreground">
{t("preflight.notRun")}
</div>
)}
</div>
<div className="space-y-2">
<label className="text-xs font-medium">{t("envVars")}</label>
<div className="relative group">
<Textarea
value={selectedDraft.envText}
onChange={(event) => {
updateSelectedDraft((current) => ({
...current,
envText: event.target.value,
}))
}}
placeholder={"KEY1=VALUE1\nKEY2=VALUE2"}
className="min-h-24"
/>
<div className="pointer-events-none absolute inset-0 rounded-md bg-background/10 backdrop-blur-[3px] transition-opacity duration-200 group-focus-within:opacity-0" />
</div>
<div className="flex justify-end">
<Button
size="sm"
onClick={() => {
persistPreferences(
selectedAgent.agent_type,
selectedDraft.enabled,
selectedDraft.envText,
selectedDraft.configText,
selectedAgent.agent_type === "open_code"
? {
openCodeAuthJsonText:
selectedDraft.openCodeAuthJsonText,
}
: undefined
).catch((err) => {
console.error(
"[Settings] save preferences failed:",
err
)
const message =
err instanceof Error ? err.message : String(err)
toast.error(t("toasts.saveEnvFailed"), {
description: message,
})
})
}}
disabled={selectedIsSaving}
>
{selectedIsSaving ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
{t("actions.saving")}
</>
) : (
<>
<Save className="h-3.5 w-3.5" />
{t("actions.saveEnvVars")}
</>
)}
</Button>
</div>
</div>
{selectedAgent.agent_type === "codex" ? (
<div className="space-y-3 rounded-md border bg-muted/10 p-3">
<div>
<label className="text-xs font-medium">
{t("configManagement")}
</label>
<p className="mt-1 text-[11px] text-muted-foreground">
{t("codex.configDescription")}
</p>
</div>
<div className="space-y-1.5">
<label className="text-[11px] text-muted-foreground">
{t("codex.authMode")}
</label>
<Select
value={selectedDraft.codexAuthMode}
onValueChange={(value) => {
if (
CODEX_AUTH_MODES.includes(value as CodexAuthMode)
) {
handleCodexAuthModeChange(value as CodexAuthMode)
}
}}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent align="start">
{CODEX_AUTH_MODES.map((mode) => (
<SelectItem key={mode} value={mode}>
{mode === "chatgpt_subscription"
? t("codex.chatgptSubscription")
: "API Key"}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-[11px] text-muted-foreground">
{selectedDraft.codexAuthMode === "chatgpt_subscription"
? t("codex.chatgptSubscriptionHint")
: t("codex.apiKeyHint")}
</p>
</div>
{selectedDraft.codexAuthMode !== "chatgpt_subscription" && (
<div className="space-y-1.5">
<label className="text-[11px] text-muted-foreground">
Provider
</label>
<Select
value={selectedDraft.codexModelProvider}
onValueChange={handleCodexModelProviderChange}
>
<SelectTrigger className="w-full">
<SelectValue
placeholder={t("codex.selectProvider")}
/>
</SelectTrigger>
<SelectContent align="start">
{selectedDraft.codexProviderOptions.map(
(provider) => (
<SelectItem key={provider} value={provider}>
{provider}
</SelectItem>
)
)}
</SelectContent>
</Select>
</div>
)}
{selectedDraft.codexAuthMode !== "chatgpt_subscription" && (
<div className="space-y-1.5">
<label className="text-[11px] text-muted-foreground">
API URL
</label>
<Input
value={selectedDraft.apiBaseUrl}
onChange={(event) => {
handleCodexImportantConfigChange(
"apiBaseUrl",
event.target.value
)
}}
placeholder="https://api.openai.com/v1"
/>
</div>
)}
{selectedDraft.codexAuthMode !== "chatgpt_subscription" && (
<div className="space-y-1.5">
<label className="text-[11px] text-muted-foreground">
API Key
</label>
<div className="flex items-center gap-2">
<Input
type={
showApiKeys[selectedAgent.agent_type]
? "text"
: "password"
}
value={selectedDraft.apiKey}
onChange={(event) => {
handleCodexImportantConfigChange(
"apiKey",
event.target.value
)
}}
placeholder="sk-..."
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setShowApiKeys((prev) => ({
...prev,
[selectedAgent.agent_type]:
!prev[selectedAgent.agent_type],
}))
}}
title={
showApiKeys[selectedAgent.agent_type]
? t("actions.hideApiKey")
: t("actions.showApiKey")
}
>
{showApiKeys[selectedAgent.agent_type] ? (
<EyeOff className="h-3.5 w-3.5" />
) : (
<Eye className="h-3.5 w-3.5" />
)}
</Button>
</div>
</div>
)}
{selectedDraft.codexAuthMode !== "chatgpt_subscription" && (
<div className="space-y-1.5">
<label className="text-[11px] text-muted-foreground">
{t("codex.modelName")}
</label>
<Input
value={selectedDraft.model}
onChange={(event) => {
handleCodexImportantConfigChange(
"model",
event.target.value
)
}}
placeholder="gpt-5 / gpt-5-mini"
/>
</div>
)}
<div className="space-y-1.5">
<label className="text-[11px] text-muted-foreground">
Reasoning Effort
</label>
<Select
value={selectedDraft.codexReasoningEffort}
onValueChange={(nextValue) => {
handleCodexImportantConfigChange(
"reasoningEffort",
nextValue
)
}}
>
<SelectTrigger className="w-full">
<SelectValue
placeholder={t("codex.selectReasoningEffort")}
/>
</SelectTrigger>
<SelectContent align="start">
{CODEX_REASONING_EFFORT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-[11px] text-muted-foreground">
{selectedCodexReasoningEffortOption?.description ??
"Greater reasoning depth for complex problems"}
</p>
</div>
<div className="space-y-1.5">
<div className="flex items-center justify-between rounded-md border px-3 py-2">
<label className="text-[11px] text-muted-foreground">
{t("codex.enableWebsocket")}
</label>
<Switch
checked={selectedDraft.codexSupportsWebsockets}
onCheckedChange={handleCodexSupportsWebsocketsChange}
aria-label={t("codex.enableWebsocketAria")}
/>
</div>
</div>
<div className="space-y-1.5">
<label className="text-[11px] text-muted-foreground">
{t("codex.authJsonNative")}
</label>
<Textarea
value={selectedDraft.codexAuthJsonText}
onChange={(event) => {
handleCodexAuthJsonTextChange(event.target.value)
}}
placeholder={`{
"OPENAI_API_KEY": "sk-..."
}`}
className="min-h-28 font-mono text-xs"
/>
{selectedCodexAuthError && (
<div className="rounded-md border border-red-500/30 bg-red-500/5 px-2.5 py-1.5 text-[11px] text-red-400">
{selectedCodexAuthError}
</div>
)}
</div>
<div className="space-y-1.5">
<label className="text-[11px] text-muted-foreground">
{t("codex.configTomlNative")}
</label>
<Textarea
value={selectedDraft.codexConfigTomlText}
onChange={(event) => {
handleCodexConfigTomlTextChange(event.target.value)
}}
placeholder={`disable_response_storage = true
model = "gpt-5"
model_reasoning_effort = "high"
model_provider = "codeg"
[features]
responses_websockets_v2 = true
[model_providers.codeg]
base_url = "https://api.openai.com/v1"
supports_websockets = true`}
className="min-h-40 font-mono text-xs"
/>
</div>
<div className="flex justify-end">
<Button
size="sm"
onClick={() => {
const codexEnvText =
selectedDraft.codexAuthMode ===
"chatgpt_subscription"
? patchEnvText(selectedDraft.envText, {
OPENAI_API_KEY: "",
OPENAI_BASE_URL: "",
})
: selectedDraft.envText
persistPreferences(
selectedAgent.agent_type,
selectedDraft.enabled,
codexEnvText,
selectedDraft.configText,
{
codexAuthJsonText:
selectedDraft.codexAuthJsonText,
codexConfigTomlText:
selectedDraft.codexConfigTomlText,
}
)
.then(() => {
toast.success(t("toasts.codexSaved"))
})
.catch((err) => {
console.error(
"[Settings] save codex native config failed:",
err
)
const message =
err instanceof Error ? err.message : String(err)
toast.error(t("toasts.saveCodexNativeFailed"), {
description: message,
})
})
}}
disabled={selectedIsSaving}
>
{selectedIsSaving ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
{t("actions.saving")}
</>
) : (
<>
<Save className="h-3.5 w-3.5" />
{t("actions.saveCodexConfig")}
</>
)}
</Button>
</div>
</div>
) : selectedAgent.agent_type === "gemini" ? (
<div className="space-y-3 rounded-md border bg-muted/10 p-3">
<div>
<label className="text-xs font-medium">
{t("gemini.authConfig")}
</label>
<p className="mt-1 text-[11px] text-muted-foreground">
{t("gemini.authConfigDescription")}
</p>
</div>
<div className="space-y-1.5">
<label className="text-[11px] text-muted-foreground">
{t("gemini.authMode")}
</label>
<Select
value={selectedDraft.geminiAuthMode}
onValueChange={(value) => {
if (
GEMINI_AUTH_MODES.includes(value as GeminiAuthMode)
) {
handleGeminiAuthModeChange(value as GeminiAuthMode)
}
}}
>
<SelectTrigger className="w-full">
<SelectValue
placeholder={t("gemini.selectAuthMode")}
/>
</SelectTrigger>
<SelectContent align="start">
{GEMINI_AUTH_MODES.map((mode) => (
<SelectItem key={mode} value={mode}>
{geminiAuthModeLabel(mode)}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-[11px] text-muted-foreground">
{geminiAuthModeHint(selectedDraft.geminiAuthMode)}
</p>
</div>
<div className="space-y-1.5">
<label className="text-[11px] text-muted-foreground">
Model
</label>
<Input
value={selectedDraft.model}
onChange={(event) => {
handleGeminiFieldChange("model", event.target.value)
}}
placeholder="gemini-3-pro-preview"
/>
<p className="text-[11px] text-muted-foreground">
{t("modelHintDefault")}
</p>
</div>
{selectedDraft.geminiAuthMode === "custom" && (
<div className="space-y-1.5">
<label className="text-[11px] text-muted-foreground">
GOOGLE_GEMINI_BASE_URL
</label>
<Input
value={selectedDraft.apiBaseUrl}
onChange={(event) => {
handleGeminiFieldChange(
"apiBaseUrl",
event.target.value
)
}}
placeholder="https://your-gemini-endpoint.example.com"
/>
</div>
)}
{(selectedDraft.geminiAuthMode === "custom" ||
selectedDraft.geminiAuthMode === "gemini_api_key" ||
selectedDraft.geminiAuthMode === "vertex_api_key") && (
<div className="space-y-1.5">
<label className="text-[11px] text-muted-foreground">
{selectedDraft.geminiAuthMode === "vertex_api_key"
? "GOOGLE_API_KEY"
: "GEMINI_API_KEY"}
</label>
<div className="flex items-center gap-2">
<Input
type={
showApiKeys[selectedAgent.agent_type]
? "text"
: "password"
}
value={
selectedDraft.geminiAuthMode === "vertex_api_key"
? selectedDraft.googleApiKey
: selectedDraft.geminiApiKey
}
onChange={(event) => {
if (
selectedDraft.geminiAuthMode ===
"vertex_api_key"
) {
handleGeminiFieldChange(
"googleApiKey",
event.target.value
)
return
}
handleGeminiFieldChange(
"geminiApiKey",
event.target.value
)
}}
placeholder="AIza..."
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setShowApiKeys((prev) => ({
...prev,
[selectedAgent.agent_type]:
!prev[selectedAgent.agent_type],
}))
}}
title={
showApiKeys[selectedAgent.agent_type]
? t("actions.hideKey")
: t("actions.showKey")
}
>
{showApiKeys[selectedAgent.agent_type] ? (
<EyeOff className="h-3.5 w-3.5" />
) : (
<Eye className="h-3.5 w-3.5" />
)}
</Button>
</div>
</div>
)}
{(selectedDraft.geminiAuthMode === "vertex_adc" ||
selectedDraft.geminiAuthMode ===
"vertex_service_account" ||
selectedDraft.geminiAuthMode === "vertex_api_key") && (
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-1.5">
<label className="text-[11px] text-muted-foreground">
GOOGLE_CLOUD_PROJECT
</label>
<Input
value={selectedDraft.googleCloudProject}
onChange={(event) => {
handleGeminiFieldChange(
"googleCloudProject",
event.target.value
)
}}
placeholder="my-gcp-project-id"
/>
</div>
<div className="space-y-1.5">
<label className="text-[11px] text-muted-foreground">
GOOGLE_CLOUD_LOCATION
</label>
<Input
value={selectedDraft.googleCloudLocation}
onChange={(event) => {
handleGeminiFieldChange(
"googleCloudLocation",
event.target.value
)
}}
placeholder="global / us-central1"
/>
</div>
</div>
)}
{selectedDraft.geminiAuthMode ===
"vertex_service_account" && (
<div className="space-y-1.5">
<label className="text-[11px] text-muted-foreground">
GOOGLE_APPLICATION_CREDENTIALS
</label>
<Input
value={selectedDraft.googleApplicationCredentials}
onChange={(event) => {
handleGeminiFieldChange(
"googleApplicationCredentials",
event.target.value
)
}}
placeholder="/path/to/service-account.json"
/>
</div>
)}
<div className="flex items-center justify-between gap-2">
<Button
type="button"
size="sm"
variant="outline"
onClick={() => {
openUrl(
"https://geminicli.com/docs/get-started/authentication/"
).catch((err) => {
console.error(
"[Settings] open gemini auth doc failed:",
err
)
})
}}
>
{t("gemini.viewAuthDoc")}
</Button>
<Button
size="sm"
onClick={() => {
persistPreferences(
selectedAgent.agent_type,
selectedDraft.enabled,
selectedDraft.envText,
selectedDraft.configText
)
.then(() => {
toast.success(t("toasts.geminiSaved"))
})
.catch((err) => {
console.error(
"[Settings] save gemini config failed:",
err
)
const message =
err instanceof Error ? err.message : String(err)
toast.error(t("toasts.saveGeminiFailed"), {
description: message,
})
})
}}
disabled={selectedIsSaving}
>
{selectedIsSaving ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
{t("actions.saving")}
</>
) : (
<>
<Save className="h-3.5 w-3.5" />
{t("actions.saveGeminiConfig")}
</>
)}
</Button>
</div>
</div>
) : selectedAgent.agent_type === "open_code" ? (
<div className="space-y-3 rounded-md border bg-muted/10 p-3">
<div>
<label className="text-xs font-medium">
{t("openCode.configManagement")}
</label>
<p className="mt-1 text-[11px] text-muted-foreground">
{t("openCode.configDescription")}
</p>
</div>
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-1.5">
<label className="text-[11px] text-muted-foreground">
model
</label>
<Input
value={selectedOpenCodeConfig?.model ?? ""}
onChange={(event) => {
handleOpenCodeFieldChange(
"model",
event.target.value
)
}}
placeholder="google/gemini-3-pro-preview"
/>
</div>
<div className="space-y-1.5">
<label className="text-[11px] text-muted-foreground">
small_model
</label>
<Input
value={selectedOpenCodeConfig?.smallModel ?? ""}
onChange={(event) => {
handleOpenCodeFieldChange(
"small_model",
event.target.value
)
}}
placeholder="google/gemini-3-flash-preview"
/>
</div>
</div>
<div className="space-y-2 rounded-md border bg-background/60 p-3">
<div className="flex items-center justify-between gap-2">
<label className="text-[11px] font-medium">
{t("openCode.providerManagement")}
</label>
<div className="text-[11px] text-muted-foreground">
{t("openCode.providerCount", {
count:
selectedOpenCodeConfig?.providerIds.length ?? 0,
})}
</div>
</div>
<div className="flex flex-wrap gap-2">
<Input
value={openCodeNewProviderId}
onChange={(event) => {
setOpenCodeNewProviderId(event.target.value)
}}
className="w-[220px]"
placeholder="new-provider-id"
/>
<Button
type="button"
size="sm"
variant="outline"
onClick={handleOpenCodeAddProvider}
>
{t("openCode.addProvider")}
</Button>
</div>
{(selectedOpenCodeConfig?.providerIds.length ?? 0) ===
0 ? (
<div className="text-[11px] text-muted-foreground">
{t("openCode.emptyProvider")}
</div>
) : (
<div className="space-y-2">
{selectedOpenCodeConfig?.providerIds.map(
(providerId) => {
const provider =
selectedOpenCodeConfig.providers[providerId]
if (!provider) return null
const expanded = openCodeProviderId === providerId
const isDisabled =
selectedOpenCodeConfig.disabledProviders.includes(
providerId
)
return (
<Collapsible
key={providerId}
open={expanded}
onOpenChange={(open) => {
setOpenCodeProviderId(
open ? providerId : ""
)
}}
>
<div className="rounded-md border bg-muted/20">
<div className="flex items-center justify-between gap-2 px-2.5 py-2">
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-2 text-left"
onClick={() => {
setOpenCodeProviderId((current) =>
current === providerId
? ""
: providerId
)
}}
>
<ChevronDown
className={cn(
"h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform",
expanded && "rotate-180"
)}
/>
<span className="truncate text-xs font-medium">
{providerId}
</span>
<span className="text-[11px] text-muted-foreground">
models: {provider.modelCount}
</span>
</button>
<div className="flex items-center gap-3">
<span className="text-[11px] text-muted-foreground">
{isDisabled
? t("status.disabled")
: t("status.enabled")}
</span>
<Switch
checked={!isDisabled}
onCheckedChange={(checked) => {
handleOpenCodeProviderStatusChange(
providerId,
checked
)
}}
aria-label={t(
"openCode.providerEnabledState",
{ providerId }
)}
title={
isDisabled
? t("actions.clickEnable", {
name: providerId,
})
: t("actions.clickDisable", {
name: providerId,
})
}
/>
<Button
type="button"
size="xs"
variant="outline"
onClick={() => {
setOpenCodeDeleteProviderId(
providerId
)
}}
>
{t("actions.delete")}
</Button>
</div>
</div>
<CollapsibleContent className="px-2.5 pb-2.5">
<div className="grid gap-3 border-t pt-2.5 md:grid-cols-2">
<div className="space-y-1.5">
<label className="text-[11px] text-muted-foreground">
provider.name
</label>
<Input
value={provider.name}
onChange={(event) => {
handleOpenCodeProviderFieldChange(
providerId,
"name",
event.target.value
)
}}
placeholder="My Provider"
/>
</div>
<div className="space-y-1.5">
<label className="text-[11px] text-muted-foreground">
provider.npm
</label>
<Select
value={
provider.npm.trim()
? provider.npm
: "__none__"
}
onValueChange={(value) => {
handleOpenCodeProviderFieldChange(
providerId,
"npm",
value === "__none__"
? ""
: value
)
}}
>
<SelectTrigger className="w-full">
<SelectValue
placeholder={t(
"openCode.selectProviderNpm"
)}
/>
</SelectTrigger>
<SelectContent align="start">
<SelectItem value="__none__">
{t("openCode.notSet")}
</SelectItem>
{buildOpenCodeNpmOptions(
provider.npm
).map((npmOption) => (
<SelectItem
key={npmOption}
value={npmOption}
>
{npmOption}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<label className="text-[11px] text-muted-foreground">
provider.api
</label>
<Input
value={provider.api}
onChange={(event) => {
handleOpenCodeProviderFieldChange(
providerId,
"api",
event.target.value
)
}}
placeholder="openai.responses"
/>
</div>
<div className="space-y-1.5">
<label className="text-[11px] text-muted-foreground">
provider.options.baseURL
</label>
<Input
value={provider.baseUrl}
onChange={(event) => {
handleOpenCodeProviderFieldChange(
providerId,
"baseURL",
event.target.value
)
}}
placeholder="https://api.example.com/v1"
/>
</div>
<div className="space-y-1.5 md:col-span-2">
<label className="text-[11px] text-muted-foreground">
provider.options.apiKey
</label>
<div className="flex items-center gap-2">
<Input
type={
showApiKeys[
selectedAgent.agent_type
]
? "text"
: "password"
}
value={provider.apiKey}
onChange={(event) => {
handleOpenCodeProviderFieldChange(
providerId,
"apiKey",
event.target.value
)
}}
placeholder="sk-..."
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setShowApiKeys((prev) => ({
...prev,
[selectedAgent.agent_type]:
!prev[
selectedAgent.agent_type
],
}))
}}
title={
showApiKeys[
selectedAgent.agent_type
]
? t("actions.hideKey")
: t("actions.showKey")
}
>
{showApiKeys[
selectedAgent.agent_type
] ? (
<EyeOff className="h-3.5 w-3.5" />
) : (
<Eye className="h-3.5 w-3.5" />
)}
</Button>
</div>
</div>
</div>
<Collapsible
open={Boolean(
openCodeModelConfigExpanded[
providerId
]
)}
onOpenChange={(open) => {
setOpenCodeModelConfigExpanded(
(prev) => ({
...prev,
[providerId]: open,
})
)
}}
>
<div className="mt-3 rounded-md border bg-background/50 p-2.5">
<button
type="button"
className="flex w-full items-center justify-between gap-2 text-left"
onClick={() => {
setOpenCodeModelConfigExpanded(
(prev) => ({
...prev,
[providerId]:
!prev[providerId],
})
)
}}
>
<div className="flex items-center gap-2">
<ChevronDown
className={cn(
"h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform",
openCodeModelConfigExpanded[
providerId
] && "rotate-180"
)}
/>
<span className="text-[11px] font-medium">
{t("openCode.modelManagement")}
</span>
</div>
<span className="text-[11px] text-muted-foreground">
{t("openCode.modelCount", {
count: provider.modelCount,
})}
</span>
</button>
<CollapsibleContent className="pt-2">
<p className="text-[11px] text-muted-foreground">
{t("openCode.modelDescription")}
</p>
<div className="mt-2 flex flex-wrap items-center gap-2">
<Input
value={
openCodeNewModelIds[
providerId
] ?? ""
}
onChange={(event) => {
handleOpenCodeModelDraftChange(
providerId,
event.target.value
)
}}
className="w-[240px]"
placeholder="new-model-id"
/>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => {
handleOpenCodeAddModel(
providerId
)
}}
>
{t("openCode.addModel")}
</Button>
</div>
{provider.modelIds.length === 0 ? (
<div className="mt-2 text-[11px] text-muted-foreground">
{t("openCode.emptyModel")}
</div>
) : (
<div className="mt-2 space-y-1">
<div className="flex items-center gap-2 px-1 text-[10px] text-muted-foreground">
<div className="min-w-0 flex-1">
{t("openCode.modelId")}
</div>
<div className="min-w-0 flex-1">
{t("openCode.modelName")}
</div>
<div className="size-8 shrink-0" />
</div>
{provider.modelIds.map(
(modelId) => {
const model =
provider.models[modelId]
if (!model) return null
const modelDraftKey = `${providerId}:${modelId}`
return (
<div
key={`${providerId}:${modelId}`}
className="flex items-center gap-2"
>
<Input
value={
openCodeModelIdDrafts[
modelDraftKey
] ?? model.id
}
onChange={(event) => {
handleOpenCodeModelIdDraftChange(
providerId,
modelId,
event.target.value
)
}}
onBlur={() => {
handleOpenCodeModelIdCommit(
providerId,
modelId
)
}}
onKeyDown={(
event
) => {
if (
event.key ===
"Enter"
) {
event.preventDefault()
handleOpenCodeModelIdCommit(
providerId,
modelId
)
event.currentTarget.blur()
return
}
if (
event.key ===
"Escape"
) {
setOpenCodeModelIdDrafts(
(prev) => {
if (
typeof prev[
modelDraftKey
] ===
"undefined"
) {
return prev
}
const next = {
...prev,
}
delete next[
modelDraftKey
]
return next
}
)
event.currentTarget.blur()
}
}}
className="h-8 min-w-0 flex-1"
placeholder="model.id"
/>
<Input
value={model.name}
onChange={(event) => {
handleOpenCodeModelFieldChange(
providerId,
modelId,
event.target.value
)
}}
className="h-8 min-w-0 flex-1"
placeholder="model.name"
/>
<Button
type="button"
size="icon-sm"
variant="ghost"
className="shrink-0 text-muted-foreground hover:text-destructive"
aria-label={t(
"openCode.deleteModel",
{ modelId }
)}
title={t(
"openCode.deleteModel",
{ modelId }
)}
onClick={() => {
handleOpenCodeRemoveModel(
providerId,
modelId
)
}}
>
<Minus className="h-3.5 w-3.5" />
</Button>
</div>
)
}
)}
</div>
)}
</CollapsibleContent>
</div>
</Collapsible>
<div className="mt-3 flex justify-end">
<Button
type="button"
size="sm"
onClick={() => {
persistPreferences(
selectedAgent.agent_type,
selectedDraft.enabled,
selectedDraft.envText,
selectedDraft.configText,
{
openCodeAuthJsonText:
selectedDraft.openCodeAuthJsonText,
}
)
.then(() => {
toast.success(
t("toasts.providerSaved", {
providerId,
}),
{
description: t(
"toasts.openCodeConfigSynced"
),
}
)
})
.catch((err) => {
console.error(
"[Settings] save opencode provider failed:",
err
)
const message =
err instanceof Error
? err.message
: String(err)
toast.error(
t(
"toasts.saveProviderFailed",
{
providerId,
}
),
{
description: message,
}
)
})
}}
disabled={selectedIsSaving}
>
{selectedIsSaving ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
{t("actions.saving")}
</>
) : (
<>
<Save className="h-3.5 w-3.5" />
{t("actions.saveCurrentProvider")}
</>
)}
</Button>
</div>
</CollapsibleContent>
</div>
</Collapsible>
)
}
)}
</div>
)}
</div>
<div className="space-y-1.5">
<label className="text-[11px] text-muted-foreground">
{t("openCode.nativeJsonConfig")}
</label>
<Textarea
value={selectedDraft.configText}
onChange={(event) => {
handleConfigTextChange(event.target.value)
}}
placeholder={`{
"$schema": "https://opencode.ai/config.json",
"model": "google/gemini-3-pro-preview",
"provider": {
"google": {
"options": {
"baseURL": "https://generativelanguage.googleapis.com/v1beta"
}
}
}
}`}
className="min-h-44 max-h-96 overflow-y-auto font-mono text-xs"
/>
{selectedConfigError && (
<div className="rounded-md border border-red-500/30 bg-red-500/5 px-2.5 py-1.5 text-[11px] text-red-400">
{selectedConfigError}
</div>
)}
</div>
<div className="flex justify-end">
<Button
size="sm"
onClick={() => {
persistPreferences(
selectedAgent.agent_type,
selectedDraft.enabled,
selectedDraft.envText,
selectedDraft.configText,
{
openCodeAuthJsonText:
selectedDraft.openCodeAuthJsonText,
}
)
.then(() => {
toast.success(t("toasts.openCodeSaved"))
})
.catch((err) => {
console.error(
"[Settings] save opencode config failed:",
err
)
const message =
err instanceof Error ? err.message : String(err)
toast.error(t("toasts.saveOpenCodeFailed"), {
description: message,
})
})
}}
disabled={selectedIsSaving}
>
{selectedIsSaving ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
{t("actions.saving")}
</>
) : (
<>
<Save className="h-3.5 w-3.5" />
{t("actions.saveOpenCodeConfig")}
</>
)}
</Button>
</div>
</div>
) : selectedAgent.agent_type === "open_claw" ? (
<div className="space-y-3 rounded-md border bg-muted/10 p-3">
<div>
<label className="text-xs font-medium">
{t("openClaw.gatewayConfig")}
</label>
<p className="mt-1 text-[11px] text-muted-foreground">
{t("openClaw.gatewayDescription")}
</p>
</div>
<div className="space-y-1.5">
<label className="text-[11px] text-muted-foreground">
Gateway URL
</label>
<Input
value={selectedDraft.openClawGatewayUrl}
onChange={(event) => {
handleOpenClawFieldChange(
"openClawGatewayUrl",
event.target.value
)
}}
placeholder="wss://gateway-host:18789"
/>
<p className="text-[11px] text-muted-foreground">
{t("openClaw.gatewayUrlHint")}
</p>
</div>
<div className="space-y-1.5">
<label className="text-[11px] text-muted-foreground">
Gateway Token
</label>
<div className="flex items-center gap-2">
<Input
type={
showApiKeys[selectedAgent.agent_type]
? "text"
: "password"
}
value={selectedDraft.openClawGatewayToken}
onChange={(event) => {
handleOpenClawFieldChange(
"openClawGatewayToken",
event.target.value
)
}}
placeholder={t("openClaw.gatewayTokenPlaceholder")}
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setShowApiKeys((prev) => ({
...prev,
[selectedAgent.agent_type]:
!prev[selectedAgent.agent_type],
}))
}}
title={
showApiKeys[selectedAgent.agent_type]
? t("actions.hideToken")
: t("actions.showToken")
}
>
{showApiKeys[selectedAgent.agent_type] ? (
<EyeOff className="h-3.5 w-3.5" />
) : (
<Eye className="h-3.5 w-3.5" />
)}
</Button>
</div>
<p className="text-[11px] text-muted-foreground">
{t("openClaw.gatewayTokenHint")}
</p>
</div>
<div className="space-y-1.5">
<label className="text-[11px] text-muted-foreground">
Session Key
</label>
<Input
value={selectedDraft.openClawSessionKey}
onChange={(event) => {
handleOpenClawFieldChange(
"openClawSessionKey",
event.target.value
)
}}
placeholder="agent:main:main"
/>
<p className="text-[11px] text-muted-foreground">
{t("openClaw.sessionKeyHint")}
</p>
</div>
<div className="flex items-center justify-end gap-2">
<Button
size="sm"
onClick={() => {
persistPreferences(
selectedAgent.agent_type,
selectedDraft.enabled,
selectedDraft.envText,
selectedDraft.configText
)
.then(() => {
toast.success(t("toasts.openClawSaved"))
})
.catch((err) => {
console.error(
"[Settings] save openclaw config failed:",
err
)
const message =
err instanceof Error ? err.message : String(err)
toast.error(t("toasts.saveOpenClawFailed"), {
description: message,
})
})
}}
disabled={selectedIsSaving}
>
{selectedIsSaving ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
{t("actions.saving")}
</>
) : (
<>
<Save className="h-3.5 w-3.5" />
{t("actions.saveOpenClawConfig")}
</>
)}
</Button>
</div>
</div>
) : (
<div className="space-y-3 rounded-md border bg-muted/10 p-3">
<div>
<label className="text-xs font-medium">
{t("configManagement")}
</label>
<p className="mt-1 text-[11px] text-muted-foreground">
{selectedAgent.agent_type === "claude_code"
? t("generalConfigDescriptionClaude")
: t("generalConfigDescriptionDefault")}
</p>
</div>
<div className="space-y-1.5">
<label className="text-[11px] text-muted-foreground">
API URL
</label>
<Input
value={selectedDraft.apiBaseUrl}
onChange={(event) => {
handleImportantConfigChange(
"apiBaseUrl",
event.target.value
)
}}
placeholder="https://api.example.com"
/>
</div>
<div className="space-y-1.5">
<label className="text-[11px] text-muted-foreground">
API Key
</label>
<div className="flex items-center gap-2">
<Input
type={
showApiKeys[selectedAgent.agent_type]
? "text"
: "password"
}
value={selectedDraft.apiKey}
onChange={(event) => {
handleImportantConfigChange(
"apiKey",
event.target.value
)
}}
placeholder="sk-..."
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setShowApiKeys((prev) => ({
...prev,
[selectedAgent.agent_type]:
!prev[selectedAgent.agent_type],
}))
}}
title={
showApiKeys[selectedAgent.agent_type]
? t("actions.hideApiKey")
: t("actions.showApiKey")
}
>
{showApiKeys[selectedAgent.agent_type] ? (
<EyeOff className="h-3.5 w-3.5" />
) : (
<Eye className="h-3.5 w-3.5" />
)}
</Button>
</div>
</div>
{selectedAgent.agent_type === "claude_code" ? (
<div className="space-y-2">
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-1.5">
<label className="text-[11px] text-muted-foreground">
{t("claude.mainModel")}
</label>
<Input
value={selectedDraft.claudeMainModel}
onChange={(event) => {
handleImportantConfigChange(
"claudeMainModel",
event.target.value
)
}}
placeholder="claude-sonnet-4-6"
/>
</div>
<div className="space-y-1.5">
<label className="text-[11px] text-muted-foreground">
{t("claude.reasoningModel")}
</label>
<Input
value={selectedDraft.claudeReasoningModel}
onChange={(event) => {
handleImportantConfigChange(
"claudeReasoningModel",
event.target.value
)
}}
placeholder="claude-opus-4-6"
/>
</div>
<div className="space-y-1.5">
<label className="text-[11px] text-muted-foreground">
{t("claude.haikuDefaultModel")}
</label>
<Input
value={selectedDraft.claudeDefaultHaikuModel}
onChange={(event) => {
handleImportantConfigChange(
"claudeDefaultHaikuModel",
event.target.value
)
}}
placeholder="claude-haiku-4-5-20251001"
/>
</div>
<div className="space-y-1.5">
<label className="text-[11px] text-muted-foreground">
{t("claude.sonnetDefaultModel")}
</label>
<Input
value={selectedDraft.claudeDefaultSonnetModel}
onChange={(event) => {
handleImportantConfigChange(
"claudeDefaultSonnetModel",
event.target.value
)
}}
placeholder="claude-sonnet-4-6"
/>
</div>
<div className="space-y-1.5 md:col-span-2">
<label className="text-[11px] text-muted-foreground">
{t("claude.opusDefaultModel")}
</label>
<Input
value={selectedDraft.claudeDefaultOpusModel}
onChange={(event) => {
handleImportantConfigChange(
"claudeDefaultOpusModel",
event.target.value
)
}}
placeholder="claude-opus-4-6"
/>
</div>
</div>
<p className="text-[11px] text-muted-foreground">
{t("modelHintDefault")}
</p>
</div>
) : (
<div className="space-y-1.5">
<label className="text-[11px] text-muted-foreground">
Model
</label>
<Input
value={selectedDraft.model}
onChange={(event) => {
handleImportantConfigChange(
"model",
event.target.value
)
}}
placeholder="gpt-5 / claude-sonnet / gemini-2.5-pro"
/>
</div>
)}
<div className="space-y-1.5">
<label className="text-[11px] text-muted-foreground">
{t("nativeJsonConfig")}
</label>
<Textarea
value={selectedDraft.configText}
onChange={(event) => {
handleConfigTextChange(event.target.value)
}}
placeholder={`{
"apiBaseUrl": "https://api.example.com",
"apiKey": "sk-...",
"model": "gpt-5",
"env": {
"CUSTOM_KEY": "VALUE"
}
}`}
className="min-h-36 font-mono text-xs"
/>
{selectedConfigError && (
<div className="rounded-md border border-red-500/30 bg-red-500/5 px-2.5 py-1.5 text-[11px] text-red-400">
{selectedConfigError}
</div>
)}
</div>
<div className="flex justify-end">
<Button
size="sm"
onClick={() => {
persistPreferences(
selectedAgent.agent_type,
selectedDraft.enabled,
selectedDraft.envText,
selectedDraft.configText
)
.then(() => {
toast.success(t("toasts.configSaved"))
})
.catch((err) => {
console.error(
"[Settings] save config management failed:",
err
)
const message =
err instanceof Error ? err.message : String(err)
toast.error(
t("toasts.saveConfigManagementFailed"),
{
description: message,
}
)
})
}}
disabled={selectedIsSaving}
>
{selectedIsSaving ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
{t("actions.saving")}
</>
) : (
<>
<Save className="h-3.5 w-3.5" />
{t("actions.saveConfigManagement")}
</>
)}
</Button>
</div>
</div>
)}
</div>
</div>
) : (
<div className="h-full flex items-center justify-center text-xs text-muted-foreground">
{t("emptyNoAgent")}
</div>
)}
</div>
</div>
<AlertDialog
open={Boolean(openCodeDeleteProviderId)}
onOpenChange={(open) => {
if (!open) setOpenCodeDeleteProviderId(null)
}}
>
<AlertDialogContent size="sm">
<AlertDialogHeader>
<AlertDialogTitle>
{t("dialogs.confirmDeleteProvider", {
providerId: openCodeDeleteProviderId ?? "",
})}
</AlertDialogTitle>
<AlertDialogDescription>
{t("dialogs.confirmDeleteProviderDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={selectedIsSaving}>
{t("actions.cancel")}
</AlertDialogCancel>
<Button
variant="destructive"
onClick={confirmOpenCodeProviderDelete}
disabled={selectedIsSaving}
>
{selectedIsSaving ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
{t("actions.deleting")}
</>
) : (
<>
<Trash2 className="h-3.5 w-3.5" />
{t("actions.confirmDelete")}
</>
)}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog
open={Boolean(uninstallConfirmAgent)}
onOpenChange={(open) => {
if (!open) setUninstallConfirmAgent(null)
}}
>
<AlertDialogContent size="sm">
<AlertDialogHeader>
<AlertDialogTitle>
{t("dialogs.confirmUninstall", {
name: uninstallConfirmAgent?.name ?? "Agent",
})}
</AlertDialogTitle>
<AlertDialogDescription>
{t("dialogs.confirmUninstallDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel
disabled={
uninstallConfirmAgent
? Boolean(busyBinaryAction[uninstallConfirmAgent.agent_type])
: false
}
>
{t("actions.cancel")}
</AlertDialogCancel>
<Button
variant="destructive"
onClick={confirmUninstall}
disabled={
uninstallConfirmAgent
? Boolean(busyBinaryAction[uninstallConfirmAgent.agent_type])
: false
}
>
{uninstallConfirmAgent &&
busyBinaryAction[uninstallConfirmAgent.agent_type] ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
{t("actions.uninstalling")}
</>
) : (
<>
<Trash2 className="h-3.5 w-3.5" />
{t("actions.confirmUninstall")}
</>
)}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}