/** * Settings AI Tab - AI provider configuration, agent CLI detection, and safety settings * * Sub-components live in ./ai/ directory: * - ProviderCard, ProviderConfigForm, AddProviderDropdown * - ModelSelector * - CodexConnectionCard, ClaudeCodeCard, CodebuddyCard * - SafetySettings */ import { AlertTriangle, Bot, FolderOpen, RefreshCcw } from "lucide-react"; import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { AIPermissionMode, AIProviderId, AIToolIntegrationMode, CursorAuthMode, ExternalAgentConfig, ProviderConfig, WebSearchConfig, } from "../../../infrastructure/ai/types"; import type { ManagedAgentKey } from "../../../infrastructure/ai/managedAgents"; import { PROVIDER_PRESETS } from "../../../infrastructure/ai/types"; import { useI18n } from "../../../application/i18n/I18nProvider"; import { Button } from "../../ui/button"; import { ConfirmDialog } from "../../ui/confirm-dialog"; import { Select, SettingCard, SettingsAnchor, SettingsSection, SettingsTabContent, SettingRow, Toggle } from "../settings-ui"; import { useOptionalSettingsFocus } from "../SettingsFocusContext"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "../../ui/tabs"; import { AgentIconBadge } from "../../ai/AgentIconBadge"; import { canSendWithAgent } from "../../ai/agentSendEligibility"; import { notifyUserSkillsStatusChanged } from "../../ai/userSkillsStatusEvents"; import type { AgentPathInfo, CodexAppServerStatus, CodexIntegrationStatus, CodexLoginSession, UserSkillsStatusResult, } from "./ai/types"; import { AGENT_DEFAULTS, getBridge, isCursorAvailableForMode, normalizeCodexBridgeError, } from "./ai/types"; import { ProviderCard } from "./ai/ProviderCard"; import { AddProviderDropdown } from "./ai/AddProviderDropdown"; import { CodexConnectionCard } from "./ai/CodexConnectionCard"; import { ClaudeCodeCard } from "./ai/ClaudeCodeCard"; import { CopilotCliCard } from "./ai/CopilotCliCard"; import { CodebuddyCard } from "./ai/CodebuddyCard"; import { SafetySettings } from "./ai/SafetySettings"; import { ExternalMcpCard } from "./ai/ExternalMcpCard"; import { ToolAccessGuidance } from "./ai/ToolAccessGuidance"; import { PermissionGrantsSettings } from "./ai/PermissionGrantsSettings"; import { useAIPermissionGrantsState } from "../../../application/state/useAIPermissionGrantsState"; import { WebSearchSettings } from "./ai/WebSearchSettings"; import { QuickMessagesSettings } from "./ai/QuickMessagesSettings"; import type { AIQuickMessage } from "../../../infrastructure/ai/quickMessages"; import { encryptField } from "../../../infrastructure/persistence/secureFieldAdapter"; import { CursorSdkCard } from "./ai/CursorSdkCard"; import { areExternalAgentListsEqual, buildManagedAgentState, getInitialManagedAgentPaths, updateCodebuddyManagedEnv, updateCodebuddyManagedOptions, } from "./ai/managedAgentState"; import { splitClaudeEnv, buildClaudeEnv } from "./ai/claudeConfigEnv"; import { splitCodebuddyEnv } from "./ai/codebuddyConfigEnv"; type IdleWindow = Window & { requestIdleCallback?: (callback: IdleRequestCallback, options?: IdleRequestOptions) => number; cancelIdleCallback?: (handle: number) => void; }; function scheduleAfterFirstPaint(callback: () => void, delayMs = 0): () => void { let cancelled = false; let idleHandle: number | null = null; const timeoutHandle = window.setTimeout(() => { if (cancelled) return; const idleWindow = window as IdleWindow; if (typeof idleWindow.requestIdleCallback === "function") { idleHandle = idleWindow.requestIdleCallback(() => { if (!cancelled) callback(); }, { timeout: 1200 }); return; } callback(); }, delayMs); return () => { cancelled = true; window.clearTimeout(timeoutHandle); if (idleHandle !== null) { (window as IdleWindow).cancelIdleCallback?.(idleHandle); } }; } type AISettingsSubTab = "providers" | "agents" | "tools" | "search" | "safety"; function getSavedManagedAgentPathInfo( agents: ExternalAgentConfig[], agentKey: ManagedAgentKey, ): AgentPathInfo | null { const managed = agents.find((agent) => agent.id === `discovered_${agentKey}`); const command = typeof managed?.command === "string" ? managed.command.trim() : ""; if (!managed || !command) return null; const savedAvailable = managed.available === true || managed.enabled === true; return { path: command, binPath: command, version: null, available: savedAvailable, installed: true, authenticated: undefined, authSource: null, }; } function getManagedAgentCommandPath( agents: ExternalAgentConfig[], agentKey: ManagedAgentKey, ): string { const managed = agents.find((agent) => agent.id === `discovered_${agentKey}`); const command = typeof managed?.command === "string" ? managed.command.trim() : ""; return command && (command.includes("/") || command.includes("\\")) ? command : ""; } // --------------------------------------------------------------------------- // Props // --------------------------------------------------------------------------- interface SettingsAITabProps { providers: ProviderConfig[]; addProvider: (provider: ProviderConfig) => void; updateProvider: (id: string, updates: Partial) => void; removeProvider: (id: string) => void; activeProviderId: string; setActiveProviderId: (id: string) => void; activeModelId: string; setActiveModelId: (id: string) => void; globalPermissionMode: AIPermissionMode; setGlobalPermissionMode: (mode: AIPermissionMode) => void; toolIntegrationMode: AIToolIntegrationMode; setToolIntegrationMode: (mode: AIToolIntegrationMode) => void; externalAgents: ExternalAgentConfig[]; setExternalAgents: (value: ExternalAgentConfig[] | ((prev: ExternalAgentConfig[]) => ExternalAgentConfig[])) => void; defaultAgentId: string; setDefaultAgentId: (id: string) => void; commandBlocklist: string[]; setCommandBlocklist: (value: string[]) => void; commandTimeout: number; setCommandTimeout: (value: number) => void; responseIdleTimeout: number; setResponseIdleTimeout: (value: number) => void; maxIterations: number; setMaxIterations: (value: number) => void; webSearchConfig: WebSearchConfig | null; setWebSearchConfig: (config: WebSearchConfig | null) => void; quickMessages: AIQuickMessage[]; setQuickMessages: (value: AIQuickMessage[] | ((prev: AIQuickMessage[]) => AIQuickMessage[])) => void; showTerminalSelectionAIAction: boolean; setShowTerminalSelectionAIAction: (value: boolean | ((prev: boolean) => boolean)) => void; } // --------------------------------------------------------------------------- // Main Tab Component // --------------------------------------------------------------------------- const SettingsAITab: React.FC = ({ providers, addProvider, updateProvider, removeProvider, activeProviderId, setActiveProviderId, activeModelId: _activeModelId, setActiveModelId, globalPermissionMode, setGlobalPermissionMode, toolIntegrationMode, setToolIntegrationMode, externalAgents, setExternalAgents, defaultAgentId, setDefaultAgentId, commandBlocklist, setCommandBlocklist, commandTimeout, setCommandTimeout, responseIdleTimeout, setResponseIdleTimeout, maxIterations, setMaxIterations, webSearchConfig, setWebSearchConfig, quickMessages, setQuickMessages, showTerminalSelectionAIAction, setShowTerminalSelectionAIAction, }) => { const { permissionGrants, addGrant, updateGrant, removeGrant, importGrants, exportGrants, } = useAIPermissionGrantsState(); const { t } = useI18n(); const [editingProviderId, setEditingProviderId] = useState(null); const [removeProviderConfirm, setRemoveProviderConfirm] = useState<{ id: string; name: string } | null>(null); const [codexIntegration, setCodexIntegration] = useState(null); const [codexLoginSession, setCodexLoginSession] = useState(null); const [isCodexLoading, setIsCodexLoading] = useState(false); const [codexError, setCodexError] = useState(null); const [codexAppServerStatus, setCodexAppServerStatus] = useState(null); const initialManagedPathsRef = useRef<{ codex: string; claude: string; copilot: string; cursor: string; codebuddy: string; opencode: string; grok: string; } | null>(null); if (!initialManagedPathsRef.current) { initialManagedPathsRef.current = getInitialManagedAgentPaths(externalAgents); } // Path detection state const [codexPathInfo, setCodexPathInfo] = useState( () => getSavedManagedAgentPathInfo(externalAgents, "codex"), ); const [codexCustomPath, setCodexCustomPath] = useState(() => initialManagedPathsRef.current?.codex ?? ""); const [isResolvingCodex, setIsResolvingCodex] = useState(false); const [activeSubTab, setActiveSubTab] = useState("providers"); const settingsFocus = useOptionalSettingsFocus(); const [claudePathInfo, setClaudePathInfo] = useState( () => getSavedManagedAgentPathInfo(externalAgents, "claude"), ); const [claudeCustomPath, setClaudeCustomPath] = useState(() => initialManagedPathsRef.current?.claude ?? ""); const [isResolvingClaude, setIsResolvingClaude] = useState(false); const claudeManagedEnv = useMemo( () => externalAgents.find((a) => a.id === "discovered_claude")?.env, [externalAgents], ); const { configDir: claudeConfigDir, settingsPath: claudeSettingsPath, envText: claudeEnvText, } = useMemo(() => splitClaudeEnv(claudeManagedEnv), [claudeManagedEnv]); const updateClaudeEnv = useCallback( (nextConfigDir: string, nextSettingsPath: string, nextEnvText: string) => { setExternalAgents((prev) => prev.map((a) => a.id === "discovered_claude" ? { ...a, env: buildClaudeEnv(a.env, nextConfigDir, nextSettingsPath, nextEnvText) } : a, ), ); }, [setExternalAgents], ); const [copilotPathInfo, setCopilotPathInfo] = useState( () => getSavedManagedAgentPathInfo(externalAgents, "copilot"), ); const [copilotCustomPath, setCopilotCustomPath] = useState(() => initialManagedPathsRef.current?.copilot ?? ""); const [isResolvingCopilot, setIsResolvingCopilot] = useState(false); const [cursorPathInfo, setCursorPathInfo] = useState( () => getSavedManagedAgentPathInfo(externalAgents, "cursor"), ); const [isResolvingCursor, setIsResolvingCursor] = useState(false); const [codebuddyPathInfo, setCodebuddyPathInfo] = useState( () => getSavedManagedAgentPathInfo(externalAgents, "codebuddy"), ); const [codebuddyCustomPath, setCodebuddyCustomPath] = useState(() => initialManagedPathsRef.current?.codebuddy ?? ""); const [isResolvingCodebuddy, setIsResolvingCodebuddy] = useState(false); const [opencodePathInfo, setOpencodePathInfo] = useState( () => getSavedManagedAgentPathInfo(externalAgents, "opencode"), ); const [opencodeCustomPath, setOpencodeCustomPath] = useState(() => initialManagedPathsRef.current?.opencode ?? ""); const [isResolvingOpencode, setIsResolvingOpencode] = useState(false); const [grokPathInfo, setGrokPathInfo] = useState( () => getSavedManagedAgentPathInfo(externalAgents, "grok"), ); const [grokCustomPath, setGrokCustomPath] = useState(() => initialManagedPathsRef.current?.grok ?? ""); const [isResolvingGrok, setIsResolvingGrok] = useState(false); const codebuddyManagedAgent = useMemo( () => externalAgents.find((a) => a.id === "discovered_codebuddy"), [externalAgents], ); const codebuddyManagedEnv = codebuddyManagedAgent?.env; const codebuddyAdvancedOptions = codebuddyManagedAgent?.codebuddyOptions; const updateCodebuddyAdvancedOptions = useCallback( (options: import("../../../infrastructure/ai/types").CodebuddyAdvancedOptions | undefined) => { setExternalAgents((prev) => updateCodebuddyManagedOptions(prev, options)); }, [setExternalAgents], ); const { internetEnv: codebuddyInternetEnv, envText: codebuddyEnvText, } = useMemo(() => splitCodebuddyEnv(codebuddyManagedEnv), [codebuddyManagedEnv]); const updateCodebuddyEnv = useCallback( (nextInternetEnv: string, nextEnvText: string) => { setExternalAgents((prev) => updateCodebuddyManagedEnv(prev, nextInternetEnv, nextEnvText), ); }, [setExternalAgents], ); const [userSkillsStatus, setUserSkillsStatus] = useState(null); const [isLoadingUserSkills, setIsLoadingUserSkills] = useState(false); const cursorManagedAgent = useMemo( () => externalAgents.find((agent) => agent.id === "discovered_cursor"), [externalAgents], ); const cursorApiKeyEncrypted = cursorManagedAgent?.apiKey; const cursorAuthMode: CursorAuthMode = cursorManagedAgent?.cursorAuthMode === "cli-login" ? "cli-login" : "api-key"; // Ref to read current defaultAgentId without adding it as a dependency. const defaultAgentIdRef = useRef(defaultAgentId); defaultAgentIdRef.current = defaultAgentId; const autoResolvedAgentStateRef = useRef>>({}); const codexIntegrationLoadedRef = useRef(false); const userSkillsLoadedRef = useRef(false); const mountedRef = useRef(true); const agentPathRequestIdRef = useRef>>({}); const codexRequestIdRef = useRef(0); useEffect(() => { mountedRef.current = true; const agentPathRequestIds = agentPathRequestIdRef.current; return () => { mountedRef.current = false; codexRequestIdRef.current += 1; for (const key of ["codex", "claude", "copilot", "cursor", "codebuddy", "opencode", "grok"] as ManagedAgentKey[]) { agentPathRequestIds[key] = (agentPathRequestIds[key] ?? 0) + 1; } }; }, []); const applyResolvedAgentPath = useCallback(( agentKey: ManagedAgentKey, result: AgentPathInfo | null, commandSource: "manual" | "auto" = "auto", ) => { const setInfo = agentKey === "codex" ? setCodexPathInfo : agentKey === "claude" ? setClaudePathInfo : agentKey === "copilot" ? setCopilotPathInfo : agentKey === "cursor" ? setCursorPathInfo : agentKey === "codebuddy" ? setCodebuddyPathInfo : agentKey === "opencode" ? setOpencodePathInfo : setGrokPathInfo; setInfo(result); let nextDefaultId: string | null = null; setExternalAgents((prev) => { const state = buildManagedAgentState(prev, defaultAgentIdRef.current, agentKey, result, commandSource); if (state.defaultAgentId !== defaultAgentIdRef.current) { nextDefaultId = state.defaultAgentId; defaultAgentIdRef.current = state.defaultAgentId; } return areExternalAgentListsEqual(prev, state.agents) ? prev : state.agents; }); if (nextDefaultId !== null) { setDefaultAgentId(nextDefaultId); } }, [setDefaultAgentId, setExternalAgents]); const resolveAgentPath = useCallback(async ( agentKey: ManagedAgentKey, customPath = "", options?: { apiKeyPresent?: boolean; refreshShellEnv?: boolean; commandSource?: "manual" | "auto"; removeUnavailableManualPath?: boolean; }, ) => { const bridge = getBridge(); if (!bridge?.aiResolveCli) return null; const setResolving = agentKey === "codex" ? setIsResolvingCodex : agentKey === "claude" ? setIsResolvingClaude : agentKey === "copilot" ? setIsResolvingCopilot : agentKey === "cursor" ? setIsResolvingCursor : agentKey === "codebuddy" ? setIsResolvingCodebuddy : agentKey === "opencode" ? setIsResolvingOpencode : setIsResolvingGrok; setResolving(true); const requestId = (agentPathRequestIdRef.current[agentKey] ?? 0) + 1; agentPathRequestIdRef.current[agentKey] = requestId; const isCurrentRequest = () => ( mountedRef.current && agentPathRequestIdRef.current[agentKey] === requestId ); try { const result = await bridge.aiResolveCli({ command: agentKey, customPath: customPath.trim(), refreshShellEnv: Boolean(options?.refreshShellEnv), ...(agentKey === "cursor" ? { // Always report stored key presence so discovery can set apiKeyOk even // while the user is in CLI-login mode (mode is separate from capability). apiKeyPresent: (options?.apiKeyPresent !== undefined ? Boolean(options.apiKeyPresent) : Boolean(cursorApiKeyEncrypted)), } : {}), }); if (!isCurrentRequest()) return null; if ( options?.commandSource === "manual" && customPath.trim() && !result?.available && !options.removeUnavailableManualPath ) { const setInfo = agentKey === "codex" ? setCodexPathInfo : agentKey === "claude" ? setClaudePathInfo : agentKey === "copilot" ? setCopilotPathInfo : agentKey === "cursor" ? setCursorPathInfo : agentKey === "codebuddy" ? setCodebuddyPathInfo : agentKey === "opencode" ? setOpencodePathInfo : setGrokPathInfo; setInfo(result); return result; } applyResolvedAgentPath(agentKey, result, options?.commandSource ?? "auto"); return result; } catch (err) { console.error("Path resolution failed:", err); return null; } finally { if (isCurrentRequest()) { setResolving(false); } } }, [applyResolvedAgentPath, cursorApiKeyEncrypted]); useEffect(() => { if (activeSubTab !== "agents") return; const initialPaths = initialManagedPathsRef.current; const tasks: Array<{ key: ManagedAgentKey; delayMs: number; path: string; options?: { apiKeyPresent?: boolean }; }> = [ { key: "codex", delayMs: 160, path: initialPaths?.codex ?? "" }, { key: "claude", delayMs: 440, path: initialPaths?.claude ?? "" }, { key: "copilot", delayMs: 720, path: initialPaths?.copilot ?? "" }, { key: "cursor", delayMs: 1000, path: initialPaths?.cursor ?? "", options: { apiKeyPresent: Boolean(cursorApiKeyEncrypted) }, }, { key: "codebuddy", delayMs: 1280, path: initialPaths?.codebuddy ?? "" }, { key: "opencode", delayMs: 1560, path: initialPaths?.opencode ?? "" }, { key: "grok", delayMs: 1840, path: initialPaths?.grok ?? "" }, ]; const cancelTasks = tasks .filter((task) => !autoResolvedAgentStateRef.current[task.key]) .map((task) => scheduleAfterFirstPaint(() => { autoResolvedAgentStateRef.current[task.key] = "pending"; void resolveAgentPath(task.key, task.path, { ...task.options, commandSource: task.path ? "manual" : "auto", removeUnavailableManualPath: Boolean(task.path), }).finally(() => { autoResolvedAgentStateRef.current[task.key] = "done"; }); }, task.delayMs)); return () => { for (const cancel of cancelTasks) cancel(); }; }, [activeSubTab, cursorApiKeyEncrypted, cursorAuthMode, resolveAgentPath]); const handleSaveCursorApiKey = useCallback(async (apiKey: string) => { const trimmed = apiKey.trim(); const encrypted = trimmed ? await encryptField(trimmed) : undefined; const result = await resolveAgentPath("cursor", "", { apiKeyPresent: Boolean(trimmed) }); setExternalAgents((prev) => { const existing = prev.find((agent) => agent.id === "discovered_cursor"); const others = prev.filter((agent) => agent.id !== "discovered_cursor"); if (!encrypted && !existing) return prev; if (!encrypted && existing && !result?.available) return others; const modeAvailable = isCursorAvailableForMode(result, "api-key", { hasStoredApiKey: Boolean(encrypted), }); const nextAgent: ExternalAgentConfig = { ...(existing ?? { id: "discovered_cursor", name: "Cursor", command: result?.path || cursorPathInfo?.path || "cursor", args: ["{prompt}"], icon: "cursor", sdkBackend: "cursor", enabled: false, }), apiKey: encrypted, cursorAuthMode: "api-key", command: result?.path || existing?.command || cursorPathInfo?.path || "cursor", available: modeAvailable, // Preserve enable preference; available alone gates send for this mode. enabled: existing?.enabled ?? true, }; return [...others, nextAgent]; }); }, [cursorPathInfo?.path, resolveAgentPath, setExternalAgents]); const handleCursorAuthModeChange = useCallback((mode: CursorAuthMode) => { setExternalAgents((prev) => { const existing = prev.find((agent) => agent.id === "discovered_cursor"); const others = prev.filter((agent) => agent.id !== "discovered_cursor"); const preservedApiKey = existing?.apiKey || cursorApiKeyEncrypted; const nextAgent: ExternalAgentConfig = { ...(existing ?? { id: "discovered_cursor", name: "Cursor", command: cursorPathInfo?.path || "cursor", args: ["{prompt}"], icon: "cursor", sdkBackend: "cursor", enabled: false, }), cursorAuthMode: mode, // Explicitly keep stored API key when switching CLI ↔ API Key modes. // CLI turns omit CURSOR_API_KEY via env wiring without destroying credentials. ...(preservedApiKey ? { apiKey: preservedApiKey } : {}), command: mode === "cli-login" ? (cursorPathInfo?.cliBinPath || cursorPathInfo?.path || existing?.command || "cursor") : (existing?.command || cursorPathInfo?.path || "cursor"), available: isCursorAvailableForMode(cursorPathInfo, mode, { hasStoredApiKey: Boolean(preservedApiKey), }), // Preserve user enable preference across mode peeks; `available` alone // gates send eligibility when the mode cannot run. enabled: existing?.enabled ?? true, }; return [...others, nextAgent]; }); void resolveAgentPath("cursor", "", { // Always report stored key presence for discovery fields; mode is separate. apiKeyPresent: Boolean(cursorApiKeyEncrypted), }); }, [cursorApiKeyEncrypted, cursorPathInfo, resolveAgentPath, setExternalAgents]); // Add a new provider from preset const handleAddProvider = useCallback( (providerId: AIProviderId) => { const preset = PROVIDER_PRESETS[providerId]; const id = `provider_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`; addProvider({ id, providerId, name: preset.name, baseURL: preset.defaultBaseURL, enabled: false, }); // Auto-open config form setEditingProviderId(id); }, [addProvider], ); // Remove provider with confirmation const handleRemoveProvider = useCallback( (id: string) => { const provider = providers.find((p) => p.id === id); const name = provider?.name || id; setRemoveProviderConfirm({ id, name }); }, [providers], ); const handleConfirmRemoveProvider = useCallback( () => { if (!removeProviderConfirm) return; const { id } = removeProviderConfirm; removeProvider(id); if (editingProviderId === id) { setEditingProviderId(null); } setRemoveProviderConfirm(null); }, [removeProvider, editingProviderId, removeProviderConfirm], ); // Agent options for default agent const agentOptions = useMemo(() => [ { value: "catty", label: t('ai.defaultAgent.catty'), icon: }, ...externalAgents .filter((a) => canSendWithAgent(a.id, externalAgents)) .map((a) => ({ value: a.id, label: a.name, icon: })), ], [externalAgents, t]); useEffect(() => { if (!agentOptions.some((option) => option.value === defaultAgentId)) { setDefaultAgentId("catty"); } }, [agentOptions, defaultAgentId, setDefaultAgentId]); useEffect(() => { const bridge = getBridge(); if (!bridge?.aiPrewarmShellEnv) return; return scheduleAfterFirstPaint(() => { void bridge.aiPrewarmShellEnv?.(); }, 900); }, []); const refreshCodexIntegration = useCallback(async (opts?: { refreshShellEnv?: boolean; validateChatGptAuth?: boolean; codexPath?: string }) => { const bridge = getBridge(); if (!bridge?.aiCodexGetIntegration) return; const requestId = codexRequestIdRef.current + 1; codexRequestIdRef.current = requestId; const isCurrentRequest = () => mountedRef.current && codexRequestIdRef.current === requestId; setIsCodexLoading(true); setCodexError(null); try { const integration = await bridge.aiCodexGetIntegration(opts); if (!isCurrentRequest()) return; setCodexIntegration(integration); } catch (err) { if (isCurrentRequest()) { setCodexError(normalizeCodexBridgeError(err)); } } finally { if (isCurrentRequest()) { setIsCodexLoading(false); } } }, []); const codexCommittedPath = useMemo( () => getManagedAgentCommandPath(externalAgents, "codex") || codexPathInfo?.path || undefined, [externalAgents, codexPathInfo?.path], ); const hasPendingCodexCustomPath = Boolean( codexCustomPath.trim() && codexCustomPath.trim() !== codexCommittedPath, ); const getCodexPathOverride = useCallback(() => ( codexCommittedPath ), [codexCommittedPath]); const codexManagedAgent = useMemo( () => externalAgents.find((agent) => agent.id === "discovered_codex"), [externalAgents], ); const codexRuntime = codexManagedAgent?.codexRuntime ?? 'sdk'; const refreshCodexAppServerStatus = useCallback(async () => { const bridge = getBridge(); if (!bridge?.codexAppServerGetStatus || !codexCommittedPath) { setCodexAppServerStatus(null); return; } setCodexAppServerStatus({ available: false, checking: true }); try { const result = await bridge.codexAppServerGetStatus(codexCommittedPath, codexManagedAgent?.env); setCodexAppServerStatus({ available: result?.available === true, error: result?.available ? undefined : result?.error, }); } catch (error) { setCodexAppServerStatus({ available: false, error: normalizeCodexBridgeError(error), }); } }, [codexCommittedPath, codexManagedAgent?.env]); const handleCodexRuntimeChange = useCallback((runtime: 'sdk' | 'app-server') => { setExternalAgents((agents) => agents.map((agent) => ( agent.id === "discovered_codex" ? { ...agent, codexRuntime: runtime } : agent ))); }, [setExternalAgents]); const handleGrokRuntimeChange = useCallback((runtime: 'acp' | 'streaming-json') => { setExternalAgents((agents) => { const managedId = "discovered_grok"; const existing = agents.find((agent) => agent.id === managedId); if (existing) { return agents.map((agent) => ( agent.id === managedId ? { ...agent, grokRuntime: runtime } : agent )); } // Path not yet resolved: still persist preference on a disabled placeholder. return [ ...agents, { ...AGENT_DEFAULTS.grok, id: managedId, command: "grok", enabled: false, available: false, grokRuntime: runtime, }, ]; }); }, [setExternalAgents]); // Validate a custom path for an agent. const handleCheckCustomPath = useCallback(async (agentKey: ManagedAgentKey) => { const customPath = agentKey === "codex" ? codexCustomPath : agentKey === "claude" ? claudeCustomPath : agentKey === "copilot" ? copilotCustomPath : agentKey === "codebuddy" ? codebuddyCustomPath : agentKey === "opencode" ? opencodeCustomPath : agentKey === "grok" ? grokCustomPath : ""; const result = await resolveAgentPath(agentKey, customPath, { refreshShellEnv: true, commandSource: customPath.trim() ? "manual" : "auto", }); if (agentKey === "codex") { await refreshCodexIntegration({ refreshShellEnv: true, validateChatGptAuth: true, codexPath: result?.path || customPath.trim() || undefined, }); } }, [claudeCustomPath, codexCustomPath, copilotCustomPath, codebuddyCustomPath, opencodeCustomPath, grokCustomPath, resolveAgentPath, refreshCodexIntegration]); const handleResetCustomPath = useCallback(async (agentKey: ManagedAgentKey) => { if (agentKey === "codex") { setCodexCustomPath(""); } else if (agentKey === "claude") { setClaudeCustomPath(""); } else if (agentKey === "copilot") { setCopilotCustomPath(""); } else if (agentKey === "codebuddy") { setCodebuddyCustomPath(""); } else if (agentKey === "opencode") { setOpencodeCustomPath(""); } else if (agentKey === "grok") { setGrokCustomPath(""); } const result = await resolveAgentPath(agentKey, "", { refreshShellEnv: true, commandSource: "auto", ...(agentKey === "cursor" ? { apiKeyPresent: Boolean(cursorApiKeyEncrypted), } : {}), }); if (agentKey === "codex") { await refreshCodexIntegration({ refreshShellEnv: true, validateChatGptAuth: true, codexPath: result?.path || undefined, }); } }, [cursorApiKeyEncrypted, resolveAgentPath, refreshCodexIntegration]); useEffect(() => { if (activeSubTab !== "agents") return; if (codexIntegrationLoadedRef.current) return; return scheduleAfterFirstPaint(() => { codexIntegrationLoadedRef.current = true; void refreshCodexIntegration({ codexPath: getCodexPathOverride() }); }, 620); }, [activeSubTab, getCodexPathOverride, refreshCodexIntegration]); useEffect(() => { if (activeSubTab !== "agents" || !codexPathInfo?.available) return; return scheduleAfterFirstPaint(() => { void refreshCodexAppServerStatus(); }, 760); }, [activeSubTab, codexPathInfo?.available, refreshCodexAppServerStatus]); useEffect(() => { if (!codexLoginSession || codexLoginSession.state !== "running") { return; } const bridge = getBridge(); if (!bridge?.aiCodexGetLoginSession) { return; } let cancelled = false; const intervalId = window.setInterval(() => { void bridge.aiCodexGetLoginSession?.(codexLoginSession.sessionId).then((result) => { if (cancelled || !result?.ok || !result.session) return; setCodexLoginSession(result.session); if (result.session.state !== "running") { if (result.session.state === "success") { void refreshCodexIntegration({ validateChatGptAuth: true, codexPath: result.session.codexPath || getCodexPathOverride(), }); } } }).catch((err) => { if (!cancelled) { setCodexError(normalizeCodexBridgeError(err)); } }); }, 1000); return () => { cancelled = true; window.clearInterval(intervalId); }; }, [codexLoginSession, getCodexPathOverride, refreshCodexIntegration]); const handleStartCodexLogin = useCallback(async () => { const bridge = getBridge(); if (!bridge?.aiCodexStartLogin) return; const requestId = codexRequestIdRef.current + 1; codexRequestIdRef.current = requestId; const isCurrentRequest = () => mountedRef.current && codexRequestIdRef.current === requestId; setCodexError(null); setIsCodexLoading(true); try { const result = await bridge.aiCodexStartLogin({ codexPath: getCodexPathOverride() }); if (!isCurrentRequest()) return; if (!result.ok || !result.session) { throw new Error(result.error || "Failed to start Codex login"); } setCodexLoginSession(result.session); } catch (err) { if (isCurrentRequest()) { setCodexError(normalizeCodexBridgeError(err)); } } finally { if (isCurrentRequest()) { setIsCodexLoading(false); } } }, [getCodexPathOverride]); const handleCancelCodexLogin = useCallback(async () => { const bridge = getBridge(); if (!bridge?.aiCodexCancelLogin || !codexLoginSession) return; setCodexError(null); try { const result = await bridge.aiCodexCancelLogin(codexLoginSession.sessionId); if (result.session) { setCodexLoginSession(result.session); } } catch (err) { setCodexError(normalizeCodexBridgeError(err)); } }, [codexLoginSession]); const handleOpenCodexLoginUrl = useCallback(() => { const bridge = getBridge(); const url = codexLoginSession?.url; if (!bridge?.openExternal || !url) return; // Only allow https:// URLs to prevent opening arbitrary protocols if (!url.startsWith("https://")) return; void bridge.openExternal(url); }, [codexLoginSession]); const handleCodexLogout = useCallback(async () => { const bridge = getBridge(); if (!bridge?.aiCodexLogout) return; const requestId = codexRequestIdRef.current + 1; codexRequestIdRef.current = requestId; const isCurrentRequest = () => mountedRef.current && codexRequestIdRef.current === requestId; setCodexError(null); setIsCodexLoading(true); try { const result = await bridge.aiCodexLogout({ codexPath: getCodexPathOverride() }); if (!isCurrentRequest()) return; if (!result.ok) { throw new Error(result.error || "Failed to log out from Codex"); } setCodexLoginSession(null); await refreshCodexIntegration({ refreshShellEnv: true, validateChatGptAuth: true, codexPath: getCodexPathOverride() }); } catch (err) { if (isCurrentRequest()) { setCodexError(normalizeCodexBridgeError(err)); } } finally { if (isCurrentRequest()) { setIsCodexLoading(false); } } }, [getCodexPathOverride, refreshCodexIntegration]); const refreshUserSkillsStatus = useCallback(async () => { const bridge = getBridge(); if (!bridge?.aiUserSkillsGetStatus) { setUserSkillsStatus({ ok: false, error: t('ai.userSkills.unavailable'), }); return; } setIsLoadingUserSkills(true); try { const result = await bridge.aiUserSkillsGetStatus(); setUserSkillsStatus(result); notifyUserSkillsStatusChanged(); } catch (err) { const message = err instanceof Error ? err.message : String(err); setUserSkillsStatus({ ok: false, error: message }); } finally { setIsLoadingUserSkills(false); } }, [t]); useEffect(() => { const request = settingsFocus?.request; if (request?.tab === "ai" && request.aiSubTab) { setActiveSubTab(request.aiSubTab as AISettingsSubTab); } }, [settingsFocus?.request]); useEffect(() => { if (activeSubTab !== "tools") return; if (userSkillsLoadedRef.current) return; return scheduleAfterFirstPaint(() => { userSkillsLoadedRef.current = true; void refreshUserSkillsStatus(); }, 520); }, [activeSubTab, refreshUserSkillsStatus]); const reservedUserSkillSlugs = useMemo( () => (userSkillsStatus?.ok && userSkillsStatus.skills ? userSkillsStatus.skills .filter((skill) => skill.status === 'ready' && typeof skill.slug === 'string' && skill.slug.length > 0) .map((skill) => skill.slug) : []), [userSkillsStatus], ); const handleOpenUserSkillsFolder = useCallback(async () => { const bridge = getBridge(); if (!bridge?.aiUserSkillsOpenFolder) return; setIsLoadingUserSkills(true); try { const result = await bridge.aiUserSkillsOpenFolder(); setUserSkillsStatus(result); notifyUserSkillsStatusChanged(); } catch (err) { const message = err instanceof Error ? err.message : String(err); setUserSkillsStatus({ ok: false, error: message }); } finally { setIsLoadingUserSkills(false); } }, []); return ( setActiveSubTab(value as AISettingsSubTab)} className="space-y-5"> {t('ai.providers')} {t('ai.agents')} {t('ai.toolAccess.title')} {t("ai.webSearch.title")} {t('ai.safety.title')} } > {providers.length === 0 ? (

{t('ai.providers.empty')}

) : (
{providers.map((provider) => ( { if (enabled) { setActiveProviderId(provider.id); if (provider.defaultModel) { setActiveModelId(provider.defaultModel); } for (const p of providers) { if (p.id === provider.id) { if (!p.enabled) updateProvider(p.id, { enabled: true }); } else if (p.enabled) { updateProvider(p.id, { enabled: false }); } } } else { if (activeProviderId === provider.id) { setActiveProviderId(""); setActiveModelId(""); } updateProvider(provider.id, { enabled: false }); } }} onEdit={() => setEditingProviderId( editingProviderId === provider.id ? null : provider.id, ) } onRemove={() => handleRemoveProvider(provider.id)} onUpdate={(updates) => { updateProvider(provider.id, updates); if (provider.id === activeProviderId && updates.defaultModel !== undefined) { setActiveModelId(updates.defaultModel || ""); } }} isEditing={editingProviderId === provider.id} onCancelEdit={() => setEditingProviderId(null)} /> ))}
)}
} > void handleCheckCustomPath("codex")} onResetPath={() => void handleResetCustomPath("codex")} integration={codexIntegration} loginSession={codexLoginSession} isLoading={isCodexLoading} hasPendingCustomPath={hasPendingCodexCustomPath} error={codexError} onRefresh={() => void refreshCodexIntegration({ refreshShellEnv: true, validateChatGptAuth: true, codexPath: getCodexPathOverride() })} onConnect={() => void handleStartCodexLogin()} onCancel={() => void handleCancelCodexLogin()} onOpenUrl={handleOpenCodexLoginUrl} onLogout={() => void handleCodexLogout()} appServerRuntime={codexRuntime} appServerStatus={codexAppServerStatus} onAppServerRuntimeChange={handleCodexRuntimeChange} /> } > void handleCheckCustomPath("claude")} onResetPath={() => void handleResetCustomPath("claude")} configDir={claudeConfigDir} onConfigDirChange={(v) => updateClaudeEnv(v, claudeSettingsPath, claudeEnvText)} settingsPath={claudeSettingsPath} onSettingsPathChange={(v) => updateClaudeEnv(claudeConfigDir, v, claudeEnvText)} envText={claudeEnvText} onEnvTextChange={(v) => updateClaudeEnv(claudeConfigDir, claudeSettingsPath, v)} /> } > void handleCheckCustomPath("copilot")} onResetPath={() => void handleResetCustomPath("copilot")} /> } > void handleCheckCustomPath("cursor")} /> } > void handleCheckCustomPath("codebuddy")} onResetPath={() => void handleResetCustomPath("codebuddy")} internetEnv={codebuddyInternetEnv} onInternetEnvChange={(v) => updateCodebuddyEnv(v, codebuddyEnvText)} envText={codebuddyEnvText} onEnvTextChange={(v) => updateCodebuddyEnv(codebuddyInternetEnv, v)} advancedOptions={codebuddyAdvancedOptions} onAdvancedOptionsChange={updateCodebuddyAdvancedOptions} /> } > void handleCheckCustomPath("opencode")} onResetPath={() => void handleResetCustomPath("opencode")} i18nPrefix="ai.opencode" /> } > void handleCheckCustomPath("grok")} onResetPath={() => void handleResetCustomPath("grok")} i18nPrefix="ai.grok" grokRuntime={ externalAgents.find((a) => a.id === "discovered_grok")?.grokRuntime === "streaming-json" ? "streaming-json" : "acp" } onGrokRuntimeChange={handleGrokRuntimeChange} /> {agentOptions.length > 1 ? ( setToolIntegrationMode(value as AIToolIntegrationMode)} className="w-48" />
)} >

{t('ai.userSkills.description')}

{userSkillsStatus?.directoryPath ? (

{t('ai.userSkills.location')}:{" "} {userSkillsStatus.directoryPath}

) : null}
{isLoadingUserSkills ? t('ai.userSkills.loading') : userSkillsStatus?.ok ? t('ai.userSkills.summary', { ready: String(userSkillsStatus.readyCount ?? 0), warnings: String(userSkillsStatus.warningCount ?? 0), }) : userSkillsStatus?.error || t('ai.userSkills.unavailable')}
{userSkillsStatus?.ok && userSkillsStatus.skills && userSkillsStatus.skills.length > 0 ? (
{userSkillsStatus.skills.map((skill) => (
{skill.name}
{skill.description}
{skill.directoryName}
{skill.status === "ready" ? t('ai.userSkills.status.ready') : t('ai.userSkills.status.warning')}
{skill.warnings.length > 0 ? (
{skill.warnings.map((warning, index) => (
{warning}
))}
) : null}
))}
) : userSkillsStatus?.ok ? (
{t('ai.userSkills.empty')}
) : null}
{ if (!open) setRemoveProviderConfirm(null); }} onConfirm={handleConfirmRemoveProvider} />
); }; export default SettingsAITab;