Files
codeg/src/components/settings/acp-agent-settings.tsx

7641 lines
276 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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,
Copy,
Download,
Eye,
EyeOff,
GripVertical,
Loader2,
Minus,
RefreshCw,
Save,
Trash2,
Wrench,
} from "lucide-react"
import { openUrl } from "@/lib/platform"
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 {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxGroup,
ComboboxInput,
ComboboxItem,
ComboboxLabel,
ComboboxList,
} from "@/components/ui/combobox"
import { cn } from "@/lib/utils"
import {
acpClearBinaryCache,
acpDetectAgentLocalVersion,
acpDownloadAgentBinary,
acpListAgents,
acpPreflight,
acpPrepareNpxAgent,
acpReorderAgents,
acpUninstallAgent,
acpUpdateAgentConfig,
acpUpdateAgentEnv,
codexPollDeviceCode,
codexRequestDeviceCode,
listModelProviders,
} from "@/lib/api"
import type {
AcpAgentInfo,
AgentType,
CheckStatus,
FixAction,
ModelProviderInfo,
PreflightResult,
} from "@/lib/types"
import { useAgentInstallStream } from "@/hooks/use-agent-install-stream"
import { OpencodePluginsModal } from "./opencode-plugins-modal"
interface AgentCheckState {
result?: PreflightResult
error?: string
}
const CLAUDE_AUTH_MODES = [
"official_subscription",
"custom",
"model_provider",
] as const
type ClaudeAuthMode = (typeof CLAUDE_AUTH_MODES)[number]
interface AgentDraft {
enabled: boolean
envText: string
configText: string
apiBaseUrl: string
apiKey: string
model: string
claudeAuthMode: ClaudeAuthMode
modelProviderId: number | null
geminiAuthMode: GeminiAuthMode
geminiApiKey: string
googleApiKey: string
googleCloudProject: string
googleCloudLocation: string
googleApplicationCredentials: string
codexAuthMode: CodexAuthMode
codexModelProvider: string
codexProviderOptions: string[]
codexReasoningEffort: CodexReasoningEffort
codexSupportsWebsockets: boolean
codexSkills: boolean
codexServiceTierFast: boolean
claudeMainModel: string
claudeReasoningModel: string
claudeDefaultHaikuModel: string
claudeDefaultSonnetModel: string
claudeDefaultOpusModel: string
claudeEffortLevel: ClaudeEffortLevel
codexAuthJsonText: string
codexConfigTomlText: string
openCodeAuthJsonText: string
openClawGatewayUrl: string
openClawGatewayToken: string
openClawSessionKey: string
clineProvider: ClineProvider
clineApiKey: string
clineModel: string
clineBaseUrl: 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"
| "install_opencode_plugins"
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 CLAUDE_EFFORT_LEVEL_CONFIG_KEY = "effortLevel"
type ClaudeEffortLevel = "" | "low" | "medium" | "high" | "xhigh"
const CLAUDE_EFFORT_LEVEL_VALUES: ReadonlyArray<
Exclude<ClaudeEffortLevel, "">
> = ["low", "medium", "high", "xhigh"]
function normalizeClaudeEffortLevel(value: unknown): ClaudeEffortLevel {
if (typeof value !== "string") return ""
const normalized = value.trim().toLowerCase()
if (
normalized === "low" ||
normalized === "medium" ||
normalized === "high" ||
normalized === "xhigh"
) {
return normalized
}
return ""
}
const GEMINI_AUTH_MODES = [
"custom",
"login_google",
"gemini_api_key",
"vertex_adc",
"vertex_service_account",
"vertex_api_key",
"model_provider",
] 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
const CLINE_PROVIDERS = [
{ value: "anthropic", label: "Anthropic" },
{ value: "openai-native", label: "OpenAI" },
{ value: "openai", label: "OpenAI Compatible" },
{ value: "openrouter", label: "OpenRouter" },
{ value: "gemini", label: "Gemini" },
{ value: "deepseek", label: "DeepSeek" },
{ value: "bedrock", label: "AWS Bedrock" },
{ value: "vertex", label: "GCP Vertex" },
{ value: "ollama", label: "Ollama" },
] as const
type ClineProvider = (typeof CLINE_PROVIDERS)[number]["value"]
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
claudeEffortLevel: ClaudeEffortLevel
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,
])
const claudeEffortLevel: ClaudeEffortLevel =
agentType === "claude_code"
? normalizeClaudeEffortLevel(config[CLAUDE_EFFORT_LEVEL_CONFIG_KEY])
: ""
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 : "",
claudeEffortLevel,
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
}
interface ClineImportantValues {
provider: ClineProvider
apiKey: string
model: string
baseUrl: string
}
function extractClineImportantValues(configText: string): ClineImportantValues {
const parseResult = parseConfigJsonText(configText)
const config = parseResult.config
return {
provider: (typeof config.apiProvider === "string" && config.apiProvider
? config.apiProvider
: "anthropic") as ClineProvider,
apiKey: typeof config.apiKey === "string" ? config.apiKey : "",
model: typeof config.model === "string" ? config.model : "",
baseUrl: typeof config.apiBaseUrl === "string" ? config.apiBaseUrl : "",
}
}
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.model, patch.model)
}
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
}
if (mode === "model_provider") {
next.googleCloudProject = ""
next.googleCloudLocation = ""
next.googleApplicationCredentials = ""
return next
}
next.apiBaseUrl = ""
next.geminiApiKey = ""
next.googleApiKey = ""
next.googleApplicationCredentials = ""
return next
}
function geminiAuthModeLabel(mode: GeminiAuthMode): string {
if (mode === "custom")
return acpText("authModeCustomEndpoint", "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)"
)
if (mode === "model_provider")
return acpText("authModeModelProvider", "Model Provider")
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."
)
}
if (mode === "model_provider") {
return acpText(
"modelProviderHint",
"Use API URL and API Key from a configured model provider."
)
}
return acpText(
"gemini.hint.vertexApiKey",
"Fill GOOGLE_API_KEY when using Vertex AI API key."
)
}
/**
* Compare original and current config objects. For any key present in
* original but missing in current, set it to `null` in the result so
* the backend merge can delete it from the file on disk.
*/
function markRemovedKeysNull(
original: Record<string, unknown>,
current: Record<string, unknown>
): Record<string, unknown> {
const result: Record<string, unknown> = { ...current }
for (const key of Object.keys(original)) {
if (!(key in result)) {
result[key] = null
} else if (
original[key] &&
typeof original[key] === "object" &&
!Array.isArray(original[key]) &&
result[key] &&
typeof result[key] === "object" &&
!Array.isArray(result[key])
) {
result[key] = markRemovedKeysNull(
original[key] as Record<string, unknown>,
result[key] as Record<string, unknown>
)
}
}
return result
}
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
interface OpenCodeModelOptionGroup {
providerId: string
label: string
models: { value: string; label: string }[]
}
function buildOpenCodeModelOptions(
config: OpenCodeConfigView | null
): OpenCodeModelOptionGroup[] {
if (!config) return []
const groups: OpenCodeModelOptionGroup[] = []
for (const providerId of config.providerIds) {
const provider = config.providers[providerId]
if (!provider || provider.modelIds.length === 0) continue
groups.push({
providerId,
label: provider.name || providerId,
models: provider.modelIds.map((modelId) => ({
value: `${providerId}/${modelId}`,
label: modelId,
})),
})
}
return groups
}
function OpenCodeModelCombobox({
value,
onValueChange,
groups,
placeholder,
}: {
value: string
onValueChange: (value: string) => void
groups: OpenCodeModelOptionGroup[]
placeholder: string
}) {
const inputRef = useRef<HTMLInputElement>(null)
const handleSelect = useCallback(
(next: string | null) => {
if (typeof next === "string" && next !== value) {
onValueChange(next)
}
},
[onValueChange, value]
)
const handleBlur = useCallback(() => {
const trimmed = (inputRef.current?.value ?? "").trim()
if (trimmed !== value) {
onValueChange(trimmed)
}
}, [onValueChange, value])
return (
<Combobox key={value} value={value} onValueChange={handleSelect}>
<ComboboxInput
ref={inputRef}
placeholder={placeholder}
onBlur={handleBlur}
showClear={false}
/>
<ComboboxContent>
<ComboboxList>
{groups.map((group) => (
<ComboboxGroup key={group.providerId}>
<ComboboxLabel>{group.label}</ComboboxLabel>
{group.models.map((model) => (
<ComboboxItem key={model.value} value={model.value}>
{model.value}
</ComboboxItem>
))}
</ComboboxGroup>
))}
<ComboboxEmpty>
{acpText("openCode.noMatchingModels", "No matching models")}
</ComboboxEmpty>
</ComboboxList>
</ComboboxContent>
</Combobox>
)
}
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
featureSkills: boolean
serviceTierFast: boolean
}
interface CodexImportantValues {
apiBaseUrl: string
apiKey: string | null
model: string
modelProvider: string
reasoningEffort: CodexReasoningEffort
providerOptions: string[]
supportsWebsockets: boolean
skills: boolean
serviceTierFast: boolean
}
const CODEX_DEFAULT_MODEL_PROVIDER = "codeg"
const CODEX_AUTH_MODES = [
"api_key",
"chatgpt_subscription",
"model_provider",
] 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 featureSkills = false
let serviceTierFast = 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
}
if (
!currentProviderSection &&
!inFeaturesSection &&
assignment.key === "service_tier"
) {
serviceTierFast = assignment.value.toLowerCase() === "fast"
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
}
if (inFeaturesSection && boolAssignment.key === "skills") {
featureSkills = 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 (boolAssignment.key === "features.skills") {
featureSkills = 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,
featureSkills,
serviceTierFast,
}
}
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 hasCodexChatgptTokens(authJsonText: string): boolean {
const { authObject } = parseCodexAuthJsonObject(authJsonText)
if (!authObject) return false
const tokens = authObject.tokens as Record<string, unknown> | undefined
if (tokens && typeof tokens === "object") {
return (
typeof tokens.access_token === "string" && tokens.access_token.length > 0
)
}
return false
}
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,
skills: toml.featureSkills,
serviceTierFast: toml.serviceTierFast,
}
}
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
}
let insertAt = findTomlRootEndIndex(lines)
while (insertAt > 0 && lines[insertAt - 1].trim() === "") {
insertAt -= 1
}
return insertAt
}
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 removeTomlSection(
configTomlText: string,
sectionName: string
): string {
const lines = configTomlText.split(/\r?\n/)
const range = findTomlSectionRange(lines, sectionName)
if (!range) return configTomlText
// Remove blank line before section header if present
const removeStart =
range.start > 0 && lines[range.start - 1].trim() === ""
? range.start - 1
: range.start
lines.splice(removeStart, range.end - removeStart)
return lines.join("\n").trim()
}
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 {
let insertAt = section.end
for (let i = section.end - 1; i > section.start; i -= 1) {
if (lines[i].trim() !== "") {
insertAt = i + 1
break
}
}
lines.splice(insertAt, 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; authMode?: "chatgpt" | null }
): {
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
}
}
if ("authMode" in patch) {
if (patch.authMode === "chatgpt") {
authObject.auth_mode = "chatgpt"
authObject.OPENAI_API_KEY = null
} else {
delete authObject.auth_mode
}
}
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
skills?: boolean
serviceTierFast?: 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
)
if (typeof patch.skills === "boolean") {
nextTomlText = upsertTomlSectionBooleanKey(
nextTomlText,
"features",
"skills",
patch.skills ? true : null
)
}
if (typeof patch.serviceTierFast === "boolean") {
nextTomlText = updateTomlRootStringKey(
nextTomlText,
"service_tier",
patch.serviceTierFast ? "fast" : ""
)
}
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
}
if (agentType === "claude_code") {
// Claude Code: write apiBaseUrl/apiKey into config.env, not root
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
}
// Remove root-level apiBaseUrl/apiKey if present (legacy cleanup)
delete config.apiBaseUrl
delete config.apiKey
assignEnv("ANTHROPIC_BASE_URL", patch.apiBaseUrl)
assignEnv("ANTHROPIC_AUTH_TOKEN", patch.apiKey)
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("apiBaseUrl", patch.apiBaseUrl)
assignOrRemove("apiKey", patch.apiKey)
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
)
const clineImportant = extractClineImportantValues(configText)
const codexAuthMode: CodexAuthMode =
agent.agent_type === "codex" && agent.model_provider_id != null
? "model_provider"
: agent.agent_type === "codex"
? inferCodexAuthMode(codexAuthJsonText)
: "api_key"
const rawEnvText = envMapToText(agent.env)
// When codex is in official subscription mode, clean up API keys/URLs from env
const envText =
agent.agent_type === "codex" && codexAuthMode === "chatgpt_subscription"
? patchEnvText(rawEnvText, {
OPENAI_API_KEY: "",
OPENAI_BASE_URL: "",
})
: rawEnvText
return {
enabled: agent.enabled,
envText,
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,
claudeAuthMode:
agent.agent_type === "claude_code" && agent.model_provider_id != null
? "model_provider"
: agent.agent_type === "claude_code" &&
(important.apiBaseUrl || important.apiKey)
? "custom"
: "official_subscription",
modelProviderId: agent.model_provider_id ?? null,
geminiAuthMode:
agent.agent_type === "gemini" && agent.model_provider_id != null
? "model_provider"
: geminiImportant.authMode,
geminiApiKey: geminiImportant.geminiApiKey,
googleApiKey: geminiImportant.googleApiKey,
googleCloudProject: geminiImportant.googleCloudProject,
googleCloudLocation: geminiImportant.googleCloudLocation,
googleApplicationCredentials: geminiImportant.googleApplicationCredentials,
codexAuthMode,
codexModelProvider: codexImportant.modelProvider,
codexProviderOptions: codexImportant.providerOptions,
codexReasoningEffort: codexImportant.reasoningEffort,
codexSupportsWebsockets: codexImportant.supportsWebsockets,
codexSkills: codexImportant.skills,
codexServiceTierFast: codexImportant.serviceTierFast,
claudeMainModel: important.claudeMainModel,
claudeReasoningModel: important.claudeReasoningModel,
claudeDefaultHaikuModel: important.claudeDefaultHaikuModel,
claudeDefaultSonnetModel: important.claudeDefaultSonnetModel,
claudeDefaultOpusModel: important.claudeDefaultOpusModel,
claudeEffortLevel: important.claudeEffortLevel,
codexAuthJsonText,
codexConfigTomlText,
openCodeAuthJsonText,
openClawGatewayUrl: openClawImportant.gatewayUrl,
openClawGatewayToken: openClawImportant.gatewayToken,
openClawSessionKey: openClawImportant.sessionKey,
clineProvider: clineImportant.provider,
clineApiKey: clineImportant.apiKey,
clineModel: clineImportant.model,
clineBaseUrl: clineImportant.baseUrl,
}
}
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 [savingEnv, setSavingEnv] = useState<
Partial<Record<AgentType, boolean>>
>({})
const [savingConfig, setSavingConfig] = useState<
Partial<Record<AgentType, boolean>>
>({})
const [modelProviders, setModelProviders] = useState<ModelProviderInfo[]>([])
const [uninstallConfirmAgent, setUninstallConfirmAgent] =
useState<AcpAgentInfo | null>(null)
const [pluginModalOpen, setPluginModalOpen] = useState(false)
const [pluginModalAgent, setPluginModalAgent] = useState<AgentType | 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 installStream = useAgentInstallStream()
const [streamAgentType, setStreamAgentType] = useState<AgentType | null>(null)
const installLogEndRef = useRef<HTMLDivElement | null>(null)
const [codexDeviceCode, setCodexDeviceCode] = useState<{
userCode: string
verificationUrl: string
deviceAuthId: string
interval: number
} | null>(null)
const [codexLoginStatus, setCodexLoginStatus] = useState<
"idle" | "requesting" | "polling" | "success" | "error"
>("idle")
const [codexLoginError, setCodexLoginError] = useState<string | null>(null)
const codexPollCancelledRef = useRef(false)
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, providers] = await Promise.all([
acpListAgents(),
listModelProviders().catch(() => [] as ModelProviderInfo[]),
])
setAgents(next)
setModelProviders(providers)
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(() => {
return () => installStream.reset()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
useEffect(() => {
const container = installLogEndRef.current?.parentElement
if (container) {
container.scrollTop = container.scrollHeight
}
}, [installStream.logs])
useEffect(() => {
if (
installStream.status === "success" ||
installStream.status === "failed"
) {
if (streamAgentType) {
runPreflight(streamAgentType).catch(() => {})
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [installStream.status])
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 persistEnv = useCallback(
async (
agentType: AgentType,
enabled: boolean,
envText: string,
modelProviderId?: number | null
) => {
const parsedEnv = parseEnvText(envText)
setSavingEnv((prev) => ({ ...prev, [agentType]: true }))
try {
await acpUpdateAgentEnv(agentType, {
enabled,
env: parsedEnv,
modelProviderId: modelProviderId ?? null,
})
setAgents((prev) =>
prev.map((agent) =>
agent.agent_type === agentType
? {
...agent,
enabled,
env: parsedEnv,
model_provider_id: modelProviderId ?? null,
}
: agent
)
)
} finally {
setSavingEnv((prev) => ({ ...prev, [agentType]: false }))
}
},
[]
)
const persistConfig = useCallback(
async (
agentType: AgentType,
configText: string,
options?: {
openCodeAuthJsonText?: string
codexAuthJsonText?: string
codexConfigTomlText?: string
}
) => {
const parsedConfig = parseConfigJsonText(configText)
if (parsedConfig.error) {
throw new Error(parsedConfig.error)
}
const codexAuthJsonText = options?.codexAuthJsonText
if (agentType === "codex" && typeof codexAuthJsonText === "string") {
const authError = parseCodexAuthJsonText(codexAuthJsonText)
if (authError) {
throw new Error(authError)
}
}
const normalizedConfig = normalizeConfigText(configText)
// For agents using merge strategy, mark removed keys as null
// so the backend merge_json_values can delete them from disk.
let configForPersist =
agentType === "open_code" && !normalizedConfig ? "{}" : normalizedConfig
const usesMerge =
agentType === "claude_code" ||
agentType === "gemini" ||
agentType === "open_claw"
if (usesMerge && configForPersist) {
const originalAgent = agents.find((a) => a.agent_type === agentType)
const originalConfig = originalAgent?.config_json
? parseConfigJsonText(originalAgent.config_json).config
: {}
const currentConfig = parsedConfig.config
configForPersist = JSON.stringify(
markRemovedKeysNull(originalConfig, currentConfig),
null,
2
)
}
setSavingConfig((prev) => ({ ...prev, [agentType]: true }))
try {
await acpUpdateAgentConfig(agentType, {
config_json: configForPersist || null,
opencode_auth_json:
typeof options?.openCodeAuthJsonText === "string"
? options.openCodeAuthJsonText
: null,
codex_auth_json:
typeof codexAuthJsonText === "string" ? codexAuthJsonText : null,
codex_config_toml:
typeof options?.codexConfigTomlText === "string"
? options.codexConfigTomlText
: null,
})
setAgents((prev) =>
prev.map((agent) =>
agent.agent_type === agentType
? {
...agent,
config_json: normalizedConfig || null,
opencode_auth_json:
typeof options?.openCodeAuthJsonText === "string"
? options.openCodeAuthJsonText
: agent.opencode_auth_json,
codex_auth_json:
typeof codexAuthJsonText === "string"
? codexAuthJsonText
: agent.codex_auth_json,
codex_config_toml:
typeof options?.codexConfigTomlText === "string"
? options.codexConfigTomlText
: agent.codex_config_toml,
}
: agent
)
)
} finally {
setSavingConfig((prev) => ({ ...prev, [agentType]: false }))
}
},
[agents]
)
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"),
}))
const taskId = crypto.randomUUID()
setStreamAgentType(agent.agent_type)
await installStream.start(taskId)
try {
if (mode === "upgrade") {
await acpClearBinaryCache(agent.agent_type)
}
await acpDownloadAgentBinary(agent.agent_type, taskId)
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,
}))
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[runPreflight, t, installStream.start]
)
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",
}))
const taskId = crypto.randomUUID()
setStreamAgentType(agent.agent_type)
await installStream.start(taskId)
try {
const installedVersion = await acpPrepareNpxAgent(
agent.agent_type,
agent.registry_version,
taskId
)
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,
}))
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[runPreflight, t, installStream.start]
)
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",
}))
const taskId = crypto.randomUUID()
setStreamAgentType(agent.agent_type)
await installStream.start(taskId)
try {
await acpUninstallAgent(agent.agent_type, taskId)
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,
}))
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[runPreflight, t, installStream.start]
)
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
}
if (action.kind === "install_opencode_plugins") {
setPluginModalAgent(agent.agent_type)
setPluginModalOpen(true)
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",
"install_opencode_plugins",
].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" />
) : fix.kind === "install_opencode_plugins" ? (
<Download 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(
savingEnv[selectedAgent.agent_type] ||
savingConfig[selectedAgent.agent_type]
)
: false
const selectedIsSavingEnv = selectedAgent
? Boolean(savingEnv[selectedAgent.agent_type])
: false
const selectedIsSavingConfig = selectedAgent
? Boolean(savingConfig[selectedAgent.agent_type])
: false
const selectedAgentKind = selectedAgent?.agent_type ?? null
const selectedModelProviders = useMemo(() => {
if (!selectedAgent) return []
return modelProviders.filter((p) =>
p.agent_types.includes(selectedAgent.agent_type)
)
}, [modelProviders, selectedAgent])
const selectedNeedsModelProvider = useMemo(() => {
if (!selectedDraft) return false
if (!selectedAgent) return false
const at = selectedAgent.agent_type
if (at === "claude_code")
return selectedDraft.claudeAuthMode === "model_provider"
if (at === "codex") return selectedDraft.codexAuthMode === "model_provider"
if (at === "gemini")
return selectedDraft.geminiAuthMode === "model_provider"
return false
}, [selectedAgent, selectedDraft])
const selectedMissingModelProvider =
selectedNeedsModelProvider && selectedDraft?.modelProviderId == null
const selectedConfigText = selectedDraft?.configText ?? ""
const selectedOpenCodeAuthJsonText = selectedDraft?.openCodeAuthJsonText ?? ""
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 openCodeModelOptions = useMemo(
() => buildOpenCodeModelOptions(selectedOpenCodeConfig),
[selectedOpenCodeConfig]
)
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
}
if (selectedAgent.agent_type === "cline") {
const cline = extractClineImportantValues(nextText)
updateSelectedDraft((current) => ({
...current,
configText: nextText,
clineProvider: cline.provider,
clineApiKey: cline.apiKey,
clineModel: cline.model,
clineBaseUrl: cline.baseUrl,
}))
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,
claudeEffortLevel: important.claudeEffortLevel,
}))
},
[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 handleClaudeEffortLevelChange = useCallback(
(nextValue: ClaudeEffortLevel) => {
if (
!selectedAgent ||
!selectedDraft ||
selectedAgent.agent_type !== "claude_code"
)
return
const parsed = parseConfigJsonText(selectedDraft.configText)
if (parsed.error) {
toast.warning(t("warnings.nativeJsonRecoveredStructured"))
}
const config: Record<string, unknown> = parsed.error
? {}
: { ...parsed.config }
if (nextValue) {
config[CLAUDE_EFFORT_LEVEL_CONFIG_KEY] = nextValue
} else {
delete config[CLAUDE_EFFORT_LEVEL_CONFIG_KEY]
}
const nextConfigText =
Object.keys(config).length === 0 ? "" : JSON.stringify(config, null, 2)
setConfigErrors((prev) => ({
...prev,
[selectedAgent.agent_type]: null,
}))
updateSelectedDraft((current) => ({
...current,
claudeEffortLevel: nextValue,
configText: nextConfigText,
}))
},
[selectedAgent, selectedDraft, t, updateSelectedDraft]
)
const handleClaudeAuthModeChange = useCallback(
(nextMode: ClaudeAuthMode) => {
if (
!selectedAgent ||
!selectedDraft ||
selectedAgent.agent_type !== "claude_code"
)
return
const keys = importantEnvKeysByAgent("claude_code")
const allEnvKeys = [...keys.apiBaseUrl, ...keys.apiKey]
if (nextMode === "official_subscription") {
// Clear API URL/API Key from env and config
const envPatch: Record<string, string> = {}
for (const k of allEnvKeys) envPatch[k] = ""
// Build clean display config (remove null keys)
const parsed = parseConfigJsonText(selectedDraft.configText)
const config: Record<string, unknown> = parsed.error
? {}
: { ...parsed.config }
delete config.apiBaseUrl
delete config.apiKey
if (config.env && typeof config.env === "object") {
const cfgEnv = { ...(config.env as Record<string, unknown>) }
for (const k of allEnvKeys) delete cfgEnv[k]
if (Object.keys(cfgEnv).length > 0) {
config.env = cfgEnv
} else {
delete config.env
}
}
const nextConfigText =
Object.keys(config).length > 0 ? JSON.stringify(config, null, 2) : ""
setConfigErrors((prev) => ({
...prev,
[selectedAgent.agent_type]: null,
}))
updateSelectedDraft((current) => ({
...current,
claudeAuthMode: nextMode,
modelProviderId: null,
apiBaseUrl: "",
apiKey: "",
envText: patchEnvText(current.envText, envPatch),
configText: nextConfigText,
}))
return
}
// "custom" or "model_provider" — keep existing values, just switch mode
updateSelectedDraft((current) => ({
...current,
claudeAuthMode: nextMode,
modelProviderId:
nextMode === "model_provider" ? current.modelProviderId : null,
}))
},
[selectedAgent, selectedDraft, updateSelectedDraft]
)
const handleModelProviderSelect = useCallback(
(providerIdStr: string) => {
if (!selectedAgent || !selectedDraft) return
const providerId = providerIdStr ? Number(providerIdStr) : null
const provider = providerId
? modelProviders.find((p) => p.id === providerId)
: null
const apiUrl = provider?.api_url ?? ""
const apiKey = provider?.api_key ?? ""
const agentType = selectedAgent.agent_type
if (agentType === "claude_code") {
const nextConfigJson = patchImportantConfigText(
agentType,
selectedDraft.configText,
{ apiBaseUrl: apiUrl, apiKey }
)
setConfigErrors((prev) => ({
...prev,
[agentType]: null,
}))
updateSelectedDraft((current) => ({
...current,
modelProviderId: providerId,
apiBaseUrl: apiUrl,
apiKey,
envText: patchEnvByImportantKey(
agentType,
patchEnvByImportantKey(
agentType,
current.envText,
"apiBaseUrl",
apiUrl
),
"apiKey",
apiKey
),
configText: nextConfigJson.configText,
}))
} else if (agentType === "codex") {
const nextAuthPatch = patchCodexAuthJsonText(
selectedDraft.codexAuthJsonText,
{ apiKey, authMode: null }
)
const nextAuthJsonText = nextAuthPatch.authJsonText
const nextConfigTomlText = patchCodexConfigTomlText(
selectedDraft.codexConfigTomlText,
{
modelProvider: CODEX_DEFAULT_MODEL_PROVIDER,
apiBaseUrl: apiUrl,
}
)
const synced = extractCodexImportantValues(
nextAuthJsonText,
nextConfigTomlText
)
updateSelectedDraft((current) => ({
...current,
modelProviderId: providerId,
apiBaseUrl: apiUrl,
apiKey,
codexAuthJsonText: nextAuthJsonText,
codexConfigTomlText: nextConfigTomlText,
codexModelProvider: CODEX_DEFAULT_MODEL_PROVIDER,
codexProviderOptions: synced.providerOptions,
envText: patchEnvText(current.envText, {
OPENAI_API_KEY: apiKey,
OPENAI_BASE_URL: apiUrl,
}),
}))
} else if (agentType === "gemini") {
const nextConfigJson = patchGeminiConfigText(selectedDraft.configText, {
apiBaseUrl: apiUrl,
geminiApiKey: apiKey,
})
setConfigErrors((prev) => ({
...prev,
[agentType]: null,
}))
updateSelectedDraft((current) => ({
...current,
modelProviderId: providerId,
apiBaseUrl: apiUrl,
apiKey,
geminiApiKey: apiKey,
envText: patchGeminiEnvText(current.envText, {
apiBaseUrl: apiUrl,
geminiApiKey: apiKey,
}),
configText: nextConfigJson.configText,
}))
} else {
updateSelectedDraft((current) => ({
...current,
modelProviderId: providerId,
}))
}
},
[selectedAgent, selectedDraft, modelProviders, 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
if (nextMode === "model_provider") {
// Keep existing values; provider selection will fill API URL/Key
updateSelectedDraft((current) => ({
...current,
geminiAuthMode: nextMode,
modelProviderId: current.modelProviderId,
}))
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,
modelProviderId: null,
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 handleClineFieldChange = useCallback(
(
key: "clineProvider" | "clineApiKey" | "clineModel" | "clineBaseUrl",
value: string
) => {
if (
!selectedAgent ||
!selectedDraft ||
selectedAgent.agent_type !== "cline"
)
return
updateSelectedDraft((current) => {
const next = { ...current, [key]: value }
// Rebuild config_json from Cline draft fields
const config: Record<string, unknown> = {}
config.apiProvider =
key === "clineProvider" ? value : next.clineProvider
const apiKey = key === "clineApiKey" ? value : next.clineApiKey
if (apiKey.trim()) config.apiKey = apiKey.trim()
const model = key === "clineModel" ? value : next.clineModel
if (model.trim()) config.model = model.trim()
const baseUrl = key === "clineBaseUrl" ? value : next.clineBaseUrl
if (baseUrl.trim()) config.apiBaseUrl = baseUrl.trim()
next.configText = JSON.stringify(config, null, 2)
return next
})
},
[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
}
persistConfig(selectedAgent.agent_type, 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,
persistConfig,
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 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,
codexSkills: important.skills,
codexServiceTierFast: important.serviceTierFast,
}))
},
[selectedAgent, selectedDraft, updateSelectedDraft]
)
const handleCodexAuthModeChange = useCallback(
(nextMode: CodexAuthMode) => {
if (
!selectedAgent ||
!selectedDraft ||
selectedAgent.agent_type !== "codex"
)
return
if (nextMode === "chatgpt_subscription") {
// Official subscription: set auth_mode to chatgpt, OPENAI_API_KEY to null
const nextAuth = patchCodexAuthJsonText(
selectedDraft.codexAuthJsonText,
{ authMode: "chatgpt" }
)
const nextAuthJsonText = nextAuth.authJsonText
let nextConfigTomlText = updateTomlRootStringKey(
selectedDraft.codexConfigTomlText,
"model_provider",
""
)
nextConfigTomlText = removeTomlSection(
nextConfigTomlText,
`model_providers.${CODEX_DEFAULT_MODEL_PROVIDER}`
)
const synced = extractCodexImportantValues(
nextAuthJsonText,
nextConfigTomlText
)
updateSelectedDraft((current) => ({
...current,
codexAuthMode: nextMode,
modelProviderId: null,
codexAuthJsonText: nextAuthJsonText,
codexConfigTomlText: nextConfigTomlText,
envText: patchEnvText(current.envText, {
OPENAI_API_KEY: "",
OPENAI_BASE_URL: "",
}),
apiBaseUrl: "",
apiKey: "",
model: synced.model,
codexModelProvider: synced.modelProvider,
codexProviderOptions: synced.providerOptions,
codexReasoningEffort: synced.reasoningEffort,
codexSupportsWebsockets: synced.supportsWebsockets,
codexSkills: synced.skills,
codexServiceTierFast: synced.serviceTierFast,
}))
return
}
// "api_key" or "model_provider": ensure model_provider = "codeg" in toml
const nextConfigTomlText = patchCodexConfigTomlText(
selectedDraft.codexConfigTomlText,
{ modelProvider: CODEX_DEFAULT_MODEL_PROVIDER }
)
const nextAuthPatch = patchCodexAuthJsonText(
selectedDraft.codexAuthJsonText,
{ authMode: null }
)
const nextAuthJsonText = nextAuthPatch.authJsonText
const synced = extractCodexImportantValues(
nextAuthJsonText,
nextConfigTomlText
)
updateSelectedDraft((current) => ({
...current,
codexAuthMode: nextMode,
modelProviderId:
nextMode === "model_provider" ? current.modelProviderId : null,
codexAuthJsonText: nextAuthJsonText,
codexConfigTomlText: nextConfigTomlText,
apiBaseUrl: synced.apiBaseUrl,
apiKey: synced.apiKey ?? current.apiKey,
model: synced.model,
codexModelProvider: CODEX_DEFAULT_MODEL_PROVIDER,
codexProviderOptions: synced.providerOptions,
codexReasoningEffort: synced.reasoningEffort,
codexSupportsWebsockets: synced.supportsWebsockets,
codexSkills: synced.skills,
codexServiceTierFast: synced.serviceTierFast,
}))
},
[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,
codexSkills: synced.skills,
codexServiceTierFast: synced.serviceTierFast,
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,
codexSkills: synced.skills,
codexServiceTierFast: synced.serviceTierFast,
codexConfigTomlText: nextToml,
}))
},
[selectedAgent, selectedDraft, updateSelectedDraft]
)
const handleCodexSkillsChange = useCallback(
(enabled: boolean) => {
if (
!selectedAgent ||
!selectedDraft ||
selectedAgent.agent_type !== "codex"
)
return
const nextToml = patchCodexConfigTomlText(
selectedDraft.codexConfigTomlText,
{ skills: 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,
codexSkills: synced.skills,
codexServiceTierFast: synced.serviceTierFast,
codexConfigTomlText: nextToml,
}))
},
[selectedAgent, selectedDraft, updateSelectedDraft]
)
const handleCodexServiceTierFastChange = useCallback(
(enabled: boolean) => {
if (
!selectedAgent ||
!selectedDraft ||
selectedAgent.agent_type !== "codex"
)
return
const nextToml = patchCodexConfigTomlText(
selectedDraft.codexConfigTomlText,
{ serviceTierFast: 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,
codexSkills: synced.skills,
codexServiceTierFast: synced.serviceTierFast,
codexConfigTomlText: nextToml,
}))
},
[selectedAgent, selectedDraft, updateSelectedDraft]
)
const handleCodexDeviceLogin = useCallback(async () => {
setCodexLoginStatus("requesting")
setCodexLoginError(null)
setCodexDeviceCode(null)
codexPollCancelledRef.current = false
try {
const resp = await codexRequestDeviceCode()
setCodexDeviceCode(resp)
setCodexLoginStatus("polling")
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
setCodexLoginError(msg)
setCodexLoginStatus("error")
}
}, [])
const cancelCodexDeviceLogin = useCallback(() => {
codexPollCancelledRef.current = true
setCodexLoginStatus("idle")
setCodexDeviceCode(null)
setCodexLoginError(null)
}, [])
useEffect(() => {
if (codexLoginStatus !== "polling" || !codexDeviceCode) return
codexPollCancelledRef.current = false
const pollInterval = (codexDeviceCode.interval || 5) * 1000
const deadline = Date.now() + 15 * 60 * 1000
let timer: ReturnType<typeof setTimeout> | null = null
let active = true
const poll = async () => {
if (!active || codexPollCancelledRef.current) return
if (Date.now() > deadline) {
setCodexLoginError(t("codex.loginTimeout"))
setCodexLoginStatus("error")
setCodexDeviceCode(null)
return
}
try {
const result = await codexPollDeviceCode({
deviceAuthId: codexDeviceCode.deviceAuthId,
userCode: codexDeviceCode.userCode,
})
if (!active || codexPollCancelledRef.current) return
if (result.status === "success") {
setCodexLoginStatus("success")
setCodexDeviceCode(null)
const authJson = JSON.stringify(
{
auth_mode: "chatgpt",
OPENAI_API_KEY: null,
tokens: {
id_token: result.idToken,
access_token: result.accessToken,
refresh_token: result.refreshToken,
account_id: result.accountId ?? "",
},
last_refresh: new Date().toISOString(),
},
null,
2
)
updateSelectedDraft((current) => ({
...current,
codexAuthJsonText: authJson,
}))
const draft = drafts.codex
if (draft) {
const codexEnvText =
draft.codexAuthMode === "chatgpt_subscription"
? patchEnvText(draft.envText, {
OPENAI_API_KEY: "",
OPENAI_BASE_URL: "",
})
: draft.envText
try {
await Promise.all([
persistEnv(
"codex",
draft.enabled,
codexEnvText,
draft.modelProviderId
),
persistConfig("codex", draft.configText, {
codexAuthJsonText: authJson,
codexConfigTomlText: draft.codexConfigTomlText,
}),
])
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
toast.error(t("codex.loginSaveFailed"), {
description: msg,
})
}
}
return
}
if (result.status === "error") {
setCodexLoginError(result.message ?? "Unknown error")
setCodexLoginStatus("error")
setCodexDeviceCode(null)
return
}
timer = setTimeout(poll, pollInterval)
} catch {
if (!active || codexPollCancelledRef.current) return
timer = setTimeout(poll, pollInterval)
}
}
timer = setTimeout(poll, pollInterval)
return () => {
active = false
if (timer) clearTimeout(timer)
}
}, [
codexLoginStatus,
codexDeviceCode,
drafts.codex,
persistConfig,
persistEnv,
updateSelectedDraft,
t,
])
useEffect(() => {
if (selectedAgent?.agent_type !== "codex" && codexLoginStatus !== "idle") {
cancelCodexDeviceLogin()
}
}, [selectedAgent, codexLoginStatus, cancelCodexDeviceLogin])
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,
}))
persistEnv(
selectedAgent.agent_type,
nextEnabled,
nextDraft.envText,
nextDraft.modelProviderId
).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>
)}
{installStream.status !== "idle" &&
streamAgentType === selectedAgent.agent_type && (
<div className="mt-2 rounded-md border bg-muted/50 text-muted-foreground p-3 max-h-[200px] overflow-y-auto font-mono text-[11px] leading-relaxed">
{installStream.logs.map((line, i) => (
<div
key={i}
className={
line.startsWith("ERROR:")
? "text-destructive"
: ""
}
>
{line}
</div>
))}
<div ref={installLogEndRef} />
</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={() => {
persistEnv(
selectedAgent.agent_type,
selectedDraft.enabled,
selectedDraft.envText,
selectedDraft.modelProviderId
)
.then(() => {
toast.success(t("toasts.configSaved"), {
description: t("toasts.configSavedHint"),
})
})
.catch((err) => {
console.error("[Settings] save env failed:", err)
const message =
err instanceof Error ? err.message : String(err)
toast.error(t("toasts.saveEnvFailed"), {
description: message,
})
})
}}
disabled={selectedIsSavingEnv}
>
{selectedIsSavingEnv ? (
<>
<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("authModeOfficialSubscription")
: mode === "model_provider"
? t("authModeModelProvider")
: t("authModeCustomEndpoint")}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-[11px] text-muted-foreground">
{selectedDraft.codexAuthMode === "chatgpt_subscription"
? t("codex.chatgptSubscriptionHint")
: selectedDraft.codexAuthMode === "model_provider"
? t("modelProviderHint")
: t("authModeCustomEndpointHint")}
</p>
</div>
{selectedDraft.codexAuthMode === "chatgpt_subscription" && (
<div className="space-y-2">
{hasCodexChatgptTokens(
selectedDraft.codexAuthJsonText
) &&
codexLoginStatus !== "polling" &&
codexLoginStatus !== "requesting" && (
<div className="flex items-center gap-1.5 text-xs text-green-600">
<CheckCircle2 className="h-3 w-3" />
{t("codex.loggedIn")}
</div>
)}
{codexLoginStatus === "idle" && (
<Button
onClick={handleCodexDeviceLogin}
size="sm"
variant="outline"
>
{hasCodexChatgptTokens(
selectedDraft.codexAuthJsonText
)
? t("codex.loginRelogin")
: t("codex.loginButton")}
</Button>
)}
{codexLoginStatus === "requesting" && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
{t("codex.loginRequesting")}
</div>
)}
{codexLoginStatus === "polling" && codexDeviceCode && (
<div className="space-y-2 rounded-md border p-3">
<p className="text-xs">{t("codex.loginStep1")}</p>
<button
type="button"
className="text-xs text-primary underline cursor-pointer"
onClick={() =>
openUrl(codexDeviceCode.verificationUrl)
}
>
{codexDeviceCode.verificationUrl}
</button>
<p className="text-xs mt-1">
{t("codex.loginStep2")}
</p>
<div className="flex items-center gap-2">
<code className="rounded bg-muted px-2 py-1 text-sm font-mono font-bold tracking-widest">
{codexDeviceCode.userCode}
</code>
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0"
onClick={() => {
navigator.clipboard.writeText(
codexDeviceCode.userCode
)
toast.success(t("codex.loginCodeCopied"))
}}
>
<Copy className="h-3 w-3" />
</Button>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground mt-1">
<Loader2 className="h-3 w-3 animate-spin" />
{t("codex.loginPolling")}
</div>
<Button
size="sm"
variant="outline"
onClick={cancelCodexDeviceLogin}
>
{t("codex.loginCancel")}
</Button>
</div>
)}
{codexLoginStatus === "success" && (
<div className="flex items-center gap-1.5 text-xs text-green-600">
<CheckCircle2 className="h-3 w-3" />
{t("codex.loginSuccess")}
</div>
)}
{codexLoginStatus === "error" && (
<div className="space-y-1.5">
<p className="text-xs text-destructive">
{t("codex.loginFailed", {
message: codexLoginError ?? "Unknown error",
})}
</p>
<Button
onClick={handleCodexDeviceLogin}
size="sm"
variant="outline"
>
{t("codex.loginRetry")}
</Button>
</div>
)}
</div>
)}
{selectedDraft.codexAuthMode === "model_provider" && (
<div className="space-y-1.5">
<label className="text-[11px] text-muted-foreground">
{t("selectModelProvider")}
</label>
{selectedModelProviders.length > 0 ? (
<Select
value={
selectedDraft.modelProviderId != null
? String(selectedDraft.modelProviderId)
: ""
}
onValueChange={handleModelProviderSelect}
>
<SelectTrigger className="w-full">
<SelectValue
placeholder={t("selectModelProvider")}
/>
</SelectTrigger>
<SelectContent align="start">
{selectedModelProviders.map((provider) => (
<SelectItem
key={provider.id}
value={String(provider.id)}
>
{provider.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<p className="text-[11px] text-muted-foreground">
{t("noModelProviderAvailable")}
</p>
)}
</div>
)}
{selectedDraft.codexAuthMode === "api_key" && (
<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 === "api_key" && (
<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 === "api_key" ||
selectedDraft.codexAuthMode === "model_provider") && (
<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">
<div className="flex items-center justify-between rounded-md border px-3 py-2">
<label className="text-[11px] text-muted-foreground">
{t("codex.enableSkills")}
</label>
<Switch
checked={selectedDraft.codexSkills}
onCheckedChange={handleCodexSkillsChange}
aria-label={t("codex.enableSkillsAria")}
/>
</div>
</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.enableFast")}
</label>
<Switch
checked={selectedDraft.codexServiceTierFast}
onCheckedChange={handleCodexServiceTierFastChange}
aria-label={t("codex.enableFastAria")}
/>
</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 max-h-80 font-mono text-xs"
/>
</div>
<div className="flex justify-end">
<Button
size="sm"
onClick={() => {
if (selectedMissingModelProvider) {
toast.error(t("toasts.modelProviderRequired"))
return
}
const codexEnvText =
selectedDraft.codexAuthMode ===
"chatgpt_subscription"
? patchEnvText(selectedDraft.envText, {
OPENAI_API_KEY: "",
OPENAI_BASE_URL: "",
})
: selectedDraft.envText
Promise.all([
persistEnv(
selectedAgent.agent_type,
selectedDraft.enabled,
codexEnvText,
selectedDraft.modelProviderId
),
persistConfig(
selectedAgent.agent_type,
selectedDraft.configText,
{
codexAuthJsonText:
selectedDraft.codexAuthJsonText,
codexConfigTomlText:
selectedDraft.codexConfigTomlText,
}
),
])
.then(() => {
toast.success(t("toasts.codexSaved"), {
description: t("toasts.configSavedHint"),
})
})
.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={selectedIsSavingEnv || selectedIsSavingConfig}
>
{selectedIsSavingEnv || selectedIsSavingConfig ? (
<>
<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>
{selectedDraft.geminiAuthMode === "model_provider" && (
<div className="space-y-1.5">
<label className="text-[11px] text-muted-foreground">
{t("selectModelProvider")}
</label>
{selectedModelProviders.length > 0 ? (
<Select
value={
selectedDraft.modelProviderId != null
? String(selectedDraft.modelProviderId)
: ""
}
onValueChange={handleModelProviderSelect}
>
<SelectTrigger className="w-full">
<SelectValue
placeholder={t("selectModelProvider")}
/>
</SelectTrigger>
<SelectContent align="start">
{selectedModelProviders.map((provider) => (
<SelectItem
key={provider.id}
value={String(provider.id)}
>
{provider.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<p className="text-[11px] text-muted-foreground">
{t("noModelProviderAvailable")}
</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" ||
selectedDraft.geminiAuthMode === "model_provider") && (
<div className="space-y-1.5">
<label className="text-[11px] text-muted-foreground">
GOOGLE_GEMINI_BASE_URL
</label>
<Input
value={selectedDraft.apiBaseUrl}
readOnly={
selectedDraft.geminiAuthMode === "model_provider"
}
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 === "model_provider" ||
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
}
readOnly={
selectedDraft.geminiAuthMode === "model_provider"
}
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={() => {
if (selectedMissingModelProvider) {
toast.error(t("toasts.modelProviderRequired"))
return
}
Promise.all([
persistEnv(
selectedAgent.agent_type,
selectedDraft.enabled,
selectedDraft.envText,
selectedDraft.modelProviderId
),
persistConfig(
selectedAgent.agent_type,
selectedDraft.configText
),
])
.then(() => {
toast.success(t("toasts.geminiSaved"), {
description: t("toasts.configSavedHint"),
})
})
.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={selectedIsSavingEnv || selectedIsSavingConfig}
>
{selectedIsSavingEnv || selectedIsSavingConfig ? (
<>
<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">
{t("openCode.mainModel")}
</label>
<OpenCodeModelCombobox
value={selectedOpenCodeConfig?.model ?? ""}
onValueChange={(v) =>
handleOpenCodeFieldChange("model", v)
}
groups={openCodeModelOptions}
placeholder="provider/model-id"
/>
</div>
<div className="space-y-1.5">
<label className="text-[11px] text-muted-foreground">
{t("openCode.smallModel")}
</label>
<OpenCodeModelCombobox
value={selectedOpenCodeConfig?.smallModel ?? ""}
onValueChange={(v) =>
handleOpenCodeFieldChange("small_model", v)
}
groups={openCodeModelOptions}
placeholder="provider/model-id"
/>
</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={() => {
persistConfig(
selectedAgent.agent_type,
selectedDraft.configText,
{
openCodeAuthJsonText:
selectedDraft.openCodeAuthJsonText,
}
)
.then(() => {
toast.success(
t("toasts.providerSaved", {
providerId,
}),
{
description: `${t("toasts.openCodeConfigSynced")} ${t("toasts.configSavedHint")}`,
}
)
})
.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={selectedIsSavingConfig}
>
{selectedIsSavingConfig ? (
<>
<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={() => {
persistConfig(
selectedAgent.agent_type,
selectedDraft.configText,
{
openCodeAuthJsonText:
selectedDraft.openCodeAuthJsonText,
}
)
.then(() => {
toast.success(t("toasts.openCodeSaved"), {
description: t("toasts.configSavedHint"),
})
})
.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={selectedIsSavingConfig}
>
{selectedIsSavingConfig ? (
<>
<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 === "cline" ? (
<div className="space-y-3 rounded-md border bg-muted/10 p-3">
<div>
<label className="text-xs font-medium">Cline</label>
<p className="mt-1 text-[11px] text-muted-foreground">
{t("cline.configDescription")}
</p>
</div>
<div className="space-y-1.5">
<label className="text-[11px] text-muted-foreground">
Provider
</label>
<Select
value={selectedDraft.clineProvider}
onValueChange={(value) => {
handleClineFieldChange("clineProvider", value)
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{CLINE_PROVIDERS.map((p) => (
<SelectItem key={p.value} value={p.value}>
{p.label}
</SelectItem>
))}
</SelectContent>
</Select>
</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.clineApiKey}
onChange={(event) => {
handleClineFieldChange(
"clineApiKey",
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>
<div className="space-y-1.5">
<label className="text-[11px] text-muted-foreground">
Model
</label>
<Input
value={selectedDraft.clineModel}
onChange={(event) => {
handleClineFieldChange(
"clineModel",
event.target.value
)
}}
placeholder="claude-sonnet-4-5-20250514"
/>
</div>
<div className="space-y-1.5">
<label className="text-[11px] text-muted-foreground">
API URL
</label>
<Input
value={selectedDraft.clineBaseUrl}
onChange={(event) => {
handleClineFieldChange(
"clineBaseUrl",
event.target.value
)
}}
placeholder="https://api.openai.com"
/>
</div>
<div className="space-y-1.5">
<label className="text-[11px] text-muted-foreground">
{t("nativeJsonConfig")} (config)
</label>
<Textarea
value={selectedDraft.configText}
onChange={(event) => {
handleConfigTextChange(event.target.value)
}}
className="min-h-24 font-mono text-xs"
placeholder={`{
"apiProvider": "anthropic",
"apiKey": "sk-...",
"model": "claude-sonnet-4-5-20250514"
}`}
/>
{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 items-center justify-end gap-2">
<Button
size="sm"
onClick={() => {
persistConfig(
selectedAgent.agent_type,
selectedDraft.configText
)
.then(() => {
toast.success(t("toasts.clineSaved"), {
description: t("toasts.configSavedHint"),
})
})
.catch((err) => {
console.error(
"[Settings] save cline config failed:",
err
)
const message =
err instanceof Error ? err.message : String(err)
toast.error(t("toasts.saveClineFailed"), {
description: message,
})
})
}}
disabled={selectedIsSavingConfig}
>
{selectedIsSavingConfig ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
{t("actions.saving")}
</>
) : (
<>
<Save className="h-3.5 w-3.5" />
{t("actions.saveClineConfig")}
</>
)}
</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={() => {
Promise.all([
persistEnv(
selectedAgent.agent_type,
selectedDraft.enabled,
selectedDraft.envText,
selectedDraft.modelProviderId
),
persistConfig(
selectedAgent.agent_type,
selectedDraft.configText
),
])
.then(() => {
toast.success(t("toasts.openClawSaved"), {
description: t("toasts.configSavedHint"),
})
})
.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={selectedIsSavingEnv || selectedIsSavingConfig}
>
{selectedIsSavingEnv || selectedIsSavingConfig ? (
<>
<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>
{selectedAgent.agent_type === "claude_code" && (
<div className="space-y-1.5">
<label className="text-[11px] text-muted-foreground">
{t("claude.authMode")}
</label>
<Select
value={selectedDraft.claudeAuthMode}
onValueChange={(value) => {
if (
CLAUDE_AUTH_MODES.includes(
value as ClaudeAuthMode
)
) {
handleClaudeAuthModeChange(
value as ClaudeAuthMode
)
}
}}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent align="start">
<SelectItem value="official_subscription">
{t("authModeOfficialSubscription")}
</SelectItem>
<SelectItem value="custom">
{t("authModeCustomEndpoint")}
</SelectItem>
<SelectItem value="model_provider">
{t("authModeModelProvider")}
</SelectItem>
</SelectContent>
</Select>
<p className="text-[11px] text-muted-foreground">
{selectedDraft.claudeAuthMode ===
"official_subscription"
? t("claude.officialSubscriptionHint")
: selectedDraft.claudeAuthMode === "custom"
? t("authModeCustomEndpointHint")
: t("modelProviderHint")}
</p>
</div>
)}
{selectedAgent.agent_type === "claude_code" &&
selectedDraft.claudeAuthMode === "model_provider" && (
<div className="space-y-1.5">
<label className="text-[11px] text-muted-foreground">
{t("selectModelProvider")}
</label>
{selectedModelProviders.length > 0 ? (
<Select
value={
selectedDraft.modelProviderId != null
? String(selectedDraft.modelProviderId)
: ""
}
onValueChange={handleModelProviderSelect}
>
<SelectTrigger className="w-full">
<SelectValue
placeholder={t("selectModelProvider")}
/>
</SelectTrigger>
<SelectContent align="start">
{selectedModelProviders.map((provider) => (
<SelectItem
key={provider.id}
value={String(provider.id)}
>
{provider.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<p className="text-[11px] text-muted-foreground">
{t("noModelProviderAvailable")}
</p>
)}
</div>
)}
{(selectedAgent.agent_type !== "claude_code" ||
selectedDraft.claudeAuthMode === "custom") && (
<>
<div className="space-y-1.5">
<label className="text-[11px] text-muted-foreground">
API URL
</label>
<Input
value={selectedDraft.apiBaseUrl}
readOnly={
selectedAgent.agent_type === "claude_code" &&
selectedDraft.claudeAuthMode === "model_provider"
}
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}
readOnly={
selectedAgent.agent_type === "claude_code" &&
selectedDraft.claudeAuthMode ===
"model_provider"
}
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.7"
/>
</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.7"
/>
</div>
</div>
<p className="text-[11px] text-muted-foreground">
{t("modelHintDefault")}
</p>
<div className="space-y-1.5">
<label className="text-[11px] text-muted-foreground">
{t("claude.effortLevel")}
</label>
<Select
value={selectedDraft.claudeEffortLevel || "default"}
onValueChange={(nextValue) => {
handleClaudeEffortLevelChange(
nextValue === "default"
? ""
: (nextValue as ClaudeEffortLevel)
)
}}
>
<SelectTrigger className="w-full">
<SelectValue
placeholder={t("claude.effortLevelDefault")}
/>
</SelectTrigger>
<SelectContent align="start">
<SelectItem value="default">
{t("claude.effortLevelDefault")}
</SelectItem>
{CLAUDE_EFFORT_LEVEL_VALUES.map((value) => (
<SelectItem key={value} value={value}>
{t(`claude.effortLevel_${value}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</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={() => {
if (selectedMissingModelProvider) {
toast.error(t("toasts.modelProviderRequired"))
return
}
Promise.all([
persistEnv(
selectedAgent.agent_type,
selectedDraft.enabled,
selectedDraft.envText,
selectedDraft.modelProviderId
),
persistConfig(
selectedAgent.agent_type,
selectedDraft.configText
),
])
.then(() => {
toast.success(t("toasts.configSaved"), {
description: t("toasts.configSavedHint"),
})
})
.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={selectedIsSavingEnv || selectedIsSavingConfig}
>
{selectedIsSavingEnv || selectedIsSavingConfig ? (
<>
<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>
<OpencodePluginsModal
open={pluginModalOpen}
onOpenChange={setPluginModalOpen}
onCompleted={() => {
if (pluginModalAgent) {
runPreflight(pluginModalAgent)
}
setPluginModalAgent(null)
}}
/>
</div>
)
}