Files
NetMesh/components/settings/tabs/SettingsAITab.tsx
zhaolei 3c72efcb7f
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
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

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;