Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
1417 lines
54 KiB
TypeScript
1417 lines
54 KiB
TypeScript
/**
|
|
* 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<ProviderConfig>) => 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<SettingsAITabProps> = ({
|
|
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<string | null>(null);
|
|
const [removeProviderConfirm, setRemoveProviderConfirm] = useState<{ id: string; name: string } | null>(null);
|
|
const [codexIntegration, setCodexIntegration] = useState<CodexIntegrationStatus | null>(null);
|
|
const [codexLoginSession, setCodexLoginSession] = useState<CodexLoginSession | null>(null);
|
|
const [isCodexLoading, setIsCodexLoading] = useState(false);
|
|
const [codexError, setCodexError] = useState<string | null>(null);
|
|
const [codexAppServerStatus, setCodexAppServerStatus] = useState<CodexAppServerStatus | null>(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<AgentPathInfo | null>(
|
|
() => getSavedManagedAgentPathInfo(externalAgents, "codex"),
|
|
);
|
|
const [codexCustomPath, setCodexCustomPath] = useState(() => initialManagedPathsRef.current?.codex ?? "");
|
|
const [isResolvingCodex, setIsResolvingCodex] = useState(false);
|
|
const [activeSubTab, setActiveSubTab] = useState<AISettingsSubTab>("providers");
|
|
const settingsFocus = useOptionalSettingsFocus();
|
|
|
|
const [claudePathInfo, setClaudePathInfo] = useState<AgentPathInfo | null>(
|
|
() => 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<AgentPathInfo | null>(
|
|
() => getSavedManagedAgentPathInfo(externalAgents, "copilot"),
|
|
);
|
|
const [copilotCustomPath, setCopilotCustomPath] = useState(() => initialManagedPathsRef.current?.copilot ?? "");
|
|
const [isResolvingCopilot, setIsResolvingCopilot] = useState(false);
|
|
|
|
const [cursorPathInfo, setCursorPathInfo] = useState<AgentPathInfo | null>(
|
|
() => getSavedManagedAgentPathInfo(externalAgents, "cursor"),
|
|
);
|
|
const [isResolvingCursor, setIsResolvingCursor] = useState(false);
|
|
|
|
const [codebuddyPathInfo, setCodebuddyPathInfo] = useState<AgentPathInfo | null>(
|
|
() => getSavedManagedAgentPathInfo(externalAgents, "codebuddy"),
|
|
);
|
|
const [codebuddyCustomPath, setCodebuddyCustomPath] = useState(() => initialManagedPathsRef.current?.codebuddy ?? "");
|
|
const [isResolvingCodebuddy, setIsResolvingCodebuddy] = useState(false);
|
|
|
|
const [opencodePathInfo, setOpencodePathInfo] = useState<AgentPathInfo | null>(
|
|
() => getSavedManagedAgentPathInfo(externalAgents, "opencode"),
|
|
);
|
|
const [opencodeCustomPath, setOpencodeCustomPath] = useState(() => initialManagedPathsRef.current?.opencode ?? "");
|
|
const [isResolvingOpencode, setIsResolvingOpencode] = useState(false);
|
|
|
|
const [grokPathInfo, setGrokPathInfo] = useState<AgentPathInfo | null>(
|
|
() => 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<UserSkillsStatusResult | null>(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<Partial<Record<ManagedAgentKey, "pending" | "done">>>({});
|
|
const codexIntegrationLoadedRef = useRef(false);
|
|
const userSkillsLoadedRef = useRef(false);
|
|
const mountedRef = useRef(true);
|
|
const agentPathRequestIdRef = useRef<Partial<Record<ManagedAgentKey, number>>>({});
|
|
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: <AgentIconBadge agent={{ id: "catty", type: "builtin" }} size="xs" variant="plain" /> },
|
|
...externalAgents
|
|
.filter((a) => canSendWithAgent(a.id, externalAgents))
|
|
.map((a) => ({ value: a.id, label: a.name, icon: <AgentIconBadge agent={a} size="xs" variant="plain" /> })),
|
|
], [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 (
|
|
<SettingsTabContent value="ai">
|
|
<Tabs value={activeSubTab} onValueChange={(value) => setActiveSubTab(value as AISettingsSubTab)} className="space-y-5">
|
|
<TabsList className="h-auto flex-wrap justify-start bg-muted/50">
|
|
<TabsTrigger value="providers">{t('ai.providers')}</TabsTrigger>
|
|
<TabsTrigger value="agents">{t('ai.agents')}</TabsTrigger>
|
|
<TabsTrigger value="tools">{t('ai.toolAccess.title')}</TabsTrigger>
|
|
<TabsTrigger value="search">{t("ai.webSearch.title")}</TabsTrigger>
|
|
<TabsTrigger value="safety">{t('ai.safety.title')}</TabsTrigger>
|
|
</TabsList>
|
|
|
|
<TabsContent value="providers" className="m-0 space-y-6">
|
|
<SettingsSection
|
|
anchorId="ai-providers"
|
|
title={t('ai.providers')}
|
|
actions={<AddProviderDropdown onAdd={handleAddProvider} />}
|
|
>
|
|
{providers.length === 0 ? (
|
|
<div className="rounded-lg border border-dashed border-border/60 p-6 text-center">
|
|
<Bot size={24} className="mx-auto text-muted-foreground mb-2" />
|
|
<p className="text-sm text-muted-foreground">
|
|
{t('ai.providers.empty')}
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{providers.map((provider) => (
|
|
<ProviderCard
|
|
key={provider.id}
|
|
provider={provider}
|
|
isActive={provider.id === activeProviderId}
|
|
onToggleEnabled={(enabled) => {
|
|
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)}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</SettingsSection>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="agents" className="m-0 space-y-6">
|
|
<SettingsSection
|
|
anchorId="ai-codex"
|
|
title={t('ai.codex')}
|
|
leading={<AgentIconBadge agent={{ id: "codex", icon: "openai", name: "Codex CLI" }} variant="plain" className="h-5 w-5 text-muted-foreground/90" />}
|
|
>
|
|
<CodexConnectionCard
|
|
pathInfo={codexPathInfo}
|
|
isResolvingPath={isResolvingCodex}
|
|
customPath={codexCustomPath}
|
|
onCustomPathChange={setCodexCustomPath}
|
|
onRecheckPath={() => 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}
|
|
/>
|
|
</SettingsSection>
|
|
|
|
<SettingsSection
|
|
anchorId="ai-claude"
|
|
title={t('ai.claude.title')}
|
|
leading={<AgentIconBadge agent={{ id: "claude", icon: "claude", name: "Claude Code" }} variant="plain" className="h-5 w-5 text-muted-foreground/90" />}
|
|
>
|
|
<ClaudeCodeCard
|
|
pathInfo={claudePathInfo}
|
|
isResolvingPath={isResolvingClaude}
|
|
customPath={claudeCustomPath}
|
|
onCustomPathChange={setClaudeCustomPath}
|
|
onRecheckPath={() => 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)}
|
|
/>
|
|
</SettingsSection>
|
|
|
|
<SettingsSection
|
|
anchorId="ai-copilot"
|
|
title={t('ai.copilot.title')}
|
|
leading={<AgentIconBadge agent={{ id: "copilot", icon: "copilot", name: "GitHub Copilot CLI" }} variant="plain" className="h-5 w-5 text-muted-foreground/90" />}
|
|
>
|
|
<CopilotCliCard
|
|
pathInfo={copilotPathInfo}
|
|
isResolvingPath={isResolvingCopilot}
|
|
customPath={copilotCustomPath}
|
|
onCustomPathChange={setCopilotCustomPath}
|
|
onRecheckPath={() => void handleCheckCustomPath("copilot")}
|
|
onResetPath={() => void handleResetCustomPath("copilot")}
|
|
/>
|
|
</SettingsSection>
|
|
|
|
<SettingsSection
|
|
anchorId="ai-cursor"
|
|
title={t('ai.cursor.title')}
|
|
leading={<AgentIconBadge agent={{ id: "cursor", icon: "cursor", name: "Cursor" }} variant="plain" className="h-5 w-5 text-muted-foreground/90" />}
|
|
>
|
|
<CursorSdkCard
|
|
pathInfo={cursorPathInfo}
|
|
isResolvingPath={isResolvingCursor}
|
|
encryptedApiKey={cursorApiKeyEncrypted}
|
|
authMode={cursorAuthMode}
|
|
onAuthModeChange={handleCursorAuthModeChange}
|
|
onSaveApiKey={handleSaveCursorApiKey}
|
|
onRecheckPath={() => void handleCheckCustomPath("cursor")}
|
|
/>
|
|
</SettingsSection>
|
|
|
|
<SettingsSection
|
|
anchorId="ai-codebuddy"
|
|
title={t('ai.codebuddy.title')}
|
|
leading={<AgentIconBadge agent={{ id: "codebuddy", icon: "codebuddy", name: "CodeBuddy Code" }} variant="plain" className="h-5 w-5 text-muted-foreground/90" />}
|
|
>
|
|
<CodebuddyCard
|
|
pathInfo={codebuddyPathInfo}
|
|
isResolvingPath={isResolvingCodebuddy}
|
|
customPath={codebuddyCustomPath}
|
|
onCustomPathChange={setCodebuddyCustomPath}
|
|
onRecheckPath={() => 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}
|
|
/>
|
|
</SettingsSection>
|
|
|
|
<SettingsSection
|
|
title={t('ai.opencode.title')}
|
|
leading={<AgentIconBadge agent={{ id: "opencode", icon: "opencode", name: "OpenCode" }} variant="plain" className="h-5 w-5 text-muted-foreground/90" />}
|
|
>
|
|
<CopilotCliCard
|
|
pathInfo={opencodePathInfo}
|
|
isResolvingPath={isResolvingOpencode}
|
|
customPath={opencodeCustomPath}
|
|
onCustomPathChange={setOpencodeCustomPath}
|
|
onRecheckPath={() => void handleCheckCustomPath("opencode")}
|
|
onResetPath={() => void handleResetCustomPath("opencode")}
|
|
i18nPrefix="ai.opencode"
|
|
/>
|
|
</SettingsSection>
|
|
|
|
<SettingsSection
|
|
title={t('ai.grok.title')}
|
|
leading={<AgentIconBadge agent={{ id: "grok", icon: "grok", name: "Grok Build" }} variant="plain" className="h-5 w-5 text-muted-foreground/90" />}
|
|
>
|
|
<CopilotCliCard
|
|
pathInfo={grokPathInfo}
|
|
isResolvingPath={isResolvingGrok}
|
|
customPath={grokCustomPath}
|
|
onCustomPathChange={setGrokCustomPath}
|
|
onRecheckPath={() => 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}
|
|
/>
|
|
</SettingsSection>
|
|
|
|
{agentOptions.length > 1 ? (
|
|
<SettingsSection anchorId="ai-default-agent" title={t('ai.defaultAgent')}>
|
|
<SettingCard>
|
|
<SettingRow description={t('ai.defaultAgent.description')}>
|
|
<Select
|
|
value={defaultAgentId}
|
|
options={agentOptions}
|
|
onChange={setDefaultAgentId}
|
|
className="w-64"
|
|
/>
|
|
</SettingRow>
|
|
</SettingCard>
|
|
</SettingsSection>
|
|
) : (
|
|
<SettingsAnchor anchorId="ai-default-agent" />
|
|
)}
|
|
</TabsContent>
|
|
|
|
<TabsContent value="tools" className="m-0 space-y-6">
|
|
<SettingsSection title={t('ai.chatShortcuts.title')}>
|
|
<SettingCard divided>
|
|
<SettingRow
|
|
anchorId="ai-chat-shortcuts-selection"
|
|
label={t('ai.chatShortcuts.selectionAction')}
|
|
description={t('ai.chatShortcuts.selectionAction.description')}
|
|
>
|
|
<Toggle
|
|
checked={showTerminalSelectionAIAction}
|
|
onChange={setShowTerminalSelectionAIAction}
|
|
ariaLabel={t('ai.chatShortcuts.selectionAction')}
|
|
/>
|
|
</SettingRow>
|
|
</SettingCard>
|
|
</SettingsSection>
|
|
|
|
<SettingsSection
|
|
anchorId="ai-tool-access-mode"
|
|
title={t('ai.toolAccess.title')}
|
|
>
|
|
<SettingCard>
|
|
<SettingRow description={t('ai.toolAccess.description')}>
|
|
<Select
|
|
value={toolIntegrationMode}
|
|
options={[
|
|
{ value: 'mcp', label: t('ai.toolAccess.mode.mcp') },
|
|
{ value: 'skills', label: t('ai.toolAccess.mode.skills') },
|
|
]}
|
|
onChange={(value) => setToolIntegrationMode(value as AIToolIntegrationMode)}
|
|
className="w-48"
|
|
/>
|
|
</SettingRow>
|
|
<div className="pb-4">
|
|
<ToolAccessGuidance mode={toolIntegrationMode} />
|
|
</div>
|
|
</SettingCard>
|
|
</SettingsSection>
|
|
|
|
<SettingsSection anchorId="ai-external-mcp" title={t('ai.externalMcp.title')}>
|
|
<ExternalMcpCard />
|
|
</SettingsSection>
|
|
|
|
<SettingsSection
|
|
anchorId="ai-user-skills"
|
|
title={t('ai.userSkills.title')}
|
|
actions={(
|
|
<>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => void refreshUserSkillsStatus()}
|
|
disabled={isLoadingUserSkills}
|
|
>
|
|
<RefreshCcw size={14} className="mr-2" />
|
|
{t('ai.userSkills.reload')}
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => void handleOpenUserSkillsFolder()}
|
|
disabled={isLoadingUserSkills}
|
|
>
|
|
<FolderOpen size={14} className="mr-2" />
|
|
{t('ai.userSkills.openFolder')}
|
|
</Button>
|
|
</>
|
|
)}
|
|
>
|
|
<SettingCard padded className="space-y-3">
|
|
<div className="space-y-1.5">
|
|
<p className="text-xs text-muted-foreground/80 leading-5">
|
|
{t('ai.userSkills.description')}
|
|
</p>
|
|
{userSkillsStatus?.directoryPath ? (
|
|
<p className="text-xs text-muted-foreground/80">
|
|
{t('ai.userSkills.location')}:{" "}
|
|
<span className="font-mono">{userSkillsStatus.directoryPath}</span>
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="text-xs text-muted-foreground/80">
|
|
{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')}
|
|
</div>
|
|
|
|
{userSkillsStatus?.ok && userSkillsStatus.skills && userSkillsStatus.skills.length > 0 ? (
|
|
<div className="border-t border-border/60 divide-y divide-border/60">
|
|
{userSkillsStatus.skills.map((skill) => (
|
|
<div key={skill.id} className="py-3">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="min-w-0 space-y-1">
|
|
<div className="text-sm font-medium">{skill.name}</div>
|
|
<div className="text-xs text-muted-foreground leading-5">{skill.description}</div>
|
|
<div className="text-xs text-muted-foreground/80 font-mono break-all">
|
|
{skill.directoryName}
|
|
</div>
|
|
</div>
|
|
<span
|
|
className={
|
|
skill.status === "ready"
|
|
? "text-xs font-medium text-emerald-500 shrink-0"
|
|
: "text-xs font-medium text-amber-500 shrink-0"
|
|
}
|
|
>
|
|
{skill.status === "ready"
|
|
? t('ai.userSkills.status.ready')
|
|
: t('ai.userSkills.status.warning')}
|
|
</span>
|
|
</div>
|
|
{skill.warnings.length > 0 ? (
|
|
<div className="mt-2 space-y-1 text-xs text-amber-500">
|
|
{skill.warnings.map((warning, index) => (
|
|
<div key={`${skill.id}-${index}`} className="flex items-start gap-2">
|
|
<AlertTriangle size={14} className="mt-0.5 shrink-0" />
|
|
<span>{warning}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : userSkillsStatus?.ok ? (
|
|
<div className="border-t border-border/60 pt-3 text-sm text-muted-foreground">
|
|
{t('ai.userSkills.empty')}
|
|
</div>
|
|
) : null}
|
|
</SettingCard>
|
|
</SettingsSection>
|
|
|
|
<QuickMessagesSettings
|
|
quickMessages={quickMessages}
|
|
setQuickMessages={setQuickMessages}
|
|
reservedUserSkillSlugs={reservedUserSkillSlugs}
|
|
/>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="search" className="m-0 space-y-6">
|
|
<WebSearchSettings
|
|
webSearchConfig={webSearchConfig}
|
|
setWebSearchConfig={setWebSearchConfig}
|
|
/>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="safety" className="m-0 space-y-6">
|
|
<SafetySettings
|
|
globalPermissionMode={globalPermissionMode}
|
|
setGlobalPermissionMode={setGlobalPermissionMode}
|
|
commandBlocklist={commandBlocklist}
|
|
setCommandBlocklist={setCommandBlocklist}
|
|
commandTimeout={commandTimeout}
|
|
setCommandTimeout={setCommandTimeout}
|
|
responseIdleTimeout={responseIdleTimeout}
|
|
setResponseIdleTimeout={setResponseIdleTimeout}
|
|
maxIterations={maxIterations}
|
|
setMaxIterations={setMaxIterations}
|
|
/>
|
|
<PermissionGrantsSettings
|
|
grants={permissionGrants}
|
|
addGrant={addGrant}
|
|
updateGrant={updateGrant}
|
|
removeGrant={removeGrant}
|
|
importGrants={importGrants}
|
|
exportGrants={exportGrants}
|
|
/>
|
|
</TabsContent>
|
|
</Tabs>
|
|
<ConfirmDialog
|
|
open={removeProviderConfirm !== null}
|
|
title={removeProviderConfirm ? t('confirm.removeProvider', { name: removeProviderConfirm.name }) : ''}
|
|
confirmLabel={t('action.remove')}
|
|
destructive
|
|
onOpenChange={(open) => {
|
|
if (!open) setRemoveProviderConfirm(null);
|
|
}}
|
|
onConfirm={handleConfirmRemoveProvider}
|
|
/>
|
|
</SettingsTabContent>
|
|
);
|
|
};
|
|
|
|
export default SettingsAITab;
|