[Init] Initial commit - NetMesh terminal manager
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

This commit is contained in:
2026-09-13 18:24:01 +08:00
commit 3c72efcb7f
3255 changed files with 907009 additions and 0 deletions

View File

@@ -0,0 +1,58 @@
import React, { useState } from "react";
import { ChevronDown, Plus } from "lucide-react";
import type { AIProviderId } from "../../../../infrastructure/ai/types";
import { PROVIDER_PRESETS } from "../../../../infrastructure/ai/types";
import { useI18n } from "../../../../application/i18n/I18nProvider";
import { Button } from "../../../ui/button";
import { cn } from "../../../../lib/utils";
import { ProviderIconBadge } from "./ProviderIconBadge";
export const ADD_PROVIDER_MENU_CLASS =
"absolute top-full right-0 mt-1 z-[101] min-w-[220px] max-w-[calc(100vw-2rem)] rounded-md border border-border bg-popover shadow-md py-1";
export const AddProviderDropdown: React.FC<{
onAdd: (providerId: AIProviderId) => void;
}> = ({ onAdd }) => {
const { t } = useI18n();
const [isOpen, setIsOpen] = useState(false);
const providerIds = Object.keys(PROVIDER_PRESETS) as AIProviderId[];
return (
<div className="relative">
<Button
variant="outline"
size="sm"
onClick={() => setIsOpen(!isOpen)}
className="gap-1.5"
>
<Plus size={14} />
{t('ai.providers.add')}
<ChevronDown size={12} className={cn("transition-transform", isOpen && "rotate-180")} />
</Button>
{isOpen && (
<>
{/* Backdrop */}
<div className="fixed inset-0 z-[100]" onClick={() => setIsOpen(false)} />
{/* Menu */}
<div className={ADD_PROVIDER_MENU_CLASS}>
{providerIds.map((pid) => (
<button
key={pid}
onClick={() => {
onAdd(pid);
setIsOpen(false);
}}
className="w-full flex items-center gap-2.5 px-3 py-2 text-sm hover:bg-accent hover:text-accent-foreground transition-colors text-left"
>
<ProviderIconBadge providerId={pid} size="sm" />
{PROVIDER_PRESETS[pid].name}
</button>
))}
</div>
</>
)}
</div>
);
};

View File

@@ -0,0 +1,179 @@
import React, { useEffect, useState } from "react";
import { ChevronDown, RefreshCw, RotateCcw } from "lucide-react";
import { useI18n } from "../../../../application/i18n/I18nProvider";
import { Button } from "../../../ui/button";
import { cn } from "../../../../lib/utils";
import type { AgentPathInfo } from "./types";
import { parseEnvLines, serializeEnvLines } from "./claudeConfigEnv";
export const ClaudeCodeCard: React.FC<{
pathInfo: AgentPathInfo | null;
isResolvingPath: boolean;
customPath: string;
onCustomPathChange: (path: string) => void;
onRecheckPath: () => void;
onResetPath: () => void;
configDir: string;
onConfigDirChange: (value: string) => void;
settingsPath: string;
onSettingsPathChange: (value: string) => void;
envText: string;
onEnvTextChange: (value: string) => void;
}> = ({
pathInfo,
isResolvingPath,
customPath,
onCustomPathChange,
onRecheckPath,
onResetPath,
configDir,
onConfigDirChange,
settingsPath,
onSettingsPathChange,
envText,
onEnvTextChange,
}) => {
const { t } = useI18n();
const found = pathInfo?.available;
// Collapsed by default; auto-expand when the user already has config so it
// isn't hidden. Local UI state — not persisted.
const [configOpen, setConfigOpen] = useState(
() => Boolean(configDir.trim() || settingsPath.trim() || envText.trim()),
);
// The env editor keeps the raw text the user types. Persisting parses it into
// a record (dropping incomplete lines), so binding the textarea directly to
// the persisted value would erase a key the moment it's typed before its "=".
// Only resync from the persisted value when it changes for some reason other
// than our own parse→serialize round-trip.
const [envDraft, setEnvDraft] = useState(envText);
useEffect(() => {
setEnvDraft((prev) =>
serializeEnvLines(parseEnvLines(prev)) === envText ? prev : envText,
);
}, [envText]);
const statusText = isResolvingPath
? t('ai.claude.detecting')
: found
? t('ai.claude.detected')
: t('ai.claude.notFound');
const statusClassName = isResolvingPath
? "text-muted-foreground"
: found
? "text-emerald-500"
: "text-amber-500";
return (
<div className="rounded-lg border bg-card p-4 space-y-3">
<div className="flex items-start justify-between gap-4">
<p className="min-w-0 text-xs text-muted-foreground leading-5">
{t('ai.claude.description')}
</p>
<div className={cn("text-xs font-medium shrink-0", statusClassName)}>
{statusText}
</div>
</div>
{found && (
<div className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground">{t('ai.claude.path')}</span>
<span className="font-mono text-foreground truncate">{pathInfo.path}</span>
{pathInfo.version && (
<>
<span className="text-muted-foreground">|</span>
<span className="text-muted-foreground">{pathInfo.version}</span>
</>
)}
</div>
)}
{!isResolvingPath && (
<div className="space-y-2">
{!found && (
<p className="text-xs text-amber-500">
{t('ai.claude.notFoundHint')}
</p>
)}
<div className="flex items-center gap-2">
<input
type="text"
value={customPath}
onChange={(e) => onCustomPathChange(e.target.value)}
placeholder={t('ai.claude.customPathPlaceholder')}
className="flex-1 h-8 rounded-md border border-input bg-background px-3 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
<Button variant="outline" size="sm" onClick={onRecheckPath} disabled={!customPath.trim()}>
<RefreshCw size={14} className="mr-1.5" />
{t('ai.claude.check')}
</Button>
<Button variant="ghost" size="sm" onClick={onResetPath} disabled={!customPath.trim()}>
<RotateCcw size={14} className="mr-1.5" />
{t('ai.claude.resetPath')}
</Button>
</div>
</div>
)}
{/* Authentication & config (optional, collapsible) */}
<div className="border-t border-border/60 pt-3">
<button
type="button"
onClick={() => setConfigOpen((v) => !v)}
aria-expanded={configOpen}
className="flex w-full items-center justify-between gap-2 text-left"
>
<span className="text-xs font-medium text-muted-foreground">
{t('ai.claude.configSection')}
</span>
<ChevronDown
size={14}
className={cn("text-muted-foreground transition-transform", configOpen && "rotate-180")}
/>
</button>
{configOpen && (
<div className="space-y-3 mt-3">
<div className="space-y-1.5">
<label htmlFor="claude-config-dir" className="text-xs text-muted-foreground">{t('ai.claude.configDir')}</label>
<input
id="claude-config-dir"
type="text"
value={configDir}
onChange={(e) => onConfigDirChange(e.target.value)}
placeholder={t('ai.claude.configDir.placeholder')}
className="w-full h-8 rounded-md border border-input bg-background px-3 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
<p className="text-[11px] text-muted-foreground leading-4">{t('ai.claude.configDir.hint')}</p>
</div>
<div className="space-y-1.5">
<label htmlFor="claude-settings" className="text-xs text-muted-foreground">{t('ai.claude.settings')}</label>
<input
id="claude-settings"
type="text"
value={settingsPath}
onChange={(e) => onSettingsPathChange(e.target.value)}
placeholder={t('ai.claude.settings.placeholder')}
className="w-full h-8 rounded-md border border-input bg-background px-3 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
<p className="text-[11px] text-muted-foreground leading-4">{t('ai.claude.settings.hint')}</p>
</div>
<div className="space-y-1.5">
<label htmlFor="claude-env-vars" className="text-xs text-muted-foreground">{t('ai.claude.envVars')}</label>
<textarea
id="claude-env-vars"
value={envDraft}
onChange={(e) => { setEnvDraft(e.target.value); onEnvTextChange(e.target.value); }}
placeholder={t('ai.claude.envVars.placeholder')}
rows={3}
spellCheck={false}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring resize-y"
/>
<p className="text-[11px] text-muted-foreground leading-4">{t('ai.claude.envVars.hint')}</p>
</div>
</div>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,310 @@
import React, { useEffect, useState } from "react";
import { ChevronDown, RefreshCw, RotateCcw } from "lucide-react";
import { useI18n } from "../../../../application/i18n/I18nProvider";
import { Button } from "../../../ui/button";
import { cn } from "../../../../lib/utils";
import type { AgentPathInfo } from "./types";
import type { CodebuddyAdvancedOptions } from "../../../../infrastructure/ai/types";
import { parseEnvLines, serializeEnvLines } from "./codebuddyConfigEnv";
const INTERNET_ENV_OPTIONS = [
{ value: "", labelKey: "ai.codebuddy.internetEnv.default" },
{ value: "internal", labelKey: "ai.codebuddy.internetEnv.internal" },
{ value: "ioa", labelKey: "ai.codebuddy.internetEnv.ioa" },
] as const;
const EFFORT_OPTIONS = [
{ value: "", labelKey: "ai.codebuddy.effort.default" },
{ value: "low", labelKey: "ai.codebuddy.effort.low" },
{ value: "medium", labelKey: "ai.codebuddy.effort.medium" },
{ value: "high", labelKey: "ai.codebuddy.effort.high" },
{ value: "xhigh", labelKey: "ai.codebuddy.effort.xhigh" },
] as const;
export const CodebuddyCard: React.FC<{
pathInfo: AgentPathInfo | null;
isResolvingPath: boolean;
customPath: string;
onCustomPathChange: (path: string) => void;
onRecheckPath: () => void;
onResetPath: () => void;
internetEnv: string;
onInternetEnvChange: (value: string) => void;
envText: string;
onEnvTextChange: (value: string) => void;
advancedOptions?: CodebuddyAdvancedOptions;
onAdvancedOptionsChange?: (options: CodebuddyAdvancedOptions | undefined) => void;
}> = ({
pathInfo,
isResolvingPath,
customPath,
onCustomPathChange,
onRecheckPath,
onResetPath,
internetEnv,
onInternetEnvChange,
envText,
onEnvTextChange,
advancedOptions,
onAdvancedOptionsChange,
}) => {
const { t } = useI18n();
const found = pathInfo?.available;
// Collapsed by default; auto-expand when the user already has config so it
// isn't hidden. Local UI state — not persisted.
const [configOpen, setConfigOpen] = useState(
() => Boolean(internetEnv.trim() || envText.trim()),
);
const [advancedOpen, setAdvancedOpen] = useState(
() => Boolean(advancedOptions && Object.keys(advancedOptions).length > 0),
);
const updateAdvanced = (patch: Partial<CodebuddyAdvancedOptions>) => {
if (!onAdvancedOptionsChange) return;
const next = { ...(advancedOptions || {}), ...patch };
// Remove undefined/empty values to keep storage clean.
const cleaned = Object.fromEntries(
Object.entries(next).filter(([, v]) => v != null && v !== "" && v !== 0),
) as CodebuddyAdvancedOptions;
onAdvancedOptionsChange(Object.keys(cleaned).length > 0 ? cleaned : undefined);
};
// The env editor keeps the raw text the user types. Persisting parses it into
// a record (dropping incomplete lines), so binding the textarea directly to
// the persisted value would erase a key the moment it's typed before its "=".
// Only resync from the persisted value when it changes for some reason other
// than our own parse→serialize round-trip.
const [envDraft, setEnvDraft] = useState(envText);
useEffect(() => {
setEnvDraft((prev) =>
serializeEnvLines(parseEnvLines(prev)) === envText ? prev : envText,
);
}, [envText]);
const statusText = isResolvingPath
? t('ai.codebuddy.detecting')
: found
? t('ai.codebuddy.detected')
: t('ai.codebuddy.notFound');
const statusClassName = isResolvingPath
? "text-muted-foreground"
: found
? "text-emerald-500"
: "text-amber-500";
return (
<div className="rounded-lg border bg-card p-4 space-y-3">
<div className="flex items-start justify-between gap-4">
<p className="min-w-0 text-xs text-muted-foreground leading-5">
{t('ai.codebuddy.description')}
</p>
<div className={cn("text-xs font-medium shrink-0", statusClassName)}>
{statusText}
</div>
</div>
{found && (
<div className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground">{t('ai.codebuddy.path')}</span>
<span className="font-mono text-foreground truncate">{pathInfo.path}</span>
{pathInfo.version && (
<>
<span className="text-muted-foreground">|</span>
<span className="text-muted-foreground">{pathInfo.version}</span>
</>
)}
</div>
)}
{!isResolvingPath && (
<div className="space-y-2">
{!found && (
<p className="text-xs text-amber-500">
{t('ai.codebuddy.notFoundHint')}
</p>
)}
<div className="flex items-center gap-2">
<input
type="text"
value={customPath}
onChange={(e) => onCustomPathChange(e.target.value)}
placeholder={t('ai.codebuddy.customPathPlaceholder')}
className="flex-1 h-8 rounded-md border border-input bg-background px-3 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
<Button variant="outline" size="sm" onClick={onRecheckPath} disabled={!customPath.trim()}>
<RefreshCw size={14} className="mr-1.5" />
{t('ai.codebuddy.check')}
</Button>
<Button variant="ghost" size="sm" onClick={onResetPath} disabled={!customPath.trim()}>
<RotateCcw size={14} className="mr-1.5" />
{t('ai.codebuddy.resetPath')}
</Button>
</div>
</div>
)}
{/* Authentication & config (optional, collapsible) */}
<div className="border-t border-border/60 pt-3">
<button
type="button"
onClick={() => setConfigOpen((v) => !v)}
aria-expanded={configOpen}
className="flex w-full items-center justify-between gap-2 text-left"
>
<span className="text-xs font-medium text-muted-foreground">
{t('ai.codebuddy.configSection')}
</span>
<ChevronDown
size={14}
className={cn("text-muted-foreground transition-transform", configOpen && "rotate-180")}
/>
</button>
{configOpen && (
<div className="space-y-3 mt-3">
<div className="space-y-1.5">
<label htmlFor="codebuddy-internet-env" className="text-xs text-muted-foreground">{t('ai.codebuddy.internetEnv')}</label>
<select
id="codebuddy-internet-env"
value={internetEnv}
onChange={(e) => onInternetEnvChange(e.target.value)}
className="w-full h-8 rounded-md border border-input bg-background px-3 text-sm font-mono focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
{INTERNET_ENV_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>{t(opt.labelKey)}</option>
))}
</select>
<p className="text-[11px] text-muted-foreground leading-4">{t('ai.codebuddy.internetEnv.hint')}</p>
</div>
<div className="space-y-1.5">
<label htmlFor="codebuddy-env-vars" className="text-xs text-muted-foreground">{t('ai.codebuddy.envVars')}</label>
<textarea
id="codebuddy-env-vars"
value={envDraft}
onChange={(e) => { setEnvDraft(e.target.value); onEnvTextChange(e.target.value); }}
placeholder={t('ai.codebuddy.envVars.placeholder')}
rows={3}
spellCheck={false}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring resize-y"
/>
<p className="text-[11px] text-muted-foreground leading-4">{t('ai.codebuddy.envVars.hint')}</p>
</div>
</div>
)}
</div>
{/* Advanced SDK options (SDK 0.3.230) */}
{onAdvancedOptionsChange && (
<div className="border-t border-border/60 pt-3">
<button
type="button"
onClick={() => setAdvancedOpen((v) => !v)}
aria-expanded={advancedOpen}
className="flex w-full items-center justify-between gap-2 text-left"
>
<span className="text-xs font-medium text-muted-foreground">
{t('ai.codebuddy.advancedSection')}
</span>
<ChevronDown
size={14}
className={cn("text-muted-foreground transition-transform", advancedOpen && "rotate-180")}
/>
</button>
{advancedOpen && (
<div className="space-y-3 mt-3">
{/* Effort */}
<div className="space-y-1.5">
<label htmlFor="codebuddy-effort" className="text-xs text-muted-foreground">{t('ai.codebuddy.effort')}</label>
<select
id="codebuddy-effort"
value={advancedOptions?.effort || ""}
onChange={(e) => updateAdvanced({ effort: (e.target.value || undefined) as CodebuddyAdvancedOptions['effort'] })}
className="w-full h-8 rounded-md border border-input bg-background px-3 text-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
{EFFORT_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>{t(opt.labelKey)}</option>
))}
</select>
<p className="text-[11px] text-muted-foreground leading-4">{t('ai.codebuddy.effort.hint')}</p>
</div>
{/* Max Turns */}
<div className="space-y-1.5">
<label htmlFor="codebuddy-max-turns" className="text-xs text-muted-foreground">{t('ai.codebuddy.maxTurns')}</label>
<input
id="codebuddy-max-turns"
type="number"
min={1}
max={200}
value={advancedOptions?.maxTurns ?? ""}
onChange={(e) => updateAdvanced({ maxTurns: e.target.value ? Number(e.target.value) : undefined })}
placeholder="20"
className="w-full h-8 rounded-md border border-input bg-background px-3 text-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
<p className="text-[11px] text-muted-foreground leading-4">{t('ai.codebuddy.maxTurns.hint')}</p>
</div>
{/* Max Budget USD */}
<div className="space-y-1.5">
<label htmlFor="codebuddy-max-budget" className="text-xs text-muted-foreground">{t('ai.codebuddy.maxBudget')}</label>
<input
id="codebuddy-max-budget"
type="number"
min={0.01}
step={0.01}
value={advancedOptions?.maxBudgetUsd ?? ""}
onChange={(e) => updateAdvanced({ maxBudgetUsd: e.target.value ? Number(e.target.value) : undefined })}
placeholder="0.50"
className="w-full h-8 rounded-md border border-input bg-background px-3 text-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
<p className="text-[11px] text-muted-foreground leading-4">{t('ai.codebuddy.maxBudget.hint')}</p>
</div>
{/* Sandbox */}
<div className="flex items-center justify-between gap-2">
<div>
<span className="text-xs text-muted-foreground">{t('ai.codebuddy.sandbox')}</span>
<p className="text-[11px] text-muted-foreground leading-4">{t('ai.codebuddy.sandbox.hint')}</p>
</div>
<button
type="button"
role="switch"
aria-checked={Boolean(advancedOptions?.sandbox?.enabled)}
onClick={() => updateAdvanced({ sandbox: advancedOptions?.sandbox?.enabled ? undefined : { enabled: true } })}
className={cn(
"relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full transition-colors",
advancedOptions?.sandbox?.enabled ? "bg-primary" : "bg-muted",
)}
>
<span className={cn(
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
advancedOptions?.sandbox?.enabled ? "translate-x-[18px]" : "translate-x-[3px]",
)} />
</button>
</div>
{/* File Checkpointing */}
<div className="flex items-center justify-between gap-2">
<div>
<span className="text-xs text-muted-foreground">{t('ai.codebuddy.fileCheckpointing')}</span>
<p className="text-[11px] text-muted-foreground leading-4">{t('ai.codebuddy.fileCheckpointing.hint')}</p>
</div>
<button
type="button"
role="switch"
aria-checked={Boolean(advancedOptions?.enableFileCheckpointing)}
onClick={() => updateAdvanced({ enableFileCheckpointing: advancedOptions?.enableFileCheckpointing ? undefined : true })}
className={cn(
"relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full transition-colors",
advancedOptions?.enableFileCheckpointing ? "bg-primary" : "bg-muted",
)}
>
<span className={cn(
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
advancedOptions?.enableFileCheckpointing ? "translate-x-[18px]" : "translate-x-[3px]",
)} />
</button>
</div>
</div>
)}
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,40 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { I18nProvider } from '../../../../application/i18n/I18nProvider';
import { CodexConnectionCard } from './CodexConnectionCard';
test('CodexConnectionCard surfaces the experimental App Server runtime', () => {
const markup = renderToStaticMarkup(
React.createElement(
I18nProvider,
{ locale: 'en' },
React.createElement(CodexConnectionCard, {
pathInfo: { path: '/usr/bin/codex', version: '0.144.3', available: true },
isResolvingPath: false,
customPath: '',
onCustomPathChange: () => {},
onRecheckPath: () => {},
onResetPath: () => {},
integration: null,
loginSession: null,
isLoading: false,
error: null,
onRefresh: () => {},
onConnect: () => {},
onCancel: () => {},
onOpenUrl: () => {},
onLogout: () => {},
appServerRuntime: 'app-server',
appServerStatus: { available: true },
onAppServerRuntimeChange: () => {},
}),
),
);
assert.match(markup, /Use Codex App Server/);
assert.match(markup, /Experimental/);
assert.match(markup, /App Server is available/);
assert.match(markup, /role="switch"/);
assert.match(markup, /aria-checked="true"/);
});

View File

@@ -0,0 +1,249 @@
import React from "react";
import { ExternalLink, LogIn, LogOut, RefreshCw, RotateCcw, X } from "lucide-react";
import { useI18n } from "../../../../application/i18n/I18nProvider";
import { Button } from "../../../ui/button";
import { Switch } from "../../../ui/switch";
import { cn } from "../../../../lib/utils";
import type { AgentPathInfo, CodexAppServerStatus, CodexIntegrationStatus, CodexLoginSession } from "./types";
export const CodexConnectionCard: React.FC<{
pathInfo: AgentPathInfo | null;
isResolvingPath: boolean;
customPath: string;
onCustomPathChange: (path: string) => void;
onRecheckPath: () => void;
onResetPath: () => void;
integration: CodexIntegrationStatus | null;
loginSession: CodexLoginSession | null;
isLoading: boolean;
hasPendingCustomPath?: boolean;
error: string | null;
onRefresh: () => void;
onConnect: () => void;
onCancel: () => void;
onOpenUrl: () => void;
onLogout: () => void;
appServerRuntime: 'sdk' | 'app-server';
appServerStatus: CodexAppServerStatus | null;
onAppServerRuntimeChange: (runtime: 'sdk' | 'app-server') => void;
}> = ({
pathInfo,
isResolvingPath,
customPath,
onCustomPathChange,
onRecheckPath,
onResetPath,
integration,
loginSession,
isLoading,
hasPendingCustomPath = false,
error,
onRefresh,
onConnect,
onCancel,
onOpenUrl,
onLogout,
appServerRuntime,
appServerStatus,
onAppServerRuntimeChange,
}) => {
const { t } = useI18n();
const found = pathInfo?.available;
const customConfigIncomplete = Boolean(
integration?.state === "connected_custom_config"
&& integration.customConfig
&& integration.customConfig.envKey
&& !integration.customConfig.envKeyPresent
&& !integration.customConfig.hasHardcodedApiKey,
);
const status = isResolvingPath
? t('ai.codex.detecting')
: !found
? t('ai.codex.notFound')
: loginSession?.state === "running"
? t('ai.codex.awaitingLogin')
: integration?.state === "connected_chatgpt"
? t('ai.codex.connectedChatGPT')
: integration?.state === "connected_api_key"
? t('ai.codex.connectedApiKey')
: integration?.state === "connected_custom_config"
? customConfigIncomplete
? t('ai.codex.customConfigIncomplete')
: t('ai.codex.connectedCustomConfig')
: integration?.state === "not_logged_in"
? t('ai.codex.notConnected')
: t('ai.codex.statusUnknown');
const statusClassName = isResolvingPath
? "text-muted-foreground"
: !found
? "text-amber-500"
: loginSession?.state === "running"
? "text-amber-500"
: customConfigIncomplete
? "text-amber-500"
: integration?.isConnected
? "text-emerald-500"
: "text-muted-foreground";
const outputText = loginSession?.error
? loginSession.error
: loginSession?.output?.trim()
? loginSession.output.trim()
: integration?.rawOutput?.trim()
? integration.rawOutput.trim()
: "";
return (
<div className="rounded-lg border bg-card p-4 space-y-3">
<div className="flex items-start justify-between gap-4">
<p className="min-w-0 text-xs text-muted-foreground leading-5">
{t('ai.codex.description')}
</p>
<div className={cn("text-xs font-medium shrink-0", statusClassName)}>
{status}
</div>
</div>
{found && (
<div className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground">{t('ai.codex.path')}</span>
<span className="font-mono text-foreground truncate">{pathInfo.path}</span>
{pathInfo.version && (
<>
<span className="text-muted-foreground">|</span>
<span className="text-muted-foreground">{pathInfo.version}</span>
</>
)}
</div>
)}
{!isResolvingPath && (
<div className="space-y-2">
{!found && (
<p className="text-xs text-amber-500">
{t('ai.codex.notFoundHint')}
</p>
)}
<div className="flex items-center gap-2">
<input
type="text"
value={customPath}
onChange={(e) => onCustomPathChange(e.target.value)}
placeholder={t('ai.codex.customPathPlaceholder')}
className="flex-1 h-8 rounded-md border border-input bg-background px-3 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
<Button variant="outline" size="sm" onClick={onRecheckPath} disabled={!customPath.trim()}>
<RefreshCw size={14} className="mr-1.5" />
{t('ai.codex.check')}
</Button>
<Button variant="ghost" size="sm" onClick={onResetPath} disabled={!customPath.trim()}>
<RotateCcw size={14} className="mr-1.5" />
{t('ai.codex.resetPath')}
</Button>
</div>
</div>
)}
{found && (
<div className="border-t border-border/40 pt-3 flex items-start justify-between gap-4">
<div className="min-w-0 space-y-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{t('ai.codex.appServer.title')}</span>
<span className="rounded border border-amber-500/30 bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium text-amber-500">
{t('ai.codex.appServer.experimental')}
</span>
</div>
<p className="text-xs text-muted-foreground leading-5">
{t('ai.codex.appServer.description')}
</p>
{appServerStatus?.checking ? (
<p className="text-xs text-muted-foreground">{t('ai.codex.appServer.checking')}</p>
) : appServerStatus?.available ? (
<p className="text-xs text-emerald-500">{t('ai.codex.appServer.available')}</p>
) : appServerStatus?.error ? (
<p className="text-xs text-amber-500">{appServerStatus.error}</p>
) : null}
</div>
<Switch
checked={appServerRuntime === 'app-server'}
disabled={Boolean(appServerStatus?.checking) || (appServerRuntime === 'sdk' && appServerStatus?.available !== true)}
aria-label={t('ai.codex.appServer.title')}
onCheckedChange={(checked) => onAppServerRuntimeChange(checked ? 'app-server' : 'sdk')}
/>
</div>
)}
{/* Connection & login UI -- only when codex is detected */}
{found && (
<>
<div className="border-t border-border/40 pt-3 flex items-center gap-2 flex-wrap">
{loginSession?.state === "running" ? (
<>
<Button variant="default" size="sm" onClick={onOpenUrl} disabled={!loginSession.url}>
<ExternalLink size={14} className="mr-1.5" />
{t('ai.codex.openLogin')}
</Button>
<Button variant="outline" size="sm" onClick={onCancel}>
<X size={14} className="mr-1.5" />
{t('common.cancel')}
</Button>
</>
) : integration?.state === "connected_custom_config" ? (
// Nothing to log out of; config.toml is user-owned state.
null
) : integration?.isConnected ? (
<Button variant="outline" size="sm" onClick={onLogout} disabled={hasPendingCustomPath}>
<LogOut size={14} className="mr-1.5" />
{t('ai.codex.logout')}
</Button>
) : (
<Button variant="default" size="sm" onClick={onConnect} disabled={hasPendingCustomPath}>
<LogIn size={14} className="mr-1.5" />
{t('ai.codex.connectChatGPT')}
</Button>
)}
<Button variant="outline" size="sm" onClick={onRefresh} disabled={isLoading || hasPendingCustomPath}>
<RefreshCw size={14} className={cn("mr-1.5", isLoading && "animate-spin")} />
{t('ai.codex.refreshStatus')}
</Button>
</div>
{integration?.state === "connected_custom_config" && integration.customConfig && (
<>
<p className="text-xs text-emerald-500">
{t('ai.codex.customConfigHint').replace(
'{provider}',
integration.customConfig.displayName || integration.customConfig.providerName,
)}
</p>
{integration.customConfig.envKey && !integration.customConfig.envKeyPresent && !integration.customConfig.hasHardcodedApiKey && (
<p className="text-xs text-amber-500">
{t('ai.codex.customConfigMissingEnvKey').replace(
'{envKey}',
integration.customConfig.envKey,
)}
</p>
)}
</>
)}
</>
)}
{error && (
<p className="text-xs text-destructive">
{error}
</p>
)}
{found && outputText && (
<pre className="rounded-md border border-border/60 bg-background px-3 py-2 text-[11px] leading-5 text-muted-foreground whitespace-pre-wrap max-h-40 overflow-auto">
{outputText}
</pre>
)}
</div>
);
};

View File

@@ -0,0 +1,75 @@
import test from "node:test";
import assert from "node:assert/strict";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { CopilotCliCard } from "./CopilotCliCard";
function firstButton(markup: string): string {
const match = markup.match(/<button\b[^>]*>/);
return match?.[0] ?? "";
}
test("Cursor check button stays enabled without a custom path", () => {
const markup = renderToStaticMarkup(
<CopilotCliCard
pathInfo={{ path: null, version: null, available: false }}
isResolvingPath={false}
customPath=""
onCustomPathChange={() => {}}
onRecheckPath={() => {}}
i18nPrefix="ai.cursor"
allowEmptyCheck
/>,
);
assert.equal(firstButton(markup).includes("disabled=\"\""), false);
});
test("Copilot check button still requires a custom path", () => {
const markup = renderToStaticMarkup(
<CopilotCliCard
pathInfo={{ path: null, version: null, available: false }}
isResolvingPath={false}
customPath=""
onCustomPathChange={() => {}}
onRecheckPath={() => {}}
/>,
);
assert.equal(firstButton(markup).includes("disabled=\"\""), true);
});
test("Grok card surfaces ACP runtime toggle when detected", () => {
const markup = renderToStaticMarkup(
<CopilotCliCard
pathInfo={{ path: "/usr/bin/grok", version: "0.2.118", available: true }}
isResolvingPath={false}
customPath=""
onCustomPathChange={() => {}}
onRecheckPath={() => {}}
i18nPrefix="ai.grok"
grokRuntime="acp"
onGrokRuntimeChange={() => {}}
/>,
);
assert.match(markup, /ai\.grok\.runtime\.acp\.title|Use Grok ACP/);
assert.match(markup, /role="switch"/);
});
test("Grok card hides ACP toggle without runtime change handler", () => {
const markup = renderToStaticMarkup(
<CopilotCliCard
pathInfo={{ path: "/usr/bin/grok", version: "0.2.118", available: true }}
isResolvingPath={false}
customPath=""
onCustomPathChange={() => {}}
onRecheckPath={() => {}}
i18nPrefix="ai.grok"
grokRuntime="acp"
/>,
);
assert.doesNotMatch(markup, /role="switch"/);
assert.doesNotMatch(markup, /ai\.grok\.runtime\.acp\.title|Use Grok ACP \(agent stdio\)/);
});

View File

@@ -0,0 +1,136 @@
import React from "react";
import { RefreshCw, RotateCcw } from "lucide-react";
import { useI18n } from "../../../../application/i18n/I18nProvider";
import { Button } from "../../../ui/button";
import { Switch } from "../../../ui/switch";
import { cn } from "../../../../lib/utils";
import type { GrokRuntime } from "../../../../infrastructure/ai/types";
import type { AgentPathInfo } from "./types";
export const CopilotCliCard: React.FC<{
pathInfo: AgentPathInfo | null;
isResolvingPath: boolean;
customPath: string;
onCustomPathChange: (path: string) => void;
onRecheckPath: () => void;
onResetPath?: () => void;
i18nPrefix?: "ai.copilot" | "ai.cursor" | "ai.opencode" | "ai.grok";
allowEmptyCheck?: boolean;
showCustomPathInput?: boolean;
/** Grok only: ACP (default) vs headless streaming-json. */
grokRuntime?: GrokRuntime;
onGrokRuntimeChange?: (runtime: GrokRuntime) => void;
}> = ({
pathInfo,
isResolvingPath,
customPath,
onCustomPathChange,
onRecheckPath,
onResetPath,
i18nPrefix = "ai.copilot",
allowEmptyCheck = false,
showCustomPathInput = true,
grokRuntime = "acp",
onGrokRuntimeChange,
}) => {
const { t } = useI18n();
const found = pathInfo?.available;
const showGrokRuntime = i18nPrefix === "ai.grok" && typeof onGrokRuntimeChange === "function";
const statusText = isResolvingPath
? t(`${i18nPrefix}.detecting`)
: found
? t(`${i18nPrefix}.detected`)
: t(`${i18nPrefix}.notFound`);
const statusClassName = isResolvingPath
? "text-muted-foreground"
: found
? "text-emerald-500"
: "text-amber-500";
return (
<div className="rounded-lg border bg-card p-4 space-y-3">
<div className="flex items-start justify-between gap-4">
<p className="min-w-0 text-xs text-muted-foreground leading-5">
{t(`${i18nPrefix}.description`)}
</p>
<div className={cn("text-xs font-medium shrink-0", statusClassName)}>
{statusText}
</div>
</div>
{found && (
<div className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground">{t(`${i18nPrefix}.path`)}</span>
<span className="font-mono text-foreground truncate">{pathInfo.path}</span>
{pathInfo.version && (
<>
<span className="text-muted-foreground">|</span>
<span className="text-muted-foreground">{pathInfo.version}</span>
</>
)}
</div>
)}
{!isResolvingPath && (
<div className="space-y-2">
{!found && (
<p className="text-xs text-amber-500">
{t(`${i18nPrefix}.notFoundHint`)}
</p>
)}
<div className={cn("flex items-center gap-2", showCustomPathInput ? "" : "justify-end")}>
{showCustomPathInput && (
<input
type="text"
value={customPath}
onChange={(e) => onCustomPathChange(e.target.value)}
placeholder={t(`${i18nPrefix}.customPathPlaceholder`)}
className="flex-1 h-8 rounded-md border border-input bg-background px-3 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
)}
<Button variant="outline" size="sm" onClick={onRecheckPath} disabled={!allowEmptyCheck && !customPath.trim()}>
<RefreshCw size={14} className="mr-1.5" />
{t(`${i18nPrefix}.check`)}
</Button>
{showCustomPathInput && onResetPath && (
<Button variant="ghost" size="sm" onClick={onResetPath} disabled={!customPath.trim()}>
<RotateCcw size={14} className="mr-1.5" />
{t(`${i18nPrefix}.resetPath`)}
</Button>
)}
</div>
</div>
)}
{showGrokRuntime && found && (
<div className="border-t border-border/40 pt-3 flex items-start justify-between gap-4">
<div className="min-w-0 space-y-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{t("ai.grok.runtime.acp.title")}</span>
<span className="rounded border border-primary/30 bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-primary">
{t("ai.grok.runtime.acp.default")}
</span>
</div>
<p className="text-xs text-muted-foreground leading-5">
{t("ai.grok.runtime.acp.description")}
</p>
{grokRuntime === "streaming-json" && (
<p className="text-xs text-muted-foreground leading-5">
{t("ai.grok.runtime.streamingJson.hint")}
</p>
)}
</div>
<Switch
checked={grokRuntime === "acp"}
aria-label={t("ai.grok.runtime.acp.title")}
onCheckedChange={(checked) =>
onGrokRuntimeChange?.(checked ? "acp" : "streaming-json")
}
/>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,231 @@
import React, { useEffect, useState } from "react";
import { Check, Eye, EyeOff, RefreshCw } from "lucide-react";
import { useI18n } from "../../../../application/i18n/I18nProvider";
import { decryptField } from "../../../../infrastructure/persistence/secureFieldAdapter";
import type { CursorAuthMode } from "../../../../infrastructure/ai/types";
import { Button } from "../../../ui/button";
import { cn } from "../../../../lib/utils";
import { isCursorRuntimeInstalled, type AgentPathInfo } from "./types";
export const CursorSdkCard: React.FC<{
pathInfo: AgentPathInfo | null;
isResolvingPath: boolean;
encryptedApiKey?: string;
authMode: CursorAuthMode;
onAuthModeChange: (mode: CursorAuthMode) => void;
onSaveApiKey: (apiKey: string) => Promise<void>;
onRecheckPath: () => void;
}> = ({
pathInfo,
isResolvingPath,
encryptedApiKey,
authMode,
onAuthModeChange,
onSaveApiKey,
onRecheckPath,
}) => {
const { t } = useI18n();
const [apiKeyDraft, setApiKeyDraft] = useState("");
const [showApiKey, setShowApiKey] = useState(false);
const [isDecrypting, setIsDecrypting] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [saved, setSaved] = useState(false);
useEffect(() => {
let cancelled = false;
setSaved(false);
if (!encryptedApiKey) {
setApiKeyDraft("");
return;
}
setIsDecrypting(true);
decryptField(encryptedApiKey)
.then((value) => {
if (!cancelled) setApiKeyDraft(value ?? "");
})
.catch(() => {
if (!cancelled) setApiKeyDraft("");
})
.finally(() => {
if (!cancelled) setIsDecrypting(false);
});
return () => {
cancelled = true;
};
}, [encryptedApiKey]);
const installed = isCursorRuntimeInstalled(pathInfo);
const hasStoredApiKey = Boolean(encryptedApiKey);
const usesEnvApiKey = pathInfo?.authSource === "CURSOR_API_KEY" || (
pathInfo?.apiKeyOk && !hasStoredApiKey && pathInfo?.authSource !== "settings"
);
// CLI login is only proven by the dedicated probe — never generic `authenticated`
// (which is also true for env/settings API keys).
const hasCliLogin = Boolean(
pathInfo?.cliLoginOk || pathInfo?.authSource === "cli-login",
);
const hasAnyApiKey = hasStoredApiKey || Boolean(pathInfo?.apiKeyOk) || usesEnvApiKey
|| pathInfo?.authSource === "settings"
|| pathInfo?.authSource === "CURSOR_API_KEY";
const isApiKeyMode = authMode === "api-key";
const isCliMode = authMode === "cli-login";
const available = isCliMode
? hasCliLogin
: (hasAnyApiKey && Boolean(pathInfo?.sdkInstalled ?? true));
const canSave = isApiKeyMode && !isSaving && !isDecrypting && (Boolean(apiKeyDraft.trim()) || hasStoredApiKey);
const installStatus = isResolvingPath
? t("ai.cursor.detecting")
: installed
? t("ai.cursor.installed")
: t("ai.cursor.notInstalled");
const authStatus = isCliMode
? hasCliLogin
? (pathInfo?.cliEmail
? t("ai.cursor.cliLoginAs", { email: pathInfo.cliEmail })
: t("ai.cursor.cliLoginOk"))
: t("ai.cursor.cliLoginMissing")
: hasAnyApiKey
? usesEnvApiKey && !hasStoredApiKey
? t("ai.cursor.apiKeyFromEnv")
: t("ai.cursor.apiKeyConfigured")
: t("ai.cursor.apiKeyMissing");
const installStatusClassName = isResolvingPath
? "text-muted-foreground"
: installed
? "text-emerald-500"
: "text-amber-500";
const authStatusClassName = isCliMode
? (hasCliLogin ? "text-emerald-500" : "text-amber-500")
: (hasAnyApiKey ? "text-emerald-500" : "text-amber-500");
const handleSave = async () => {
if (!isApiKeyMode) return;
setIsSaving(true);
setSaved(false);
try {
await onSaveApiKey(apiKeyDraft.trim());
setSaved(true);
} finally {
setIsSaving(false);
}
};
return (
<div className="rounded-lg border bg-card p-4 space-y-3">
<div className="flex gap-1 rounded-md border border-border/60 p-0.5 bg-muted/30">
<button
type="button"
onClick={() => onAuthModeChange("cli-login")}
className={cn(
"flex-1 h-7 rounded text-xs font-medium transition-colors",
isCliMode ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground",
)}
>
{t("ai.cursor.modeCli")}
</button>
<button
type="button"
onClick={() => onAuthModeChange("api-key")}
className={cn(
"flex-1 h-7 rounded text-xs font-medium transition-colors",
isApiKeyMode ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground",
)}
>
{t("ai.cursor.modeApiKey")}
</button>
</div>
<p className="text-[11px] text-muted-foreground leading-4">
{isCliMode ? t("ai.cursor.modeCliHint") : t("ai.cursor.modeApiKeyHint")}
</p>
<div className="grid gap-2 text-xs">
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">{t("ai.cursor.installStatus")}</span>
<span className={cn("font-medium", installStatusClassName)}>{installStatus}</span>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">
{isCliMode ? t("ai.cursor.cliLoginStatus") : t("ai.cursor.apiKeyStatus")}
</span>
<span className={cn("font-medium truncate max-w-[60%] text-right", authStatusClassName)}>
{authStatus}
</span>
</div>
</div>
{!available && (
<p className="text-xs text-amber-500">
{isCliMode
? t("ai.cursor.cliLoginHint")
: (Boolean(pathInfo?.sdkInstalled) || installed)
? t("ai.cursor.notFoundHint")
: t("ai.cursor.notInstalledHint")}
</p>
)}
{isApiKeyMode ? (
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">{t("ai.cursor.apiKey")}</label>
<div className="flex items-center gap-2">
<div className="relative flex-1">
<input
type={showApiKey ? "text" : "password"}
value={isDecrypting ? "" : apiKeyDraft}
onChange={(event) => {
setSaved(false);
setApiKeyDraft(event.target.value);
}}
placeholder={
isDecrypting
? t("ai.providers.apiKey.decrypting")
: usesEnvApiKey && !hasStoredApiKey
? t("ai.cursor.apiKeyPlaceholder.env")
: t("ai.cursor.apiKeyPlaceholder")
}
disabled={isDecrypting}
className="w-full h-8 rounded-md border border-input bg-background px-3 pr-9 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50"
/>
<button
type="button"
onClick={() => setShowApiKey((value) => !value)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
aria-label={showApiKey ? t("ai.cursor.hideApiKey") : t("ai.cursor.showApiKey")}
>
{showApiKey ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
</div>
<Button variant="outline" size="sm" onClick={handleSave} disabled={!canSave}>
{saved ? <Check size={14} className="mr-1.5" /> : null}
{saved ? t("ai.cursor.saved") : t("ai.cursor.saveApiKey")}
</Button>
<Button variant="outline" size="sm" onClick={onRecheckPath} disabled={isResolvingPath}>
<RefreshCw size={14} className="mr-1.5" />
{t("ai.cursor.check")}
</Button>
</div>
{usesEnvApiKey && !hasStoredApiKey ? (
<p className="text-[11px] text-muted-foreground leading-4">
{t("ai.cursor.apiKeyEnvHint")}
</p>
) : null}
{usesEnvApiKey && hasStoredApiKey ? (
<p className="text-[11px] text-muted-foreground leading-4">
{t("ai.cursor.apiKeyOverrideHint")}
</p>
) : null}
</div>
) : (
<div className="flex justify-end">
<Button variant="outline" size="sm" onClick={onRecheckPath} disabled={isResolvingPath}>
<RefreshCw size={14} className="mr-1.5" />
{t("ai.cursor.check")}
</Button>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,928 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Check, Copy, HelpCircle, RefreshCw } from "lucide-react";
import { useI18n } from "../../../../application/i18n/I18nProvider";
import {
readExternalMcpFocusOnHostOpen,
readExternalMcpIdleTimeoutMinutes,
readExternalMcpMode,
readExternalMcpSilentSessions,
readSessionIdleTimeoutMinutes,
writeExternalMcpFocusOnHostOpen,
writeExternalMcpIdleTimeoutMinutes,
writeExternalMcpMode,
writeExternalMcpSilentSessions,
writeSessionIdleTimeoutMinutes,
type ExternalMcpMode,
useExternalMcpToggleState,
} from "../../../../application/state/useExternalMcpToggleState";
import { cn } from "../../../../lib/utils";
import { Button } from "../../../ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../ui/tooltip";
import { Select, SettingCard, SettingRow, Toggle } from "../../../settings/settings-ui";
import { getBridge } from "./types";
type ExternalMcpClient = "codex" | "claude" | "grok" | "cursor";
const CLIENT_TABS: ExternalMcpClient[] = ["codex", "claude", "grok", "cursor"];
type CopyableCodeBlockProps = {
label?: string;
value: string;
copyKey: string;
copied: string | null;
onCopy: (key: string, text: string) => void;
copyLabel: string;
copiedLabel: string;
emptyLabel?: string;
className?: string;
};
const CopyableCodeBlock: React.FC<CopyableCodeBlockProps> = ({
label,
value,
copyKey,
copied,
onCopy,
copyLabel,
copiedLabel,
emptyLabel,
className,
}) => {
const display = value || emptyLabel || "";
const canCopy = Boolean(value);
const isCopied = copied === copyKey;
return (
<div className={cn("space-y-1.5", className)}>
{label ? (
<div className="text-xs font-medium text-muted-foreground">{label}</div>
) : null}
<div className="group relative rounded-md border border-border/60 bg-muted/20">
<pre
className={cn(
"max-h-40 overflow-auto whitespace-pre-wrap break-all px-3 py-2.5 pr-11 font-mono text-xs leading-5",
!value && "text-muted-foreground",
)}
>
{display}
</pre>
<Button
type="button"
variant="ghost"
size="sm"
disabled={!canCopy}
className="absolute right-1.5 top-1.5 h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
onClick={() => void onCopy(copyKey, value)}
aria-label={isCopied ? copiedLabel : copyLabel}
title={isCopied ? copiedLabel : copyLabel}
>
{isCopied ? <Check size={14} className="text-emerald-500" /> : <Copy size={14} />}
</Button>
</div>
</div>
);
};
type ExternalMcpStatus = {
ok: boolean;
enabled?: boolean;
state?: string;
host?: string;
port?: number | null;
discoveryPath?: string | null;
launcherPath?: string | null;
exposedSessionCount?: number;
mode?: ExternalMcpMode;
idleTimeoutMinutes?: number;
sessionIdleTimeoutMinutes?: number;
permissionMode?: string;
error?: string | null;
};
type ClientSetupStatus = {
ok: boolean;
state?: string;
launcherPath?: string | null;
command?: string;
existingCommand?: string | null;
error?: string | null;
};
type StatusView = {
labelKey: string;
className: string;
};
function getBridgeStatusView(status: ExternalMcpStatus | null, enabled: boolean): StatusView {
if (!enabled) {
return { labelKey: "ai.externalMcp.status.disabled", className: "text-muted-foreground" };
}
if (!status || !status.ok) {
return { labelKey: "ai.externalMcp.status.unavailable", className: "text-amber-500" };
}
if (status.state === "running") {
return { labelKey: "ai.externalMcp.status.running", className: "text-emerald-500" };
}
if (status.state === "starting") {
return { labelKey: "ai.externalMcp.status.starting", className: "text-amber-500" };
}
if (status.state === "error") {
return { labelKey: "ai.externalMcp.status.error", className: "text-destructive" };
}
return { labelKey: "ai.externalMcp.status.disabled", className: "text-muted-foreground" };
}
/** Map bridge permissionMode to a Safety i18n key for display. */
function getPermissionModeLabelKey(mode: string | null | undefined): string {
switch (mode) {
case "observer":
return "ai.safety.permissionMode.observer";
case "auto":
return "ai.safety.permissionMode.auto";
case "confirm":
return "ai.safety.permissionMode.confirm";
default:
return "ai.externalMcp.permissionMode.unknown";
}
}
function getPermissionModeToneClass(mode: string | null | undefined): string {
switch (mode) {
case "auto":
return "text-emerald-500";
case "observer":
return "text-amber-500";
case "confirm":
return "text-foreground";
default:
return "text-muted-foreground";
}
}
function getCodexStatusView(status: ClientSetupStatus | null): StatusView {
switch (status?.state) {
case "configured":
return { labelKey: "ai.externalMcp.status.configured", className: "text-emerald-500" };
case "not_configured":
return { labelKey: "ai.externalMcp.status.notConfigured", className: "text-muted-foreground" };
case "codex_not_found":
return { labelKey: "ai.externalMcp.status.codexNotFound", className: "text-amber-500" };
case "conflict":
return { labelKey: "ai.externalMcp.status.conflict", className: "text-destructive" };
case "error":
return { labelKey: "ai.externalMcp.status.error", className: "text-destructive" };
default:
return { labelKey: "ai.externalMcp.status.checking", className: "text-muted-foreground" };
}
}
function getClaudeStatusView(status: ClientSetupStatus | null): StatusView {
switch (status?.state) {
case "configured":
return { labelKey: "ai.externalMcp.status.configured", className: "text-emerald-500" };
case "not_configured":
return { labelKey: "ai.externalMcp.status.notConfigured", className: "text-muted-foreground" };
case "claude_not_found":
return { labelKey: "ai.externalMcp.status.claudeNotFound", className: "text-amber-500" };
case "conflict":
return { labelKey: "ai.externalMcp.status.conflict", className: "text-destructive" };
case "error":
return { labelKey: "ai.externalMcp.status.error", className: "text-destructive" };
default:
return { labelKey: "ai.externalMcp.status.checking", className: "text-muted-foreground" };
}
}
function getGrokStatusView(status: ClientSetupStatus | null): StatusView {
switch (status?.state) {
case "configured":
return { labelKey: "ai.externalMcp.status.configured", className: "text-emerald-500" };
case "not_configured":
return { labelKey: "ai.externalMcp.status.notConfigured", className: "text-muted-foreground" };
case "grok_not_found":
return { labelKey: "ai.externalMcp.status.grokNotFound", className: "text-amber-500" };
case "conflict":
return { labelKey: "ai.externalMcp.status.conflict", className: "text-destructive" };
case "error":
return { labelKey: "ai.externalMcp.status.error", className: "text-destructive" };
default:
return { labelKey: "ai.externalMcp.status.checking", className: "text-muted-foreground" };
}
}
function escapeTomlBasicString(value: string) {
return value.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"");
}
function quoteShellArg(value: string) {
if (!value) return '""';
if (!/[\s"'\\]/.test(value)) return value;
return `"${value.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"")}"`;
}
export const EXTERNAL_MCP_DISCOVERY_ENV_VAR = "NETCATTY_EXTERNAL_MCP_DISCOVERY_FILE";
export function formatCodexAddCommand(launcherPath: string, discoveryPath?: string | null) {
const envFlags = discoveryPath
? ` --env ${EXTERNAL_MCP_DISCOVERY_ENV_VAR}=${quoteShellArg(discoveryPath)}`
: "";
return `codex mcp add netcatty-external${envFlags} -- ${quoteShellArg(launcherPath)}`;
}
export function formatClaudeAddCommand(launcherPath: string, discoveryPath?: string | null) {
const envFlags = discoveryPath
? ` -e ${EXTERNAL_MCP_DISCOVERY_ENV_VAR}=${quoteShellArg(discoveryPath)}`
: "";
return `claude mcp add -s user netcatty-external${envFlags} -- ${quoteShellArg(launcherPath)}`;
}
export function formatGrokAddCommand(launcherPath: string, discoveryPath?: string | null) {
const envFlags = discoveryPath
? ` -e ${EXTERNAL_MCP_DISCOVERY_ENV_VAR}=${quoteShellArg(discoveryPath)}`
: "";
return `grok mcp add netcatty-external${envFlags} -- ${quoteShellArg(launcherPath)}`;
}
function buildTomlEnvBlock(discoveryPath?: string | null) {
if (!discoveryPath) return "";
return `\nenv = { ${EXTERNAL_MCP_DISCOVERY_ENV_VAR} = "${escapeTomlBasicString(discoveryPath)}" }`;
}
export function buildCodexTomlSnippet(launcherPath: string, discoveryPath?: string | null) {
return `[mcp_servers.netcatty-external]
command = "${escapeTomlBasicString(launcherPath)}"
args = []${buildTomlEnvBlock(discoveryPath)}`;
}
export function buildGrokTomlSnippet(launcherPath: string, discoveryPath?: string | null) {
return `[mcp_servers.netcatty-external]
command = "${escapeTomlBasicString(launcherPath)}"
args = []${buildTomlEnvBlock(discoveryPath)}`;
}
function buildJsonServerEntry(launcherPath: string, discoveryPath?: string | null) {
const entry: {
command: string;
args: string[];
env?: Record<string, string>;
} = {
command: launcherPath,
args: [],
};
if (discoveryPath) {
entry.env = { [EXTERNAL_MCP_DISCOVERY_ENV_VAR]: discoveryPath };
}
return entry;
}
export function buildClaudeSnippet(launcherPath: string, discoveryPath?: string | null) {
return JSON.stringify({
mcpServers: {
"netcatty-external": buildJsonServerEntry(launcherPath, discoveryPath),
},
}, null, 2);
}
export function buildCursorSnippet(launcherPath: string, discoveryPath?: string | null) {
return JSON.stringify({
mcpServers: {
"netcatty-external": buildJsonServerEntry(launcherPath, discoveryPath),
},
}, null, 2);
}
export const ExternalMcpCard: React.FC = () => {
const { t } = useI18n();
const { enabled, setEnabled } = useExternalMcpToggleState();
const [mode, setModeRaw] = useState<ExternalMcpMode>(() => readExternalMcpMode());
const [idleTimeoutMinutes, setIdleTimeoutRaw] = useState<number>(() => readExternalMcpIdleTimeoutMinutes());
const [focusOnHostOpen, setFocusOnHostOpenRaw] = useState<boolean>(() => readExternalMcpFocusOnHostOpen());
const [sessionIdleTimeoutMinutes, setSessionIdleTimeoutRaw] = useState<number>(() => readSessionIdleTimeoutMinutes());
const [silentSessions, setSilentSessionsRaw] = useState<boolean>(() => readExternalMcpSilentSessions());
const [status, setStatus] = useState<ExternalMcpStatus | null>(null);
const [selectedClient, setSelectedClient] = useState<ExternalMcpClient>("codex");
const [codexStatus, setCodexStatus] = useState<ClientSetupStatus | null>(null);
const [claudeStatus, setClaudeStatus] = useState<ClientSetupStatus | null>(null);
const [grokStatus, setGrokStatus] = useState<ClientSetupStatus | null>(null);
const [isRefreshing, setIsRefreshing] = useState(false);
const [isAddingCodex, setIsAddingCodex] = useState(false);
const [isAddingClaude, setIsAddingClaude] = useState(false);
const [isAddingGrok, setIsAddingGrok] = useState(false);
const [copied, setCopied] = useState<string | null>(null);
const [actionMessage, setActionMessage] = useState<{ tone: "error" | "warning" | "success"; text: string } | null>(null);
const bridgeUnavailableMessage = t("ai.externalMcp.bridgeUnavailable");
const pushConfig = useCallback((nextMode: ExternalMcpMode, nextIdle: number, nextSessionIdle: number) => {
void getBridge()?.externalMcpSetConfig?.({
mode: nextMode,
idleTimeoutMinutes: nextIdle,
sessionIdleTimeoutMinutes: nextSessionIdle,
});
}, []);
const setMode = useCallback((nextMode: ExternalMcpMode) => {
const normalized = writeExternalMcpMode(nextMode);
setModeRaw(normalized);
pushConfig(normalized, idleTimeoutMinutes, sessionIdleTimeoutMinutes);
}, [idleTimeoutMinutes, pushConfig, sessionIdleTimeoutMinutes]);
const setIdleTimeoutMinutes = useCallback((minutes: number) => {
const normalized = writeExternalMcpIdleTimeoutMinutes(minutes);
setIdleTimeoutRaw(normalized);
pushConfig(mode, normalized, sessionIdleTimeoutMinutes);
}, [mode, pushConfig, sessionIdleTimeoutMinutes]);
const setSessionIdleTimeoutMinutes = useCallback((minutes: number) => {
const normalized = writeSessionIdleTimeoutMinutes(minutes);
setSessionIdleTimeoutRaw(normalized);
pushConfig(mode, idleTimeoutMinutes, normalized);
}, [idleTimeoutMinutes, mode, pushConfig]);
const setFocusOnHostOpen = useCallback((nextFocusOnHostOpen: boolean) => {
setFocusOnHostOpenRaw(nextFocusOnHostOpen);
writeExternalMcpFocusOnHostOpen(nextFocusOnHostOpen);
}, []);
const setSilentSessions = useCallback((nextSilentSessions: boolean) => {
setSilentSessionsRaw(nextSilentSessions);
writeExternalMcpSilentSessions(nextSilentSessions);
}, []);
const refreshStatus = useCallback(async (options?: { quiet?: boolean; clients?: boolean }) => {
const bridge = getBridge();
const includeClients = options?.clients !== false;
if (
!bridge?.externalMcpGetStatus
|| !bridge?.externalMcpCodexGetStatus
|| !bridge?.externalMcpClaudeGetStatus
|| !bridge?.externalMcpGrokGetStatus
) {
setStatus({
ok: false,
enabled,
state: "unavailable",
discoveryPath: null,
launcherPath: null,
exposedSessionCount: 0,
// Bridge default when IPC is missing; keeps the permission row from
// looking blank while Safety settings remain the source of truth.
permissionMode: "confirm",
error: bridgeUnavailableMessage,
});
const unavailableClientStatus: ClientSetupStatus = {
ok: true,
state: "error",
launcherPath: null,
command: "",
existingCommand: null,
error: bridgeUnavailableMessage,
};
setCodexStatus(unavailableClientStatus);
setClaudeStatus(unavailableClientStatus);
setGrokStatus(unavailableClientStatus);
return;
}
if (!options?.quiet) setIsRefreshing(true);
try {
if (includeClients) {
const [nextStatus, nextCodexStatus, nextClaudeStatus, nextGrokStatus] = await Promise.all([
bridge.externalMcpGetStatus(),
bridge.externalMcpCodexGetStatus(),
bridge.externalMcpClaudeGetStatus(),
bridge.externalMcpGrokGetStatus(),
]);
setStatus(nextStatus as ExternalMcpStatus);
if (enabled && nextStatus?.ok && !nextStatus.enabled) {
setEnabled(false);
}
setCodexStatus(nextCodexStatus as ClientSetupStatus);
setClaudeStatus(nextClaudeStatus as ClientSetupStatus);
setGrokStatus(nextGrokStatus as ClientSetupStatus);
} else {
const nextStatus = await bridge.externalMcpGetStatus();
setStatus(nextStatus as ExternalMcpStatus);
if (enabled && nextStatus?.ok && !nextStatus.enabled) {
setEnabled(false);
}
}
} finally {
if (!options?.quiet) setIsRefreshing(false);
}
}, [bridgeUnavailableMessage, enabled, setEnabled]);
useEffect(() => {
void refreshStatus();
}, [refreshStatus]);
useEffect(() => {
if (!enabled) return;
// Quiet polling only refreshes bridge runtime status. Spawning Codex/Claude/Grok
// CLIs every few seconds is too expensive for a settings page keep-alive.
const intervalId = window.setInterval(() => {
void refreshStatus({ quiet: true, clients: false });
}, 3000);
return () => window.clearInterval(intervalId);
}, [enabled, refreshStatus]);
useEffect(() => {
pushConfig(mode, idleTimeoutMinutes, sessionIdleTimeoutMinutes);
}, []); // eslint-disable-line react-hooks/exhaustive-deps -- sync stored config once on mount
const bridgeStatusView = useMemo(() => getBridgeStatusView(status, enabled), [enabled, status]);
const exposedSessionCount = enabled ? status?.exposedSessionCount ?? 0 : 0;
const codexStatusView = useMemo(() => getCodexStatusView(codexStatus), [codexStatus]);
const claudeStatusView = useMemo(() => getClaudeStatusView(claudeStatus), [claudeStatus]);
const grokStatusView = useMemo(() => getGrokStatusView(grokStatus), [grokStatus]);
const launcherPath = status?.launcherPath
|| codexStatus?.launcherPath
|| claudeStatus?.launcherPath
|| grokStatus?.launcherPath
|| null;
const discoveryPath = status?.discoveryPath || null;
// Prefer backend status.command so desktop-resolved absolute CLI paths
// (outside PATH) survive into the copyable setup command.
const codexCommand = (codexStatus?.command || "").trim()
|| (launcherPath ? formatCodexAddCommand(launcherPath, discoveryPath) : "");
const claudeCommand = (claudeStatus?.command || "").trim()
|| (launcherPath ? formatClaudeAddCommand(launcherPath, discoveryPath) : "");
const grokCommand = (grokStatus?.command || "").trim()
|| (launcherPath ? formatGrokAddCommand(launcherPath, discoveryPath) : "");
const codexTomlSnippet = launcherPath ? buildCodexTomlSnippet(launcherPath, discoveryPath) : "";
const grokTomlSnippet = launcherPath ? buildGrokTomlSnippet(launcherPath, discoveryPath) : "";
const claudeSnippet = launcherPath ? buildClaudeSnippet(launcherPath, discoveryPath) : "";
const cursorSnippet = launcherPath ? buildCursorSnippet(launcherPath, discoveryPath) : "";
const canAddToCodex = codexStatus?.state === "not_configured";
const canAddToClaude = claudeStatus?.state === "not_configured";
const canAddToGrok = grokStatus?.state === "not_configured";
const copyText = useCallback(async (key: string, text: string) => {
if (!text) return;
try {
await navigator.clipboard.writeText(text);
setCopied(key);
window.setTimeout(() => {
setCopied((current) => (current === key ? null : current));
}, 1200);
} catch {
setActionMessage({ tone: "error", text: t("ai.externalMcp.copyFailed") });
}
}, [t]);
const handleAddToCodex = useCallback(async () => {
const bridge = getBridge();
if (!bridge?.externalMcpCodexAdd) return;
setActionMessage(null);
setIsAddingCodex(true);
try {
const result = await bridge.externalMcpCodexAdd() as ClientSetupStatus;
setCodexStatus(result);
if (result.state === "configured") {
setActionMessage({ tone: "success", text: t("ai.externalMcp.codexAdded") });
} else if (result.state === "codex_not_found") {
setActionMessage({ tone: "warning", text: t("ai.externalMcp.installCodex") });
} else if (result.state === "conflict") {
setActionMessage({ tone: "error", text: t("ai.externalMcp.conflict.description") });
} else if (result.state === "error" && result.error) {
setActionMessage({ tone: "error", text: result.error });
}
await refreshStatus({ quiet: true });
} finally {
setIsAddingCodex(false);
}
}, [refreshStatus, t]);
const handleAddToClaude = useCallback(async () => {
const bridge = getBridge();
if (!bridge?.externalMcpClaudeAdd) return;
setActionMessage(null);
setIsAddingClaude(true);
try {
const result = await bridge.externalMcpClaudeAdd() as ClientSetupStatus;
setClaudeStatus(result);
if (result.state === "configured") {
setActionMessage({ tone: "success", text: t("ai.externalMcp.claudeAdded") });
} else if (result.state === "claude_not_found") {
setActionMessage({ tone: "warning", text: t("ai.externalMcp.installClaude") });
} else if (result.state === "conflict") {
setActionMessage({ tone: "error", text: t("ai.externalMcp.conflict.description") });
} else if (result.state === "error" && result.error) {
setActionMessage({ tone: "error", text: result.error });
}
await refreshStatus({ quiet: true });
} finally {
setIsAddingClaude(false);
}
}, [refreshStatus, t]);
const handleAddToGrok = useCallback(async () => {
const bridge = getBridge();
if (!bridge?.externalMcpGrokAdd) return;
setActionMessage(null);
setIsAddingGrok(true);
try {
const result = await bridge.externalMcpGrokAdd() as ClientSetupStatus;
setGrokStatus(result);
if (result.state === "configured") {
setActionMessage({ tone: "success", text: t("ai.externalMcp.grokAdded") });
} else if (result.state === "grok_not_found") {
setActionMessage({ tone: "warning", text: t("ai.externalMcp.installGrok") });
} else if (result.state === "conflict") {
setActionMessage({ tone: "error", text: t("ai.externalMcp.conflict.description") });
} else if (result.state === "error" && result.error) {
setActionMessage({ tone: "error", text: result.error });
}
await refreshStatus({ quiet: true });
} finally {
setIsAddingGrok(false);
}
}, [refreshStatus, t]);
const selectedClientMeta = useMemo(() => {
if (selectedClient === "cursor") {
return {
kind: "snippet" as const,
statusView: null as StatusView | null,
command: "",
snippet: cursorSnippet,
canAdd: false,
isAdding: false,
addLabelKey: "",
onAdd: null as (() => void) | null,
};
}
if (selectedClient === "claude") {
return {
kind: "installable" as const,
statusView: claudeStatusView,
command: claudeCommand,
snippet: claudeSnippet,
canAdd: canAddToClaude,
isAdding: isAddingClaude,
addLabelKey: "ai.externalMcp.addToClaude",
onAdd: () => { void handleAddToClaude(); },
};
}
if (selectedClient === "grok") {
return {
kind: "installable" as const,
statusView: grokStatusView,
command: grokCommand,
snippet: grokTomlSnippet,
canAdd: canAddToGrok,
isAdding: isAddingGrok,
addLabelKey: "ai.externalMcp.addToGrok",
onAdd: () => { void handleAddToGrok(); },
};
}
return {
kind: "installable" as const,
statusView: codexStatusView,
command: codexCommand,
snippet: codexTomlSnippet,
canAdd: canAddToCodex,
isAdding: isAddingCodex,
addLabelKey: "ai.externalMcp.addToCodex",
onAdd: () => { void handleAddToCodex(); },
};
}, [
canAddToClaude,
canAddToCodex,
canAddToGrok,
claudeCommand,
claudeSnippet,
claudeStatusView,
codexCommand,
codexStatusView,
codexTomlSnippet,
cursorSnippet,
grokCommand,
grokStatusView,
grokTomlSnippet,
handleAddToClaude,
handleAddToCodex,
handleAddToGrok,
isAddingClaude,
isAddingCodex,
isAddingGrok,
selectedClient,
]);
return (
<div className="rounded-lg border bg-card p-4 space-y-3">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex items-start gap-1.5">
<p className="min-w-0 text-xs text-muted-foreground leading-5">
{t("ai.externalMcp.description")}
</p>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="relative -top-px mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
aria-label={t("ai.externalMcp.help.ariaLabel")}
>
<HelpCircle size={13} />
</button>
</TooltipTrigger>
<TooltipContent
side="bottom"
align="start"
className="max-w-[320px] space-y-1.5 bg-popover text-popover-foreground border border-border px-3 py-2.5 text-left text-xs leading-relaxed shadow-md"
>
<div className="font-medium text-foreground">{t("ai.externalMcp.usage.title")}</div>
<p>{t("ai.externalMcp.usage.keepRunning")}</p>
<p>{t("ai.externalMcp.usage.localhost")}</p>
<p>{t("ai.externalMcp.usage.permissions")}</p>
<p>{t("ai.externalMcp.usage.capabilities")}</p>
<div className="pt-1 font-medium text-foreground">{t("ai.externalMcp.security")}</div>
<p>{t("ai.externalMcp.security.description")}</p>
</TooltipContent>
</Tooltip>
</div>
<div className={cn("text-xs font-medium shrink-0", bridgeStatusView.className)}>
{t(bridgeStatusView.labelKey)}
</div>
</div>
<div className="flex items-center justify-between gap-4 rounded-md border border-border/60 bg-background/70 px-3 py-2">
<div className="min-w-0">
<div className="text-sm font-medium">{t("ai.externalMcp.title")}</div>
<div className="text-xs text-muted-foreground">
{t("ai.externalMcp.sessionsExposed", { count: String(exposedSessionCount) })}
</div>
</div>
<Toggle
checked={enabled}
onChange={(nextEnabled) => {
setActionMessage(null);
setEnabled(nextEnabled);
window.setTimeout(() => { void refreshStatus(); }, 0);
}}
/>
</div>
{/* Permission mode is controlled in Safety settings; surface it here so External MCP
users see why write tools may still prompt (confirm) or run freely (auto). */}
<div className="flex items-start justify-between gap-4 rounded-md border border-border/60 bg-background/70 px-3 py-2">
<div className="min-w-0 space-y-1">
<div className="text-sm font-medium">{t("ai.externalMcp.permissionMode.label")}</div>
<div className="text-xs text-muted-foreground leading-5">
{t("ai.externalMcp.permissionMode.hint")}
</div>
</div>
<div
className={cn(
"shrink-0 text-xs font-medium text-right max-w-[12rem]",
getPermissionModeToneClass(status?.permissionMode),
)}
data-testid="external-mcp-permission-mode"
>
{t(getPermissionModeLabelKey(status?.permissionMode))}
</div>
</div>
<SettingCard divided className="rounded-md border-border/60 bg-background/70">
<SettingRow
label={t("ai.externalMcp.mode")}
description={t("ai.externalMcp.mode.description")}
>
<Select
value={mode}
options={[
{ value: "temporary", label: t("ai.externalMcp.mode.temporary") },
{ value: "persistent", label: t("ai.externalMcp.mode.persistent") },
]}
onChange={(value) => setMode(value === "persistent" ? "persistent" : "temporary")}
className="w-36"
/>
</SettingRow>
{mode === "temporary" ? (
<SettingRow
label={t("ai.externalMcp.idleTimeout")}
description={t("ai.externalMcp.idleTimeout.description")}
>
<div className="flex items-center gap-2 shrink-0">
<input
type="number"
aria-label={t("ai.externalMcp.idleTimeout")}
min={1}
max={24 * 60}
value={idleTimeoutMinutes}
onChange={(event) => {
const minutes = Number.parseInt(event.currentTarget.value, 10);
if (!Number.isFinite(minutes)) return;
setIdleTimeoutMinutes(minutes);
}}
className="w-20 rounded-md border border-border/60 bg-background px-2 py-1 text-sm"
/>
<span className="text-xs text-muted-foreground">{t("ai.externalMcp.idleTimeout.minutes")}</span>
</div>
</SettingRow>
) : null}
<SettingRow
label={t("ai.externalMcp.focusOnHostOpen")}
description={t("ai.externalMcp.focusOnHostOpen.description")}
>
<Toggle
checked={focusOnHostOpen}
onChange={setFocusOnHostOpen}
ariaLabel={t("ai.externalMcp.focusOnHostOpen")}
/>
</SettingRow>
<SettingRow
label={t("ai.externalMcp.silentSessions")}
description={t("ai.externalMcp.silentSessions.description")}
>
<Toggle
checked={silentSessions}
onChange={setSilentSessions}
ariaLabel={t("ai.externalMcp.silentSessions")}
/>
</SettingRow>
<SettingRow
label={t("ai.externalMcp.sessionIdleTimeout")}
description={t("ai.externalMcp.sessionIdleTimeout.description")}
>
<div className="flex items-center gap-2 shrink-0">
<input
type="number"
aria-label={t("ai.externalMcp.sessionIdleTimeout")}
min={1}
max={24 * 60}
value={sessionIdleTimeoutMinutes}
onChange={(event) => {
const minutes = Number.parseInt(event.currentTarget.value, 10);
if (!Number.isFinite(minutes)) return;
setSessionIdleTimeoutMinutes(minutes);
}}
className="w-20 rounded-md border border-border/60 bg-background px-2 py-1 text-sm"
/>
<span className="text-xs text-muted-foreground">{t("ai.externalMcp.idleTimeout.minutes")}</span>
</div>
</SettingRow>
</SettingCard>
<div className="space-y-2">
<div className="flex min-h-8 items-center justify-between gap-2">
<div className="text-sm font-semibold text-foreground">{t("ai.externalMcp.discovery")}</div>
<Button
variant="outline"
size="sm"
onClick={() => void refreshStatus()}
disabled={isRefreshing}
>
<RefreshCw size={14} className={cn("mr-1.5", isRefreshing && "animate-spin")} />
{t("ai.externalMcp.refresh")}
</Button>
</div>
<div className="space-y-2.5 rounded-md border border-border/60 bg-background/50 p-3">
<CopyableCodeBlock
label={t("ai.externalMcp.launcher")}
value={launcherPath || ""}
copyKey="launcher"
copied={copied}
onCopy={copyText}
copyLabel={t("ai.externalMcp.copy")}
copiedLabel={t("ai.externalMcp.copied")}
emptyLabel={t("ai.externalMcp.unavailable")}
/>
<CopyableCodeBlock
label={t("ai.externalMcp.discovery")}
value={status?.discoveryPath || ""}
copyKey="discovery"
copied={copied}
onCopy={copyText}
copyLabel={t("ai.externalMcp.copy")}
copiedLabel={t("ai.externalMcp.copied")}
emptyLabel={t("ai.externalMcp.unavailable")}
/>
{!enabled ? (
<p className="text-xs text-amber-500">{t("ai.externalMcp.enableForLauncher")}</p>
) : null}
</div>
</div>
<div className="space-y-2">
<div className="space-y-1">
<div className="text-sm font-semibold text-foreground">
{t("ai.externalMcp.clientConfiguration")}
</div>
<p className="text-xs text-muted-foreground leading-5">
{t("ai.externalMcp.clientConfiguration.description")}
</p>
</div>
<div
role="tablist"
aria-label={t("ai.externalMcp.clientConfiguration")}
className="grid grid-cols-4 gap-1 rounded-md bg-muted p-1"
>
{CLIENT_TABS.map((client) => {
const active = selectedClient === client;
return (
<button
key={client}
type="button"
role="tab"
aria-selected={active}
onClick={() => {
setSelectedClient(client);
setActionMessage(null);
}}
className={cn(
"inline-flex h-8 items-center justify-center rounded-sm px-2 text-xs font-medium transition-colors",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
active
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{t(`ai.externalMcp.client.${client}`)}
</button>
);
})}
</div>
<div className="space-y-3 rounded-md border border-border/60 bg-background/50 p-3">
{selectedClientMeta.kind === "installable" && selectedClientMeta.statusView ? (
<div className="flex items-center justify-between gap-3">
<div className={cn("text-xs font-medium", selectedClientMeta.statusView.className)}>
{t(selectedClientMeta.statusView.labelKey)}
</div>
<Button
size="sm"
disabled={
!selectedClientMeta.canAdd
|| selectedClientMeta.isAdding
|| !enabled
|| !launcherPath
}
onClick={() => selectedClientMeta.onAdd?.()}
>
{t(selectedClientMeta.addLabelKey)}
</Button>
</div>
) : (
<p className="text-xs text-muted-foreground leading-5">
{t("ai.externalMcp.cursor.description")}
</p>
)}
{selectedClientMeta.kind === "installable" ? (
<>
<CopyableCodeBlock
label={t("ai.externalMcp.cliCommand")}
value={selectedClientMeta.command}
copyKey="command"
copied={copied}
onCopy={copyText}
copyLabel={t("ai.externalMcp.copy")}
copiedLabel={t("ai.externalMcp.copied")}
emptyLabel={t("ai.externalMcp.unavailable")}
/>
<CopyableCodeBlock
label={t("ai.externalMcp.configSnippet")}
value={selectedClientMeta.snippet}
copyKey="snippet"
copied={copied}
onCopy={copyText}
copyLabel={t("ai.externalMcp.copy")}
copiedLabel={t("ai.externalMcp.copied")}
emptyLabel={t("ai.externalMcp.unavailable")}
/>
</>
) : (
<CopyableCodeBlock
label={t("ai.externalMcp.configSnippet")}
value={selectedClientMeta.snippet}
copyKey="cursor"
copied={copied}
onCopy={copyText}
copyLabel={t("ai.externalMcp.copy")}
copiedLabel={t("ai.externalMcp.copied")}
emptyLabel={t("ai.externalMcp.unavailable")}
/>
)}
</div>
</div>
{actionMessage ? (
<div
className={cn(
"text-xs",
actionMessage.tone === "success" && "text-emerald-500",
actionMessage.tone === "warning" && "text-amber-500",
actionMessage.tone === "error" && "text-destructive",
)}
>
{actionMessage.text}
</div>
) : null}
{status?.error ? (
<div className="text-xs text-destructive">{status.error}</div>
) : null}
</div>
);
};

View File

@@ -0,0 +1,310 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Check, ChevronDown, RefreshCw } from "lucide-react";
import type { AIProviderId, ProviderStyle } from "../../../../infrastructure/ai/types";
import { resolveProviderStyle } from "../../../../infrastructure/ai/types";
import { buildModelDiscoveryHeaders, resolveModelsDiscoveryEndpoint } from "../../../../infrastructure/ai/modelDiscoveryHeaders";
import { buildProviderProbeUrl } from "../../../../infrastructure/ai/providerConnectionProbe";
import { useI18n } from "../../../../application/i18n/I18nProvider";
import { Button } from "../../../ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../ui/tooltip";
import { cn } from "../../../../lib/utils";
import type { FetchedModel } from "./types";
import { getFetchBridge } from "./types";
import { parseFetchedModels } from "./modelMetadata";
export function buildModelSuggestions({
presetModels,
fetchedModels,
hasFetched,
value,
}: {
presetModels?: readonly string[];
fetchedModels: FetchedModel[];
hasFetched: boolean;
value: string;
}): FetchedModel[] {
const byId = new Map<string, FetchedModel>();
for (const modelId of presetModels ?? []) {
const id = modelId.trim();
if (id) byId.set(id, { id });
}
if (hasFetched) {
for (const model of fetchedModels) {
byId.set(model.id, model);
}
}
const allSuggestions = Array.from(byId.values());
if (!value.trim()) return allSuggestions;
const q = value.toLowerCase();
return allSuggestions.filter((m) =>
m.id.toLowerCase().includes(q) || (m.name && m.name.toLowerCase().includes(q)),
);
}
export function getModelSuggestionsPresentation({
suggestionsLength,
isLoading,
error,
hasFetched,
hasPresetModels,
}: {
suggestionsLength: number;
isLoading: boolean;
error: string | null;
hasFetched: boolean;
hasPresetModels: boolean;
}): {
showSuggestions: boolean;
emptyState: "loading" | "error" | "noMatches" | "loadPrompt" | null;
footerState: "loading" | "error" | null;
} {
if (suggestionsLength > 0) {
return {
showSuggestions: true,
emptyState: null,
footerState: isLoading ? "loading" : error ? "error" : null,
};
}
if (isLoading) {
return { showSuggestions: false, emptyState: "loading", footerState: null };
}
if (error) {
return { showSuggestions: false, emptyState: "error", footerState: null };
}
return {
showSuggestions: false,
emptyState: hasFetched || hasPresetModels ? "noMatches" : "loadPrompt",
footerState: null,
};
}
export function getModelSuggestionClassName(isSelected: boolean): string {
return cn(
"w-full text-left px-3 py-1.5 text-xs hover:bg-accent hover:text-accent-foreground transition-colors flex items-center justify-between gap-2",
isSelected && "bg-accent text-accent-foreground",
);
}
export const ModelSelector: React.FC<{
value: string;
onChange: (value: string) => void;
baseURL: string;
modelsEndpoint?: string;
presetModels?: readonly string[];
placeholder?: string;
apiKey?: string;
providerId?: AIProviderId;
/** Optional protocol-family override; falls back to `providerId` via {@link resolveProviderStyle}. */
style?: ProviderStyle;
skipTLSVerify?: boolean;
onModelMetadata?: (model: FetchedModel) => void;
}> = ({ value, onChange, baseURL, modelsEndpoint, presetModels, placeholder, apiKey, providerId, style, skipTLSVerify, onModelMetadata }) => {
const { t } = useI18n();
const [models, setModels] = useState<FetchedModel[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isOpen, setIsOpen] = useState(false);
const [hasFetched, setHasFetched] = useState(false);
// Resolve the wire-protocol family: prefer an explicit style override (set in
// the form), then fall back to the providerId-derived default.
const resolvedStyle: ProviderStyle = style
?? (providerId ? resolveProviderStyle({ providerId }) : "openai");
// Endpoint follows the resolved style so a providerId+style mismatch (e.g.
// Anthropic providerId switched to OpenAI style) still hits the right path.
const effectiveModelsEndpoint = resolveModelsDiscoveryEndpoint(resolvedStyle, modelsEndpoint);
// Ollama runs locally without auth; all other providers need an API key to list models
const needsApiKey = providerId !== "ollama";
const canFetch = !!effectiveModelsEndpoint && (!needsApiKey || !!apiKey);
const hasPresetModels = (presetModels?.length ?? 0) > 0;
const canSuggest = canFetch || hasPresetModels;
const discoveryKey = JSON.stringify({
baseURL,
effectiveModelsEndpoint,
apiKey,
resolvedStyle,
skipTLSVerify,
});
const discoveryKeyRef = useRef(discoveryKey);
useEffect(() => {
discoveryKeyRef.current = discoveryKey;
setModels([]);
setHasFetched(false);
setError(null);
setIsLoading(false);
}, [discoveryKey]);
const fetchModels = useCallback(async () => {
if (!effectiveModelsEndpoint) return;
const bridge = getFetchBridge();
if (!bridge?.aiFetch) return;
const requestKey = discoveryKey;
setIsLoading(true);
setError(null);
try {
// Temporarily allow the provider's host in the backend fetch allowlist
// so model listing works for URLs not yet synced from the main window.
if (bridge.aiAllowlistAddHost && baseURL) {
await bridge.aiAllowlistAddHost(baseURL);
}
const url = buildProviderProbeUrl(baseURL, effectiveModelsEndpoint);
const headers = buildModelDiscoveryHeaders(resolvedStyle, apiKey);
const result = await bridge.aiFetch(url, "GET", headers, undefined, undefined, undefined, undefined, skipTLSVerify);
if (!result.ok) {
if (discoveryKeyRef.current !== requestKey) return;
setError(`Failed to fetch models (${result.error || "unknown error"})`);
return;
}
const parsed = JSON.parse(result.data);
const list = parseFetchedModels(parsed);
list.sort((a, b) => (a.name || a.id).localeCompare(b.name || b.id));
if (discoveryKeyRef.current !== requestKey) return;
setModels(list);
setHasFetched(true);
} catch (err) {
if (discoveryKeyRef.current !== requestKey) return;
setError(err instanceof Error ? err.message : "Failed to parse response");
} finally {
if (discoveryKeyRef.current === requestKey) setIsLoading(false);
}
}, [baseURL, effectiveModelsEndpoint, apiKey, resolvedStyle, skipTLSVerify, discoveryKey]);
// Auto-fetch when dropdown first opens
useEffect(() => {
if (isOpen && canFetch && !hasFetched && !isLoading) {
void fetchModels();
}
}, [isOpen, canFetch, hasFetched, isLoading, fetchModels]);
// Filter preset and discovered models by current input value (inline autocomplete).
const suggestions = useMemo(() => {
return buildModelSuggestions({
presetModels,
fetchedModels: models,
hasFetched,
value,
});
}, [models, presetModels, value, hasFetched]);
const showSuggestions = isOpen && canSuggest;
const presentation = getModelSuggestionsPresentation({
suggestionsLength: suggestions.length,
isLoading,
error,
hasFetched,
hasPresetModels,
});
return (
<div className="relative">
<div className="flex items-center gap-2">
<div className="relative flex-1">
<input
type="text"
value={value}
onChange={(e) => {
onChange(e.target.value);
if (canSuggest && !isOpen) setIsOpen(true);
}}
onFocus={() => { if (canSuggest) setIsOpen(true); }}
onBlur={() => { setIsOpen(false); }}
placeholder={placeholder ?? (canSuggest ? t('ai.providers.searchModel') : t('ai.providers.defaultModel.placeholder'))}
className={cn(
"w-full h-8 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
canSuggest && "pr-8",
)}
/>
{canSuggest && (
<button
type="button"
onMouseDown={(e) => { e.preventDefault(); setIsOpen(!isOpen); }}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<ChevronDown size={14} className={cn("transition-transform", isOpen && "rotate-180")} />
</button>
)}
</div>
{canFetch && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
onClick={() => { setHasFetched(false); void fetchModels(); }}
disabled={isLoading}
className="shrink-0 px-2"
>
<RefreshCw size={14} className={isLoading ? "animate-spin" : ""} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('ai.providers.refreshModels')}</TooltipContent>
</Tooltip>
)}
</div>
{/* Suggestions dropdown */}
{showSuggestions && (
<div className="absolute top-full left-0 right-0 mt-1 z-[101] rounded-md border border-border bg-popover shadow-md">
<div className="max-h-60 overflow-y-auto">
{!presentation.showSuggestions ? (
<div className="px-3 py-3 text-center text-xs text-muted-foreground">
{presentation.emptyState === "loading" ? (
<>
<RefreshCw size={14} className="animate-spin inline mr-1.5" />
{t('ai.providers.loadingModels')}
</>
) : presentation.emptyState === "error" ? (
<span className="text-destructive">{error}</span>
) : presentation.emptyState === "noMatches" ? (
t('ai.providers.noMatchingModels')
) : (
t('ai.providers.clickToLoadModels')
)}
</div>
) : (
suggestions.slice(0, 100).map((m) => (
<button
key={m.id}
onMouseDown={(e) => {
e.preventDefault();
onChange(m.id);
onModelMetadata?.(m);
setIsOpen(false);
}}
className={getModelSuggestionClassName(m.id === value)}
>
<span className="font-mono truncate">{m.id}</span>
{m.id === value && <Check size={12} className="text-accent-foreground shrink-0" />}
</button>
))
)}
{presentation.footerState && (
<div className={cn(
"px-3 py-2 text-center text-[10px] border-t border-border/40",
presentation.footerState === "error" ? "text-destructive" : "text-muted-foreground",
)}>
{presentation.footerState === "loading" ? (
<>
<RefreshCw size={12} className="animate-spin inline mr-1" />
{t('ai.providers.loadingModels')}
</>
) : (
error
)}
</div>
)}
{suggestions.length > 100 && (
<div className="px-3 py-2 text-center text-[10px] text-muted-foreground border-t border-border/40">
{t('ai.providers.showingModels').replace('{count}', String(suggestions.length))}
</div>
)}
</div>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,255 @@
import React, { useCallback, useMemo, useRef, useState } from 'react';
import { Download, Plus, Trash2, Upload } from 'lucide-react';
import { useI18n } from '../../../../application/i18n/I18nProvider';
import { Button } from '../../../ui/button';
import { SettingCard, SettingsSection } from '../../settings-ui';
import { Tooltip, TooltipContent, TooltipTrigger } from '../../../ui/tooltip';
import type { PermissionGrantRule } from '../../../../infrastructure/ai/harness/permissionGrants';
import {
capabilitySupportsCommandPatternGrant,
createPermissionGrantId,
listGrantableCapabilityIds,
} from '../../../../infrastructure/ai/harness/permissionGrants';
const cellInputClass =
'w-full min-w-0 max-w-full h-7 rounded border border-input bg-background px-2 text-xs font-mono focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring overflow-x-auto whitespace-nowrap scrollbar-thin';
const cellSelectClass =
`${cellInputClass} font-sans truncate pr-6`;
const GrantCellInput: React.FC<{
value: string;
placeholder?: string;
mono?: boolean;
onChange: (value: string) => void;
}> = ({ value, placeholder, mono = true, onChange }) => (
<input
type="text"
value={value}
placeholder={placeholder}
onChange={(e) => onChange(e.target.value)}
className={mono ? cellInputClass : `${cellInputClass} font-sans whitespace-normal`}
title={value}
/>
);
const GrantCapabilitySelect: React.FC<{
value: string;
options: readonly string[];
onChange: (value: string) => void;
}> = ({ value, options, onChange }) => {
const selectOptions = useMemo(() => {
if (options.includes(value)) return options;
return [value, ...options];
}, [options, value]);
return (
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className={cellSelectClass}
title={value}
>
{selectOptions.map((capabilityId) => (
<option key={capabilityId} value={capabilityId}>
{capabilityId}
</option>
))}
</select>
);
};
export const PermissionGrantsSettings: React.FC<{
grants: PermissionGrantRule[];
addGrant: (rule: PermissionGrantRule) => void;
updateGrant: (id: string, updates: Partial<Omit<PermissionGrantRule, 'id' | 'createdAt'>>) => void;
removeGrant: (id: string) => void;
importGrants: (raw: unknown, mode?: 'merge' | 'replace') => void;
exportGrants: () => PermissionGrantRule[];
}> = ({
grants,
addGrant,
updateGrant,
removeGrant,
importGrants,
exportGrants,
}) => {
const { t } = useI18n();
const fileInputRef = useRef<HTMLInputElement>(null);
const [importError, setImportError] = useState<string | null>(null);
const grantableCapabilityIds = useMemo(() => listGrantableCapabilityIds(), []);
const handleAdd = useCallback(() => {
addGrant({
id: createPermissionGrantId(),
capabilityId: grantableCapabilityIds[0] ?? 'terminal.execute',
sessionPattern: '*',
createdAt: Date.now(),
});
}, [addGrant, grantableCapabilityIds]);
const handleExport = useCallback(() => {
const payload = exportGrants();
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = 'netcatty-permission-grants.json';
anchor.click();
URL.revokeObjectURL(url);
}, [exportGrants]);
const handleImportFile = useCallback(async (file: File) => {
setImportError(null);
try {
const text = await file.text();
const parsed = JSON.parse(text) as unknown;
importGrants(parsed, 'replace');
} catch (error) {
setImportError(error instanceof Error ? error.message : String(error));
}
}, [importGrants]);
return (
<SettingsSection title={t('ai.safety.grants.title')} anchorId="ai-safety-grants">
<SettingCard padded className="space-y-3 min-w-0 max-w-full overflow-hidden">
<div className="space-y-3">
<div className="min-w-0">
<p className="text-sm font-medium">{t('ai.safety.grants.heading')}</p>
<p className="text-xs text-muted-foreground mt-1">{t('ai.safety.grants.description')}</p>
</div>
<div className="flex flex-wrap items-center gap-1.5">
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={handleAdd}>
<Plus size={14} className="mr-1" />
{t('ai.safety.grants.add')}
</Button>
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={handleExport}>
<Download size={14} className="mr-1" />
{t('ai.safety.grants.export')}
</Button>
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={() => fileInputRef.current?.click()}
>
<Upload size={14} className="mr-1" />
{t('ai.safety.grants.import')}
</Button>
<input
ref={fileInputRef}
type="file"
accept="application/json,.json"
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0];
event.target.value = '';
if (file) void handleImportFile(file);
}}
/>
</div>
</div>
{importError && (
<p className="text-[11px] text-destructive">{importError}</p>
)}
{grants.length === 0 ? (
<p className="text-xs text-muted-foreground py-6 text-center border border-dashed border-border/50 rounded-lg">
{t('ai.safety.grants.empty')}
</p>
) : (
<div className="w-full max-w-full min-w-0 overflow-x-auto overscroll-x-contain rounded-lg border border-border/40 bg-card">
<table className="w-full max-w-full table-fixed text-sm border-collapse">
<colgroup>
<col className="w-[28%]" />
<col className="w-[42%]" />
<col className="w-[24%]" />
<col className="w-[6%]" />
</colgroup>
<thead>
<tr className="bg-muted/50 border-b border-border">
<th className="text-left px-2 py-2 text-xs font-medium text-muted-foreground truncate">
{t('ai.safety.grants.capability')}
</th>
<th className="text-left px-2 py-2 text-xs font-medium text-muted-foreground truncate">
{t('ai.safety.grants.commandPattern')}
</th>
<th className="text-left px-2 py-2 text-xs font-medium text-muted-foreground truncate">
{t('ai.safety.grants.note')}
</th>
<th className="px-1 py-2" aria-hidden />
</tr>
</thead>
<tbody>
{grants.map((grant) => {
const supportsCommandPattern = capabilitySupportsCommandPatternGrant(grant.capabilityId);
return (
<tr
key={grant.id}
className="border-b border-border/60 last:border-b-0 hover:bg-muted/20"
>
<td className="px-2 py-2 align-middle max-w-0">
<GrantCapabilitySelect
value={grant.capabilityId}
options={grantableCapabilityIds}
onChange={(capabilityId) => {
const updates: Partial<Omit<PermissionGrantRule, 'id' | 'createdAt'>> = {
capabilityId,
};
if (!capabilitySupportsCommandPatternGrant(capabilityId)) {
updates.commandPattern = undefined;
}
updateGrant(grant.id, updates);
}}
/>
</td>
<td className="px-2 py-2 align-middle max-w-0">
{supportsCommandPattern ? (
<GrantCellInput
value={grant.commandPattern ?? ''}
placeholder="lscpu *"
onChange={(commandPattern) => updateGrant(grant.id, {
commandPattern: commandPattern.trim() || undefined,
})}
/>
) : (
<span className="text-xs text-muted-foreground px-1"></span>
)}
</td>
<td className="px-2 py-2 align-middle max-w-0">
<GrantCellInput
value={grant.note ?? ''}
mono={false}
onChange={(note) => updateGrant(grant.id, {
note: note.trim() || undefined,
})}
/>
</td>
<td className="px-1 py-2 align-middle text-center">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-destructive hover:bg-destructive/10"
onClick={() => removeGrant(grant.id)}
>
<Trash2 size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('ai.safety.grants.remove')}</TooltipContent>
</Tooltip>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</SettingCard>
</SettingsSection>
);
};

View File

@@ -0,0 +1,104 @@
import React from "react";
import { Pencil, Trash2 } from "lucide-react";
import type { ProviderConfig } from "../../../../infrastructure/ai/types";
import { useI18n } from "../../../../application/i18n/I18nProvider";
import { Toggle } from "../../settings-ui";
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../ui/tooltip";
import { cn } from "../../../../lib/utils";
import { ProviderIconBadge } from "./ProviderIconBadge";
import { ProviderConfigForm } from "./ProviderConfigForm";
export const ProviderCard: React.FC<{
provider: ProviderConfig;
isActive: boolean;
onToggleEnabled: (enabled: boolean) => void;
onEdit: () => void;
onRemove: () => void;
onUpdate: (updates: Partial<ProviderConfig>) => void;
isEditing: boolean;
onCancelEdit: () => void;
}> = ({ provider, isActive, onToggleEnabled, onEdit, onRemove, onUpdate, isEditing, onCancelEdit }) => {
const { t } = useI18n();
const hasApiKey = !!provider.apiKey;
return (
<div
className={cn(
"rounded-lg border p-4 transition-colors",
isActive ? "border-primary/50 bg-primary/5" : "border-border bg-card",
)}
>
<div className="flex items-center gap-3">
{/* Provider icon */}
<ProviderIconBadge provider={provider} />
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium truncate">{provider.name}</span>
{isActive && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-primary/20 text-primary font-medium">
{t('ai.providers.active')}
</span>
)}
</div>
<div className="flex items-center gap-2 mt-0.5">
<span
className={cn(
"text-xs",
hasApiKey ? "text-emerald-500" : "text-muted-foreground",
)}
>
{hasApiKey ? t('ai.providers.apiKeyConfigured') : t('ai.providers.noApiKey')}
</span>
{provider.defaultModel && (
<>
<span className="text-muted-foreground text-xs">|</span>
<span className="text-xs text-muted-foreground truncate">{provider.defaultModel}</span>
</>
)}
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-1 shrink-0">
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={onEdit}
className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
>
<Pencil size={14} />
</button>
</TooltipTrigger>
<TooltipContent>{t('ai.providers.configure')}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={onRemove}
className="p-1.5 rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
>
<Trash2 size={14} />
</button>
</TooltipTrigger>
<TooltipContent>{t('ai.providers.remove')}</TooltipContent>
</Tooltip>
<Toggle checked={provider.enabled} onChange={onToggleEnabled} />
</div>
</div>
{/* Expandable config form */}
{isEditing && (
<ProviderConfigForm
provider={provider}
onSave={(updates) => {
onUpdate(updates);
onCancelEdit();
}}
onCancel={onCancelEdit}
/>
)}
</div>
);
};

View File

@@ -0,0 +1,749 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Check, ChevronDown, ChevronRight, Eye, EyeOff, Pencil, Upload, RotateCcw, X, RefreshCw } from "lucide-react";
import type { ProviderConfig, ProviderAdvancedParams, OpenAIApiFormat, ProviderStyle } from "../../../../infrastructure/ai/types";
import { PROVIDER_PRESETS, resolveOpenAIApi, resolveProviderStyle } from "../../../../infrastructure/ai/types";
import { normalizeOllamaSdkBaseURL } from "../../../../infrastructure/ai/ollamaCompatBaseUrl";
import { sanitizeContextWindow } from "../../../../infrastructure/ai/contextCompaction";
import {
probeProviderConnection,
validateProviderProbeInputs,
type ProviderProbeHealth,
} from "../../../../infrastructure/ai/providerConnectionProbe";
import { encryptField, decryptField } from "../../../../infrastructure/persistence/secureFieldAdapter";
import { useI18n } from "../../../../application/i18n/I18nProvider";
import { Button } from "../../../ui/button";
import { cn } from "../../../../lib/utils";
import type { BuiltinProviderIcon } from "./types";
import { BUILTIN_PROVIDER_ICONS, getFetchBridge } from "./types";
import type { ProviderFormState } from "./types";
import { ModelSelector } from "./ModelSelector";
import { mergeModelContextWindow } from "./modelMetadata";
import { ProviderIconBadge } from "./ProviderIconBadge";
const ICON_PIXEL_SIZE = 64;
const ICON_WEBP_QUALITY = 0.85;
const MAX_UPLOAD_BYTES = 5 * 1024 * 1024;
async function compressIconFileToDataUrl(file: File): Promise<string> {
if (file.size > MAX_UPLOAD_BYTES) {
throw new Error("Image too large; please use an image under 5 MB.");
}
const sourceUrl = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = () => reject(reader.error ?? new Error("Failed to read file"));
reader.readAsDataURL(file);
});
const img = await new Promise<HTMLImageElement>((resolve, reject) => {
const el = new Image();
el.onload = () => resolve(el);
el.onerror = () => reject(new Error("Failed to decode image"));
el.src = sourceUrl;
});
const canvas = document.createElement("canvas");
canvas.width = ICON_PIXEL_SIZE;
canvas.height = ICON_PIXEL_SIZE;
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("Canvas 2D context unavailable");
ctx.clearRect(0, 0, ICON_PIXEL_SIZE, ICON_PIXEL_SIZE);
const scale = Math.min(ICON_PIXEL_SIZE / img.width, ICON_PIXEL_SIZE / img.height);
const w = img.width * scale;
const h = img.height * scale;
ctx.drawImage(img, (ICON_PIXEL_SIZE - w) / 2, (ICON_PIXEL_SIZE - h) / 2, w, h);
return canvas.toDataURL("image/webp", ICON_WEBP_QUALITY);
}
const STYLE_OPTIONS: ReadonlyArray<ProviderStyle> = ["anthropic", "openai", "google"];
const OPENAI_API_OPTIONS: ReadonlyArray<OpenAIApiFormat> = ["chat", "responses"];
/** Same box as the h-8 fields above. Transparent border keeps primary aligned with outline. */
const PROVIDER_ACTION_CLASS = "box-border h-8 px-3 gap-1.5 text-sm font-medium leading-none";
export const ProviderConfigForm: React.FC<{
provider: ProviderConfig;
onSave: (updates: Partial<ProviderConfig>) => void;
onCancel: () => void;
}> = ({ provider, onSave, onCancel }) => {
const { t } = useI18n();
const fileInputRef = useRef<HTMLInputElement | null>(null);
const [form, setForm] = useState<ProviderFormState>({
name: provider.name ?? PROVIDER_PRESETS[provider.providerId]?.name ?? "",
apiKey: "",
baseURL: provider.baseURL ?? PROVIDER_PRESETS[provider.providerId]?.defaultBaseURL ?? "",
defaultModel: provider.defaultModel ?? "",
contextWindow: provider.contextWindow != null ? String(provider.contextWindow) : "",
modelContextWindows: provider.modelContextWindows ?? {},
skipTLSVerify: provider.skipTLSVerify ?? false,
advancedParams: provider.advancedParams ?? {},
style: provider.style ?? "",
openaiApi: resolveOpenAIApi(provider),
iconId: provider.iconId ?? "",
iconDataUrl: provider.iconDataUrl ?? "",
});
const [showApiKey, setShowApiKey] = useState(false);
const [isDecrypting, setIsDecrypting] = useState(false);
const [showAdvanced, setShowAdvanced] = useState(false);
const [showIconPicker, setShowIconPicker] = useState(false);
const [iconError, setIconError] = useState<string | null>(null);
const [contextWindowError, setContextWindowError] = useState<string | null>(null);
const [apiKeySourceVersion, setApiKeySourceVersion] = useState(0);
const [isTesting, setIsTesting] = useState(false);
const [probeResult, setProbeResult] = useState<{
health: ProviderProbeHealth;
message: string;
} | null>(null);
const probeRequestIdRef = useRef(0);
const preset = PROVIDER_PRESETS[provider.providerId];
const resolvedStyle: ProviderStyle = form.style || resolveProviderStyle({ providerId: provider.providerId });
const resolvedBaseURL = provider.providerId === "ollama"
? normalizeOllamaSdkBaseURL(form.baseURL || preset?.defaultBaseURL || "")
: (form.baseURL || preset?.defaultBaseURL || "");
const modelMetadataSourceKey = useMemo(() => JSON.stringify({
providerId: provider.providerId,
baseURL: form.baseURL || preset?.defaultBaseURL || "",
modelsEndpoint: preset?.modelsEndpoint ?? "",
apiKeySourceVersion,
style: resolvedStyle,
skipTLSVerify: form.skipTLSVerify,
}), [
provider.providerId,
form.baseURL,
apiKeySourceVersion,
form.skipTLSVerify,
preset?.defaultBaseURL,
preset?.modelsEndpoint,
resolvedStyle,
]);
const probeFingerprint = useMemo(() => JSON.stringify({
baseURL: form.baseURL || preset?.defaultBaseURL || "",
apiKey: form.apiKey,
style: resolvedStyle,
skipTLSVerify: form.skipTLSVerify,
modelsEndpoint: preset?.modelsEndpoint ?? "",
}), [
form.apiKey,
form.baseURL,
form.skipTLSVerify,
preset?.defaultBaseURL,
preset?.modelsEndpoint,
resolvedStyle,
]);
const modelMetadataSourceKeyRef = useRef<string | null>(null);
const probeFingerprintRef = useRef<string | null>(null);
const previewProvider: Pick<ProviderConfig, "providerId" | "name" | "iconId" | "iconDataUrl"> = {
providerId: provider.providerId,
name: form.name,
iconId: form.iconId || undefined,
iconDataUrl: form.iconDataUrl || undefined,
};
// Decrypt and load existing API key on mount
useEffect(() => {
if (provider.apiKey) {
setIsDecrypting(true);
decryptField(provider.apiKey)
.then((decrypted) => {
setForm((prev) => ({ ...prev, apiKey: decrypted ?? "" }));
})
.catch(() => {
// If decryption fails, show raw value
setForm((prev) => ({ ...prev, apiKey: provider.apiKey ?? "" }));
})
.finally(() => setIsDecrypting(false));
}
}, [provider.apiKey]);
useEffect(() => {
if (modelMetadataSourceKeyRef.current == null) {
modelMetadataSourceKeyRef.current = modelMetadataSourceKey;
return;
}
if (modelMetadataSourceKeyRef.current === modelMetadataSourceKey) return;
modelMetadataSourceKeyRef.current = modelMetadataSourceKey;
setForm((prev) => Object.keys(prev.modelContextWindows).length > 0
? { ...prev, modelContextWindows: {} }
: prev);
}, [modelMetadataSourceKey]);
useEffect(() => {
if (probeFingerprintRef.current == null) {
probeFingerprintRef.current = probeFingerprint;
return;
}
if (probeFingerprintRef.current === probeFingerprint) return;
probeFingerprintRef.current = probeFingerprint;
probeRequestIdRef.current += 1;
setProbeResult(null);
setIsTesting(false);
}, [probeFingerprint]);
const [advancedParamRaw, setAdvancedParamRaw] = useState<Record<string, string>>({});
const handleAdvancedParam = useCallback((key: keyof ProviderAdvancedParams, raw: string) => {
setAdvancedParamRaw((prev) => ({ ...prev, [key]: raw }));
setForm((prev) => {
const next = { ...prev.advancedParams };
if (raw.trim() === "" || raw.trim() === "-") {
delete next[key];
} else {
const num = Number(raw);
if (!Number.isNaN(num)) {
next[key] = num;
}
}
return { ...prev, advancedParams: next };
});
}, []);
const handleIconFileSelect = useCallback(async (file: File | null) => {
setIconError(null);
if (!file) return;
if (!/^image\//.test(file.type)) {
setIconError(t("ai.providers.icon.errorType"));
return;
}
try {
const dataUrl = await compressIconFileToDataUrl(file);
setForm((prev) => ({ ...prev, iconDataUrl: dataUrl, iconId: "" }));
} catch (err) {
setIconError(err instanceof Error ? err.message : String(err));
}
}, [t]);
const handlePickBuiltin = useCallback((icon: BuiltinProviderIcon) => {
setIconError(null);
setForm((prev) => ({ ...prev, iconId: icon.id, iconDataUrl: "", name: icon.name }));
}, []);
const handleResetIcon = useCallback(() => {
setIconError(null);
setForm((prev) => ({ ...prev, iconId: "", iconDataUrl: "" }));
}, []);
const handleApiKeyChange = useCallback((value: string) => {
setApiKeySourceVersion((version) => version + 1);
setForm((prev) => ({ ...prev, apiKey: value }));
}, []);
const handleTestConnection = useCallback(async () => {
const baseURL = resolvedBaseURL;
const inputCheck = validateProviderProbeInputs({
baseURL,
apiKey: form.apiKey,
providerId: provider.providerId,
});
if (!inputCheck.ok) {
probeRequestIdRef.current += 1;
setIsTesting(false);
setProbeResult({
health: "error",
message: t(
inputCheck.reason === "missing_base_url"
? "ai.providers.test.missingBaseUrl"
: "ai.providers.test.missingApiKey",
),
});
return;
}
const requestId = ++probeRequestIdRef.current;
setIsTesting(true);
setProbeResult(null);
try {
const run = await probeProviderConnection({
bridge: getFetchBridge(),
baseURL,
apiKey: form.apiKey,
providerId: provider.providerId,
style: resolvedStyle,
presetModelsEndpoint: preset?.modelsEndpoint,
skipTLSVerify: form.skipTLSVerify,
});
if (probeRequestIdRef.current !== requestId) return;
if (!run.ok) {
setProbeResult({
health: "error",
message: t(
run.reason === "missing_base_url"
? "ai.providers.test.missingBaseUrl"
: run.reason === "missing_api_key"
? "ai.providers.test.missingApiKey"
: "ai.providers.test.unavailable",
),
});
return;
}
const classified = run.classification;
const latency = String(classified.latencyMs);
if (classified.health === "ok") {
setProbeResult({
health: "ok",
message: t("ai.providers.test.ok", { latency }),
});
} else if (classified.health === "warn") {
const warnKey = classified.modelCount === 0 || classified.error
? "ai.providers.test.warn"
: "ai.providers.test.warnSlow";
setProbeResult({
health: "warn",
message: t(warnKey, { latency }),
});
} else {
const detail = classified.error
|| (classified.statusCode ? `HTTP ${classified.statusCode}` : "error");
setProbeResult({
health: "error",
message: t("ai.providers.test.error", { detail }),
});
}
} catch (err) {
if (probeRequestIdRef.current !== requestId) return;
setProbeResult({
health: "error",
message: t("ai.providers.test.error", {
detail: err instanceof Error ? err.message : String(err),
}),
});
} finally {
if (probeRequestIdRef.current === requestId) setIsTesting(false);
}
}, [form.apiKey, form.skipTLSVerify, preset?.modelsEndpoint, provider.providerId, resolvedBaseURL, resolvedStyle, t]);
const handleSave = useCallback(async () => {
const cleanedParams: ProviderAdvancedParams = {};
const ap = form.advancedParams;
if (ap.maxTokens != null && Number.isFinite(ap.maxTokens) && ap.maxTokens > 0) cleanedParams.maxTokens = Math.max(1, Math.round(ap.maxTokens));
if (ap.temperature != null) cleanedParams.temperature = Math.min(2, Math.max(0, ap.temperature));
if (ap.topP != null) cleanedParams.topP = Math.min(1, Math.max(0, ap.topP));
if (ap.frequencyPenalty != null) cleanedParams.frequencyPenalty = Math.min(2, Math.max(-2, ap.frequencyPenalty));
if (ap.presencePenalty != null) cleanedParams.presencePenalty = Math.min(2, Math.max(-2, ap.presencePenalty));
const trimmedName = form.name.trim();
const defaultName = PROVIDER_PRESETS[provider.providerId]?.name ?? "";
const rawContextWindow = form.contextWindow.trim();
const rawContextWindowNumber = Number(rawContextWindow);
if (rawContextWindow && (!Number.isInteger(rawContextWindowNumber) || rawContextWindowNumber <= 0)) {
setContextWindowError(t("ai.providers.contextWindow.error"));
return;
}
const manualContextWindow = rawContextWindow ? sanitizeContextWindow(rawContextWindow) : undefined;
if (rawContextWindow && manualContextWindow == null) {
setContextWindowError(t("ai.providers.contextWindow.error"));
return;
}
setContextWindowError(null);
const updates: Partial<ProviderConfig> = {
name: trimmedName || defaultName,
baseURL: provider.providerId === "ollama"
? resolvedBaseURL
: (form.baseURL || undefined),
defaultModel: form.defaultModel || undefined,
contextWindow: manualContextWindow,
modelContextWindows: Object.keys(form.modelContextWindows).length > 0 ? form.modelContextWindows : undefined,
skipTLSVerify: form.skipTLSVerify || undefined,
advancedParams: Object.keys(cleanedParams).length > 0 ? cleanedParams : undefined,
style: form.style || undefined,
openaiApi: resolvedStyle === "openai" && form.openaiApi === "responses" ? "responses" : undefined,
iconId: form.iconId || undefined,
iconDataUrl: form.iconDataUrl || undefined,
};
// Encrypt API key before saving
if (form.apiKey) {
updates.apiKey = await encryptField(form.apiKey);
} else {
updates.apiKey = undefined;
}
onSave(updates);
}, [form, onSave, provider.providerId, resolvedBaseURL, resolvedStyle, t]);
return (
<div className="mt-3 space-y-3 border-t border-border/40 pt-3">
{/* Display: icon + name */}
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">{t('ai.providers.name')}</label>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setShowIconPicker((v) => !v)}
className="group relative shrink-0 rounded-md transition-all hover:brightness-110 hover:ring-2 hover:ring-primary/45 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60"
aria-label={t('ai.providers.icon.change')}
title={t('ai.providers.icon.change')}
>
<ProviderIconBadge provider={previewProvider} />
<span
aria-hidden="true"
className="pointer-events-none absolute -bottom-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full border border-background bg-primary text-primary-foreground opacity-0 shadow-sm transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100"
>
<Pencil size={9} strokeWidth={2.5} />
</span>
</button>
<input
type="text"
value={form.name}
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
placeholder={t('ai.providers.name.placeholder')}
className="flex-1 h-8 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
</div>
{showIconPicker && (
<div className="rounded-md border border-border/50 bg-muted/20 p-2 space-y-2">
<div className="grid grid-cols-[repeat(auto-fill,minmax(120px,1fr))] gap-1.5">
{BUILTIN_PROVIDER_ICONS.map((icon) => {
const isSelected = form.iconId === icon.id && !form.iconDataUrl;
return (
<button
key={icon.id}
type="button"
onClick={() => (isSelected ? handleResetIcon() : handlePickBuiltin(icon))}
title={icon.label}
aria-label={icon.label}
aria-pressed={isSelected}
className={cn(
"flex items-center gap-2 px-2 py-1.5 rounded-md border text-left transition-colors min-w-0",
isSelected
? "border-primary/70 bg-primary/15"
: "border-transparent hover:border-border hover:bg-muted/40",
)}
>
<ProviderIconBadge
provider={{ providerId: provider.providerId, name: icon.label, iconId: icon.id }}
size="md"
/>
<span className="text-xs text-foreground/85 truncate">{icon.label}</span>
</button>
);
})}
</div>
<div className="flex items-center gap-2 pt-2 border-t border-border/40">
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => void handleIconFileSelect(e.target.files?.[0] ?? null)}
/>
<Button variant="ghost" size="sm" onClick={() => fileInputRef.current?.click()}>
<Upload size={12} className="mr-1.5" />
{t('ai.providers.icon.upload')}
</Button>
<Button variant="ghost" size="sm" onClick={handleResetIcon}>
<RotateCcw size={12} className="mr-1.5" />
{t('ai.providers.icon.reset')}
</Button>
{form.iconDataUrl && (
<span className="text-[10px] text-muted-foreground">{t('ai.providers.icon.uploadedNote')}</span>
)}
<div className="ml-auto" />
<Button
variant="ghost"
size="sm"
onClick={() => setShowIconPicker(false)}
aria-label={t('ai.providers.icon.close')}
title={t('ai.providers.icon.close')}
>
<X size={12} className="mr-1.5" />
{t('ai.providers.icon.close')}
</Button>
</div>
{iconError && <p className="text-[11px] text-destructive">{iconError}</p>}
</div>
)}
</div>
{/* Provider style */}
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">{t('ai.providers.style')}</label>
<div className="flex items-center gap-1.5">
{STYLE_OPTIONS.map((style) => {
const isSelected = resolvedStyle === style;
const isInherited = !form.style && isSelected;
return (
<button
key={style}
type="button"
onClick={() => setForm((prev) => ({ ...prev, style: prev.style === style ? "" : style }))}
className={cn(
"h-7 px-2.5 rounded-md text-xs border transition-colors",
isSelected
? "border-primary/70 bg-primary/15 text-foreground"
: "border-border/50 bg-background text-muted-foreground hover:text-foreground hover:bg-muted/40",
)}
aria-pressed={isSelected}
>
{t(`ai.providers.style.${style}`)}
{isInherited && (
<span className="ml-1 text-[9px] text-muted-foreground/70">({t('ai.providers.style.inherited')})</span>
)}
</button>
);
})}
</div>
<p className="text-[11px] text-muted-foreground/70">{t('ai.providers.style.help')}</p>
</div>
{resolvedStyle === "openai" && (
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">{t('ai.providers.openaiApi')}</label>
<div className="flex items-center gap-1.5">
{OPENAI_API_OPTIONS.map((format) => {
const isSelected = form.openaiApi === format;
return (
<button
key={format}
type="button"
onClick={() => setForm((prev) => ({ ...prev, openaiApi: format }))}
className={cn(
"h-7 px-2.5 rounded-md text-xs border transition-colors",
isSelected
? "border-primary/70 bg-primary/15 text-foreground"
: "border-border/50 bg-background text-muted-foreground hover:text-foreground hover:bg-muted/40",
)}
aria-pressed={isSelected}
>
{t(`ai.providers.openaiApi.${format}`)}
</button>
);
})}
</div>
<p className="text-[11px] text-muted-foreground/70">{t('ai.providers.openaiApi.help')}</p>
</div>
)}
{/* API Key */}
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">{t('ai.providers.apiKey')}</label>
<div className="flex items-center gap-2">
<div className="relative flex-1">
<input
type={showApiKey ? "text" : "password"}
value={isDecrypting ? "" : form.apiKey}
onChange={(e) => handleApiKeyChange(e.target.value)}
placeholder={isDecrypting ? t('ai.providers.apiKey.decrypting') : t('ai.providers.apiKey.placeholder')}
disabled={isDecrypting}
className="w-full h-8 rounded-md border border-input bg-background px-3 pr-9 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50"
/>
<button
type="button"
onClick={() => setShowApiKey(!showApiKey)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showApiKey ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
</div>
</div>
</div>
{/* Base URL */}
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">{t('ai.providers.baseUrl')}</label>
<input
type="text"
value={form.baseURL}
onChange={(e) => setForm((prev) => ({ ...prev, baseURL: e.target.value }))}
placeholder={preset?.defaultBaseURL || "https://"}
className="w-full h-8 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
{resolvedStyle === "anthropic" ? (
<p className="text-[11px] text-muted-foreground/70">{t('ai.providers.baseUrl.anthropicHelp')}</p>
) : null}
{provider.providerId === "ollama" ? (
<p className="text-[11px] text-muted-foreground/70">{t('ai.providers.baseUrl.ollamaHelp')}</p>
) : null}
</div>
{/* Default Model */}
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">{t('ai.providers.defaultModel')}</label>
<ModelSelector
value={form.defaultModel}
onChange={(val) => setForm((prev) => ({ ...prev, defaultModel: val }))}
onModelMetadata={(model) => {
setForm((prev) => ({
...prev,
modelContextWindows: mergeModelContextWindow(prev.modelContextWindows, model.id, model.contextWindow) ?? prev.modelContextWindows,
}));
}}
baseURL={resolvedBaseURL}
modelsEndpoint={preset?.modelsEndpoint}
presetModels={preset?.defaultModels}
apiKey={form.apiKey}
providerId={provider.providerId}
style={resolvedStyle}
skipTLSVerify={form.skipTLSVerify}
/>
</div>
{/* Context window */}
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">{t('ai.providers.contextWindow')}</label>
<input
type="number"
min={1}
step={1}
value={form.contextWindow}
onChange={(e) => {
setContextWindowError(null);
setForm((prev) => ({ ...prev, contextWindow: e.target.value }));
}}
placeholder={
form.defaultModel && form.modelContextWindows[form.defaultModel]
? String(form.modelContextWindows[form.defaultModel])
: t('ai.providers.contextWindow.placeholder')
}
className={cn(
"w-full h-8 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
contextWindowError && "border-destructive focus-visible:ring-destructive",
)}
/>
{contextWindowError && <p className="text-[11px] text-destructive">{contextWindowError}</p>}
<p className="text-[11px] text-muted-foreground/70">{t('ai.providers.contextWindow.help')}</p>
</div>
{/* Skip TLS Verification */}
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={form.skipTLSVerify}
onChange={(e) => setForm((prev) => ({ ...prev, skipTLSVerify: e.target.checked }))}
className="rounded border-input"
/>
<span className="text-xs text-muted-foreground">{t('ai.providers.skipTLSVerify')}</span>
</label>
{/* Advanced Parameters */}
<div className="space-y-2">
<button
type="button"
onClick={() => setShowAdvanced(!showAdvanced)}
className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors"
>
{showAdvanced ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
{t('ai.providers.advancedParams')}
</button>
{showAdvanced && (
<div className="space-y-2.5 pl-1 border-l-2 border-border/40 ml-1">
<p className="text-[11px] text-muted-foreground/70 pl-3">{t('ai.providers.advancedParams.hint')}</p>
{/* max_tokens */}
<div className="space-y-1 pl-3">
<label className="text-xs text-muted-foreground">max_tokens</label>
<input
type="number"
min={1}
step={1}
value={advancedParamRaw.maxTokens ?? (form.advancedParams.maxTokens != null ? String(form.advancedParams.maxTokens) : "")}
onChange={(e) => handleAdvancedParam("maxTokens", e.target.value)}
placeholder={t('ai.providers.advancedParams.maxTokens.placeholder')}
className="w-full h-8 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
</div>
{/* temperature */}
<div className="space-y-1 pl-3">
<label className="text-xs text-muted-foreground">temperature <span className="text-muted-foreground/50">(02)</span></label>
<input
type="number"
min={0}
max={2}
step={0.1}
value={advancedParamRaw.temperature ?? (form.advancedParams.temperature != null ? String(form.advancedParams.temperature) : "")}
onChange={(e) => handleAdvancedParam("temperature", e.target.value)}
placeholder={t('ai.providers.advancedParams.default')}
className="w-full h-8 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
</div>
{/* top_p */}
<div className="space-y-1 pl-3">
<label className="text-xs text-muted-foreground">top_p <span className="text-muted-foreground/50">(01)</span></label>
<input
type="number"
min={0}
max={1}
step={0.05}
value={advancedParamRaw.topP ?? (form.advancedParams.topP != null ? String(form.advancedParams.topP) : "")}
onChange={(e) => handleAdvancedParam("topP", e.target.value)}
placeholder={t('ai.providers.advancedParams.default')}
className="w-full h-8 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
</div>
{/* frequency_penalty */}
<div className="space-y-1 pl-3">
<label className="text-xs text-muted-foreground">frequency_penalty <span className="text-muted-foreground/50">(-22)</span></label>
<input
type="number"
min={-2}
max={2}
step={0.1}
value={advancedParamRaw.frequencyPenalty ?? (form.advancedParams.frequencyPenalty != null ? String(form.advancedParams.frequencyPenalty) : "")}
onChange={(e) => handleAdvancedParam("frequencyPenalty", e.target.value)}
placeholder={t('ai.providers.advancedParams.default')}
className="w-full h-8 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
</div>
{/* presence_penalty */}
<div className="space-y-1 pl-3">
<label className="text-xs text-muted-foreground">presence_penalty <span className="text-muted-foreground/50">(-22)</span></label>
<input
type="number"
min={-2}
max={2}
step={0.1}
value={advancedParamRaw.presencePenalty ?? (form.advancedParams.presencePenalty != null ? String(form.advancedParams.presencePenalty) : "")}
onChange={(e) => handleAdvancedParam("presencePenalty", e.target.value)}
placeholder={t('ai.providers.advancedParams.default')}
className="w-full h-8 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
</div>
</div>
)}
</div>
{/* Actions */}
<div className="flex flex-col gap-2 pt-1">
<div className="flex items-center gap-2">
<Button
variant="default"
size="sm"
className={cn(PROVIDER_ACTION_CLASS, "border border-transparent")}
onClick={() => void handleSave()}
>
<Check size={14} className="size-3.5 shrink-0" />
{t('common.save')}
</Button>
<Button
variant="outline"
size="sm"
className={PROVIDER_ACTION_CLASS}
onClick={() => void handleTestConnection()}
disabled={isTesting || isDecrypting}
>
<RefreshCw size={14} className={cn("size-3.5 shrink-0", isTesting && "animate-spin")} />
{isTesting ? t('ai.providers.test.testing') : t('ai.providers.test')}
</Button>
<Button variant="ghost" size="sm" className={PROVIDER_ACTION_CLASS} onClick={onCancel}>
{t('common.cancel')}
</Button>
</div>
{(isTesting || probeResult) && (
<p
className={cn(
"text-[11px]",
isTesting && "text-muted-foreground",
probeResult?.health === "ok" && "text-emerald-500",
probeResult?.health === "warn" && "text-amber-500",
probeResult?.health === "error" && "text-destructive",
)}
role="status"
aria-live="polite"
>
{isTesting ? t('ai.providers.test.testing') : probeResult?.message}
</p>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,117 @@
import React from "react";
import { cn } from "../../../../lib/utils";
import type { ProviderConfig } from "../../../../infrastructure/ai/types";
import type { SettingsIconId } from "./types";
import {
BUILTIN_PROVIDER_ICON_BY_ID,
SETTINGS_ICON_PATHS,
SETTINGS_ICON_COLORS,
} from "./types";
/**
* Optional ProviderConfig-like shape for per-provider customization. Only the
* fields used by the badge are listed so non-provider call sites (Claude/Copilot
* agent cards) can still pass a bare `providerId`.
*/
type ProviderLike = Pick<ProviderConfig, "providerId" | "name" | "iconId" | "iconDataUrl">;
interface BaseProps {
size?: "xs" | "sm" | "md";
}
type Props =
| (BaseProps & { providerId: SettingsIconId; provider?: undefined })
| (BaseProps & { provider: ProviderLike; providerId?: undefined });
const BADGE_DIMENSIONS = {
xs: "w-4 h-4",
sm: "w-5 h-5",
md: "w-8 h-8",
} as const;
const IMG_DIMENSIONS = {
xs: "w-2.5 h-2.5",
sm: "w-3 h-3",
md: "w-4 h-4",
} as const;
const UPLOAD_IMG_DIMENSIONS = {
xs: "w-4 h-4",
sm: "w-5 h-5",
md: "w-8 h-8",
} as const;
export const ProviderIconBadge: React.FC<Props> = (props) => {
const size = props.size ?? "md";
const dim = BADGE_DIMENSIONS[size];
// Branch 1: user-uploaded data URL — render verbatim, no filter, neutral bg.
if (props.provider?.iconDataUrl) {
return (
<div className={cn("rounded-md flex items-center justify-center shrink-0 overflow-hidden bg-zinc-900/40", dim)}>
<img
src={props.provider.iconDataUrl}
alt=""
aria-hidden="true"
draggable={false}
className={cn("object-contain", UPLOAD_IMG_DIMENSIONS[size])}
/>
</div>
);
}
// Branch 2: built-in iconId (lobe-icons subset).
const iconId = props.provider?.iconId;
if (iconId) {
const builtin = BUILTIN_PROVIDER_ICON_BY_ID[iconId];
if (builtin) {
return (
<div className={cn("rounded-md flex items-center justify-center shrink-0 overflow-hidden", dim, builtin.bgColor)}>
<img
src={builtin.path}
alt=""
aria-hidden="true"
draggable={false}
className={cn("object-contain brightness-0 invert", IMG_DIMENSIONS[size])}
/>
</div>
);
}
}
// Branch 3: providerId → existing built-in fallback table.
const fallbackId: SettingsIconId | undefined =
props.providerId ?? (props.provider ? (props.provider.providerId as SettingsIconId) : undefined);
if (fallbackId && fallbackId in SETTINGS_ICON_PATHS) {
return (
<div className={cn("rounded-md flex items-center justify-center shrink-0 overflow-hidden", dim, SETTINGS_ICON_COLORS[fallbackId])}>
<img
src={SETTINGS_ICON_PATHS[fallbackId]}
alt=""
aria-hidden="true"
draggable={false}
className={cn(
"object-contain",
fallbackId === "copilot" ? "brightness-0" : "brightness-0 invert",
IMG_DIMENSIONS[size],
)}
/>
</div>
);
}
// Branch 4: letter avatar from the provider name.
const letter = (props.provider?.name?.trim().charAt(0) ?? "?").toUpperCase();
return (
<div
className={cn(
"rounded-md flex items-center justify-center shrink-0 overflow-hidden bg-zinc-600 text-white font-medium",
dim,
size === "md" ? "text-sm" : size === "sm" ? "text-[10px]" : "text-[9px]",
)}
aria-hidden="true"
>
{letter}
</div>
);
};

View File

@@ -0,0 +1,305 @@
import { MessageSquare, Pencil, Plus, Trash2, X } from "lucide-react";
import React, { useCallback, useMemo, useState } from "react";
import type { AIQuickMessage } from "../../../../infrastructure/ai/quickMessages";
import {
createQuickMessageId,
isValidQuickMessageSlug,
normalizeQuickMessageSlug,
QUICK_MESSAGE_LIMITS,
slugFromQuickMessageName,
} from "../../../../infrastructure/ai/quickMessages";
import { useI18n } from "../../../../application/i18n/I18nProvider";
import { Button } from "../../../ui/button";
import { SettingCard, SettingsSection } from "../../settings-ui";
interface QuickMessagesSettingsProps {
quickMessages: AIQuickMessage[];
setQuickMessages: (value: AIQuickMessage[] | ((prev: AIQuickMessage[]) => AIQuickMessage[])) => void;
reservedUserSkillSlugs?: string[];
}
type DraftQuickMessage = {
name: string;
slug: string;
content: string;
description: string;
};
const emptyDraft = (): DraftQuickMessage => ({
name: "",
slug: "",
content: "",
description: "",
});
export const QuickMessagesSettings: React.FC<QuickMessagesSettingsProps> = ({
quickMessages,
setQuickMessages,
reservedUserSkillSlugs = [],
}) => {
const { t } = useI18n();
const [editingId, setEditingId] = useState<string | null>(null);
const [isCreating, setIsCreating] = useState(false);
const [draft, setDraft] = useState<DraftQuickMessage>(emptyDraft);
const [slugTouched, setSlugTouched] = useState(false);
const [error, setError] = useState<string | null>(null);
const sortedMessages = useMemo(
() => [...quickMessages].sort((a, b) => a.name.localeCompare(b.name)),
[quickMessages],
);
const resetEditor = useCallback(() => {
setEditingId(null);
setIsCreating(false);
setDraft(emptyDraft());
setSlugTouched(false);
setError(null);
}, []);
const beginCreate = useCallback(() => {
setEditingId(null);
setIsCreating(true);
setDraft(emptyDraft());
setSlugTouched(false);
setError(null);
}, []);
const beginEdit = useCallback((message: AIQuickMessage) => {
setIsCreating(false);
setEditingId(message.id);
setDraft({
name: message.name,
slug: message.slug,
content: message.content,
description: message.description ?? "",
});
setSlugTouched(true);
setError(null);
}, []);
const handleNameChange = useCallback((name: string) => {
setDraft((prev) => ({
...prev,
name,
slug: slugTouched ? prev.slug : slugFromQuickMessageName(name),
}));
}, [slugTouched]);
const handleSlugChange = useCallback((slug: string) => {
setSlugTouched(true);
setDraft((prev) => ({ ...prev, slug: normalizeQuickMessageSlug(slug) }));
}, []);
const validateDraft = useCallback((nextDraft: DraftQuickMessage, excludeId?: string | null): string | null => {
const name = nextDraft.name.trim();
const slug = normalizeQuickMessageSlug(nextDraft.slug);
const content = nextDraft.content.trim();
if (!name) return t("ai.quickMessages.error.nameRequired");
if (!isValidQuickMessageSlug(slug)) return t("ai.quickMessages.error.invalidSlug");
if (!content) return t("ai.quickMessages.error.contentRequired");
if (!excludeId && quickMessages.length >= QUICK_MESSAGE_LIMITS.maxItems) {
return t("ai.quickMessages.error.maxItems", { max: String(QUICK_MESSAGE_LIMITS.maxItems) });
}
const slugTaken = quickMessages.some(
(message) => message.slug === slug && message.id !== excludeId,
);
if (slugTaken) return t("ai.quickMessages.error.slugTaken");
const skillConflict = reservedUserSkillSlugs.some((skillSlug) => skillSlug === slug);
if (skillConflict) {
return t("ai.quickMessages.error.slugConflictsWithSkill", { slug });
}
return null;
}, [quickMessages, reservedUserSkillSlugs, t]);
const handleSave = useCallback(() => {
const validationError = validateDraft(draft, editingId);
if (validationError) {
setError(validationError);
return;
}
const payload: AIQuickMessage = {
id: editingId ?? createQuickMessageId(),
name: draft.name.trim(),
slug: normalizeQuickMessageSlug(draft.slug),
content: draft.content.trim(),
description: draft.description.trim() || undefined,
};
if (editingId) {
setQuickMessages((prev) => prev.map((message) => (
message.id === editingId ? payload : message
)));
} else {
setQuickMessages((prev) => [...prev, payload]);
}
resetEditor();
}, [draft, editingId, resetEditor, setQuickMessages, validateDraft]);
const handleDelete = useCallback((message: AIQuickMessage) => {
const ok = window.confirm(t("ai.quickMessages.confirmDelete", { name: message.name }));
if (!ok) return;
setQuickMessages((prev) => prev.filter((item) => item.id !== message.id));
if (editingId === message.id) {
resetEditor();
}
}, [editingId, resetEditor, setQuickMessages, t]);
const showEditor = isCreating || editingId != null;
return (
<SettingsSection
anchorId="ai-quick-messages"
title={t("ai.quickMessages.title")}
actions={(
<Button variant="outline" size="sm" onClick={beginCreate} disabled={showEditor}>
<Plus size={14} className="mr-2" />
{t("ai.quickMessages.add")}
</Button>
)}
>
<SettingCard padded className="space-y-3">
<p className="text-xs text-muted-foreground/80 leading-5">
{t("ai.quickMessages.description")}
</p>
{showEditor ? (
<div className="rounded-md border border-border/60 bg-background/40 p-4 space-y-3">
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-medium">
{isCreating ? t("ai.quickMessages.createTitle") : t("ai.quickMessages.editTitle")}
</div>
<button
type="button"
onClick={resetEditor}
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground hover:bg-muted/30 hover:text-foreground transition-colors"
aria-label={t("common.cancel")}
>
<X size={14} />
</button>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<label className="space-y-1.5 text-sm">
<span className="text-muted-foreground">{t("ai.quickMessages.name")}</span>
<input
value={draft.name}
onChange={(e) => handleNameChange(e.target.value)}
placeholder={t("ai.quickMessages.name.placeholder")}
maxLength={QUICK_MESSAGE_LIMITS.name}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
/>
</label>
<label className="space-y-1.5 text-sm">
<span className="text-muted-foreground">{t("ai.quickMessages.slug")}</span>
<div className="flex items-center gap-2">
<span className="text-muted-foreground/70">/</span>
<input
value={draft.slug}
onChange={(e) => handleSlugChange(e.target.value)}
placeholder={t("ai.quickMessages.slug.placeholder")}
maxLength={QUICK_MESSAGE_LIMITS.slug}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono"
/>
</div>
</label>
</div>
<label className="block space-y-1.5 text-sm">
<span className="text-muted-foreground">{t("ai.quickMessages.descriptionField")}</span>
<input
value={draft.description}
onChange={(e) => setDraft((prev) => ({ ...prev, description: e.target.value }))}
placeholder={t("ai.quickMessages.descriptionField.placeholder")}
maxLength={QUICK_MESSAGE_LIMITS.description}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
/>
</label>
<label className="block space-y-1.5 text-sm">
<span className="text-muted-foreground">{t("ai.quickMessages.content")}</span>
<textarea
value={draft.content}
onChange={(e) => setDraft((prev) => ({ ...prev, content: e.target.value }))}
placeholder={t("ai.quickMessages.content.placeholder")}
rows={5}
maxLength={QUICK_MESSAGE_LIMITS.content}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono resize-y min-h-[120px]"
/>
</label>
{error ? (
<p className="text-sm text-destructive">{error}</p>
) : null}
<div className="flex justify-end gap-2">
<Button variant="outline" size="sm" onClick={resetEditor}>
{t("common.cancel")}
</Button>
<Button size="sm" onClick={handleSave}>
{t("common.save")}
</Button>
</div>
</div>
) : null}
{sortedMessages.length > 0 ? (
<div className="border-t border-border/60 divide-y divide-border/60">
{sortedMessages.map((message) => (
<div
key={message.id}
className="py-3"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
<div className="flex items-center gap-2">
<MessageSquare size={14} className="text-muted-foreground shrink-0" />
<span className="text-sm font-medium">{message.name}</span>
<span className="text-xs font-mono text-muted-foreground/80">/{message.slug}</span>
</div>
{message.description ? (
<p className="text-xs text-muted-foreground leading-5">{message.description}</p>
) : null}
<p className="text-xs text-muted-foreground/70 line-clamp-2 whitespace-pre-wrap">
{message.content}
</p>
</div>
<div className="flex items-center gap-1 shrink-0">
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-foreground"
onClick={() => beginEdit(message)}
aria-label={t("ai.quickMessages.editTitle")}
>
<Pencil size={14} />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-destructive"
onClick={() => handleDelete(message)}
aria-label={t("ai.quickMessages.confirmDelete", { name: message.name })}
>
<Trash2 size={14} />
</Button>
</div>
</div>
</div>
))}
</div>
) : !showEditor ? (
<div className="border-t border-border/60 pt-3 text-sm text-muted-foreground">
<p className="text-sm text-muted-foreground">{t("ai.quickMessages.empty")}</p>
</div>
) : null}
</SettingCard>
</SettingsSection>
);
};

View File

@@ -0,0 +1,235 @@
import React, { useCallback, useState } from "react";
import { Plus, X } from "lucide-react";
import type { AIPermissionMode } from "../../../../infrastructure/ai/types";
import {
DEFAULT_COMMAND_BLOCKLIST,
MAX_COMMAND_TIMEOUT_SECONDS,
MAX_RESPONSE_IDLE_TIMEOUT_SECONDS,
} from "../../../../infrastructure/ai/types";
import { useI18n } from "../../../../application/i18n/I18nProvider";
import { Button } from "../../../ui/button";
import { Select, SettingCard, SettingRow, SettingsAnchor, SettingsSection } from "../../settings-ui";
export const SafetySettings: React.FC<{
globalPermissionMode: AIPermissionMode;
setGlobalPermissionMode: (mode: AIPermissionMode) => 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;
}> = ({
globalPermissionMode,
setGlobalPermissionMode,
commandBlocklist,
setCommandBlocklist,
commandTimeout,
setCommandTimeout,
responseIdleTimeout,
setResponseIdleTimeout,
maxIterations,
setMaxIterations,
}) => {
const { t } = useI18n();
const [regexErrors, setRegexErrors] = useState<Record<number, string>>({});
const validatePattern = useCallback((pattern: string, idx: number): boolean => {
if (!pattern) {
setRegexErrors((prev) => {
const next = { ...prev };
delete next[idx];
return next;
});
return true;
}
try {
new RegExp(pattern);
setRegexErrors((prev) => {
const next = { ...prev };
delete next[idx];
return next;
});
return true;
} catch (e) {
setRegexErrors((prev) => ({
...prev,
[idx]: e instanceof Error ? e.message : String(e),
}));
return false;
}
}, []);
const handlePatternChange = useCallback((value: string, idx: number) => {
const next = [...commandBlocklist];
next[idx] = value;
validatePattern(value, idx);
setCommandBlocklist(next);
}, [commandBlocklist, setCommandBlocklist, validatePattern]);
const permissionModeOptions = [
{ value: "observer", label: t('ai.safety.permissionMode.observer') },
{ value: "confirm", label: t('ai.safety.permissionMode.confirm') },
{ value: "auto", label: t('ai.safety.permissionMode.auto') },
];
return (
<SettingsSection title={t('ai.safety.title')}>
<div className="flex flex-col gap-4">
<SettingCard divided>
<SettingRow
anchorId="ai-safety-permission-mode"
label={t('ai.safety.permissionMode')}
description={t('ai.safety.permissionMode.description')}
>
<Select
value={globalPermissionMode}
options={permissionModeOptions}
onChange={(val) => setGlobalPermissionMode(val as AIPermissionMode)}
className="w-64"
/>
</SettingRow>
<SettingRow
anchorId="ai-safety-response-idle-timeout"
label={t('ai.safety.responseIdleTimeout')}
description={t('ai.safety.responseIdleTimeout.description')}
>
<div className="flex items-center gap-2">
<input
type="number"
aria-label={t('ai.safety.responseIdleTimeout')}
value={responseIdleTimeout}
onChange={(e) => {
const val = parseInt(e.target.value, 10);
if (!isNaN(val)) setResponseIdleTimeout(val);
}}
min={1}
max={MAX_RESPONSE_IDLE_TIMEOUT_SECONDS}
className="w-20 h-9 rounded-md border border-input bg-background px-3 text-sm text-right focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
<span className="text-xs text-muted-foreground">{t('ai.safety.responseIdleTimeout.unit')}</span>
</div>
</SettingRow>
<SettingRow
anchorId="ai-safety-command-timeout"
label={t('ai.safety.commandTimeout')}
description={t('ai.safety.commandTimeout.description')}
>
<div className="flex items-center gap-2">
<input
type="number"
value={commandTimeout}
onChange={(e) => {
const val = parseInt(e.target.value, 10);
if (!isNaN(val)) setCommandTimeout(val);
}}
min={1}
max={MAX_COMMAND_TIMEOUT_SECONDS}
className="w-20 h-9 rounded-md border border-input bg-background px-3 text-sm text-right focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
<span className="text-xs text-muted-foreground">{t('ai.safety.commandTimeout.unit')}</span>
</div>
</SettingRow>
<SettingRow
label={t('ai.safety.maxIterations')}
description={t('ai.safety.maxIterations.description')}
>
<input
type="number"
value={maxIterations}
onChange={(e) => {
const val = parseInt(e.target.value, 10);
if (!isNaN(val) && val > 0) setMaxIterations(val);
}}
min={1}
max={100}
className="w-20 h-9 rounded-md border border-input bg-background px-3 text-sm text-right focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
</SettingRow>
</SettingCard>
{/* Command Blocklist */}
<SettingsAnchor anchorId="ai-safety-blocklist">
<SettingCard padded className="space-y-3">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">{t('ai.safety.blocklist')}</p>
<p className="text-xs text-muted-foreground">
{t('ai.safety.blocklist.description')}
</p>
</div>
<Button
variant="ghost"
size="sm"
className="text-xs"
onClick={() => { setCommandBlocklist([...DEFAULT_COMMAND_BLOCKLIST]); setRegexErrors({}); }}
>
{t('ai.safety.blocklist.reset')}
</Button>
</div>
<div className="space-y-1.5">
{commandBlocklist.map((pattern, idx) => (
<div key={idx} className="space-y-0.5">
<div className="flex items-center gap-2">
<input
type="text"
value={pattern}
onChange={(e) => handlePatternChange(e.target.value, idx)}
className={`flex-1 h-8 rounded-md border bg-background px-3 text-xs font-mono focus-visible:outline-none focus-visible:ring-1 ${
regexErrors[idx]
? 'border-destructive focus-visible:ring-destructive'
: 'border-input focus-visible:ring-ring'
}`}
placeholder={t('ai.safety.blocklist.placeholder')}
/>
<button
onClick={() => {
const next = commandBlocklist.filter((_, i) => i !== idx);
setCommandBlocklist(next);
setRegexErrors((prev) => {
const updated: Record<number, string> = {};
for (const [k, v] of Object.entries(prev)) {
const ki = Number(k);
if (ki < idx) updated[ki] = v as string;
else if (ki > idx) updated[ki - 1] = v as string;
}
return updated;
});
}}
className="p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
>
<X size={14} />
</button>
</div>
{regexErrors[idx] && (
<p className="text-[11px] text-destructive pl-1">{regexErrors[idx]}</p>
)}
</div>
))}
</div>
<Button
variant="outline"
size="sm"
className="text-xs"
onClick={() => setCommandBlocklist([...commandBlocklist, ''])}
>
<Plus size={14} className="mr-1" />
{t('ai.safety.blocklist.add')}
</Button>
</SettingCard>
</SettingsAnchor>
<p className="text-xs text-muted-foreground">
{t('ai.safety.note')}
</p>
</div>
</SettingsSection>
);
};

View File

@@ -0,0 +1,24 @@
import assert from "node:assert/strict";
import test from "node:test";
import { buildMcpOnboardingPrompt } from "./ToolAccessGuidance";
test("buildMcpOnboardingPrompt includes launcher and discovery env", () => {
const prompt = buildMcpOnboardingPrompt("/opt/netcatty/launcher", "/tmp/discovery.json");
assert.match(prompt, /netcatty-external/);
assert.match(prompt, /\/opt\/netcatty\/launcher/);
assert.match(prompt, /NETCATTY_EXTERNAL_MCP_DISCOVERY_FILE=\/tmp\/discovery\.json/);
assert.match(prompt, /get_environment/);
});
test("buildMcpOnboardingPrompt omits env line without discovery path", () => {
const prompt = buildMcpOnboardingPrompt("/opt/netcatty/launcher", null);
assert.match(prompt, /\/opt\/netcatty\/launcher/);
assert.doesNotMatch(prompt, /NETCATTY_EXTERNAL_MCP_DISCOVERY_FILE=/);
});
test("buildMcpOnboardingPrompt falls back to enable-External-MCP guidance", () => {
const prompt = buildMcpOnboardingPrompt(null, null);
assert.match(prompt, /External MCP/);
assert.doesNotMatch(prompt, /Command: /);
});

View File

@@ -0,0 +1,134 @@
import React, { useState } from "react";
import { Check, Copy } from "lucide-react";
import { useI18n } from "../../../../application/i18n/I18nProvider";
import type { AIToolIntegrationMode } from "../../../../infrastructure/ai/types";
import { cn } from "../../../../lib/utils";
import { useToolAccessGuidanceState } from "../../../../application/state/useToolAccessGuidanceState";
import { EXTERNAL_MCP_DISCOVERY_ENV_VAR } from "./ExternalMcpCard";
/** Build a ready-to-paste prompt so an external AI client can register Netcatty MCP itself. */
export function buildMcpOnboardingPrompt(
launcherPath: string | null | undefined,
discoveryPath: string | null | undefined,
): string {
if (!launcherPath) {
return [
"Please connect Netcatty to this session via MCP.",
"In the Netcatty desktop app, open Settings → AI → Tool Access, turn on External MCP,",
"then copy the generated prompt from the Tool Access section and run it here.",
"After that, list the netcatty-external MCP tools and call get_environment to verify the connection.",
].join(" ");
}
const lines = [
"Please register Netcatty's MCP server in your MCP client configuration:",
`- Server name: netcatty-external`,
`- Transport: local stdio`,
`- Command: ${launcherPath}`,
];
if (discoveryPath) {
lines.push(`- Environment: ${EXTERNAL_MCP_DISCOVERY_ENV_VAR}=${discoveryPath}`);
}
lines.push(
"After registering, list the server's tools and call get_environment to verify the connection.",
"Keep the Netcatty desktop app running while you use these tools.",
);
return lines.join("\n");
}
type CopyRowProps = {
value: string;
label: string;
copyLabel: string;
copiedLabel: string;
testId?: string;
};
const CopyRow: React.FC<CopyRowProps> = ({ value, label, copyLabel, copiedLabel, testId }) => {
const [copied, setCopied] = useState(false);
const canCopy = Boolean(value);
const handleCopy = async () => {
if (!value) return;
try {
await navigator.clipboard.writeText(value);
setCopied(true);
window.setTimeout(() => setCopied(false), 1200);
} catch {
// Clipboard may be unavailable; the text stays selectable in the block.
}
};
return (
<div className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground">{label}</div>
<div className="group relative rounded-md border border-border/60 bg-muted/20">
<pre
data-testid={testId}
className={cn(
"max-h-40 overflow-auto whitespace-pre-wrap break-all px-3 py-2.5 pr-11 font-mono text-xs leading-5",
!value && "text-muted-foreground",
)}
>
{value}
</pre>
<button
type="button"
disabled={!canCopy}
className="absolute right-1.5 top-1.5 flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground disabled:opacity-40"
onClick={() => void handleCopy()}
aria-label={copied ? copiedLabel : copyLabel}
title={copied ? copiedLabel : copyLabel}
>
{copied ? <Check size={14} className="text-emerald-500" /> : <Copy size={14} />}
</button>
</div>
</div>
);
};
export const ToolAccessGuidance: React.FC<{ mode: AIToolIntegrationMode }> = ({ mode }) => {
const { t } = useI18n();
const { skillPath, commandPrefix, mcpLauncherPath, mcpDiscoveryPath } =
useToolAccessGuidanceState(mode);
if (mode === "skills") {
return (
<div className="rounded-md border border-border/60 bg-background/50 p-3 space-y-2">
<p className="text-xs text-muted-foreground leading-5">
{t("ai.toolAccess.skills.description")}
</p>
<CopyRow
value={skillPath || ""}
label={t("ai.toolAccess.skills.file")}
copyLabel={t("ai.externalMcp.copy")}
copiedLabel={t("ai.externalMcp.copied")}
testId="tool-access-skill-path"
/>
{!skillPath ? (
<p className="text-xs text-amber-500">{t("ai.toolAccess.skills.unavailable")}</p>
) : null}
{commandPrefix ? (
<p className="text-xs text-muted-foreground/80 font-mono break-all">{commandPrefix}</p>
) : null}
</div>
);
}
return (
<div className="rounded-md border border-border/60 bg-background/50 p-3 space-y-2">
<p className="text-xs text-muted-foreground leading-5">
{t("ai.toolAccess.mcpPrompt.description")}
</p>
<CopyRow
value={buildMcpOnboardingPrompt(mcpLauncherPath, mcpDiscoveryPath)}
label={t("ai.toolAccess.mcpPrompt.title")}
copyLabel={t("ai.externalMcp.copy")}
copiedLabel={t("ai.externalMcp.copied")}
testId="tool-access-mcp-prompt"
/>
{!mcpLauncherPath ? (
<p className="text-xs text-amber-500">{t("ai.toolAccess.mcpPrompt.enableHint")}</p>
) : null}
</div>
);
};

View File

@@ -0,0 +1,211 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Eye, EyeOff } from "lucide-react";
import type { WebSearchConfig, WebSearchProviderId } from "../../../../infrastructure/ai/types";
import { WEB_SEARCH_PROVIDER_PRESETS } from "../../../../infrastructure/ai/types";
import { encryptField, decryptField } from "../../../../infrastructure/persistence/secureFieldAdapter";
import { useI18n } from "../../../../application/i18n/I18nProvider";
import { Select, SettingCard, SettingRow, SettingsSection, Toggle } from "../../settings-ui";
const SEARCH_ICON_PATHS: Record<WebSearchProviderId, string> = {
tavily: "/ai/search/tavily.svg",
exa: "/ai/search/exa.png",
bocha: "/ai/search/bocha.webp",
zhipu: "/ai/search/zhipu.png",
searxng: "/ai/search/searxng.svg",
};
const SearchProviderIcon: React.FC<{ providerId: WebSearchProviderId }> = ({ providerId }) => (
<img
src={SEARCH_ICON_PATHS[providerId]}
alt=""
className="w-4 h-4 shrink-0"
/>
);
const PROVIDER_OPTIONS: Array<{ value: WebSearchProviderId; label: string; icon: React.ReactNode }> = Object.entries(
WEB_SEARCH_PROVIDER_PRESETS,
).map(([id, preset]) => ({
value: id as WebSearchProviderId,
label: preset.name,
icon: <SearchProviderIcon providerId={id as WebSearchProviderId} />,
}));
export const WebSearchSettings: React.FC<{
webSearchConfig: WebSearchConfig | null;
setWebSearchConfig: (config: WebSearchConfig | null) => void;
}> = ({ webSearchConfig, setWebSearchConfig }) => {
const { t } = useI18n();
const [apiKeyInput, setApiKeyInput] = useState("");
const [showApiKey, setShowApiKey] = useState(false);
const [isDecrypting, setIsDecrypting] = useState(false);
const config = useMemo(() => webSearchConfig ?? {
providerId: "tavily" as WebSearchProviderId,
enabled: false,
maxResults: 5,
}, [webSearchConfig]);
// Ref to always read the latest config in async callbacks (avoids stale closure)
const configRef = useRef(config);
configRef.current = config;
const preset = WEB_SEARCH_PROVIDER_PRESETS[config.providerId];
// Decrypt API key on mount or when provider changes (with cancellation guard)
const decryptSeqRef = useRef(0);
useEffect(() => {
if (config.apiKey) {
const seq = ++decryptSeqRef.current;
setIsDecrypting(true);
decryptField(config.apiKey)
.then((decrypted) => {
if (decryptSeqRef.current === seq) setApiKeyInput(decrypted ?? "");
})
.catch(() => {
if (decryptSeqRef.current === seq) setApiKeyInput(config.apiKey ?? "");
})
.finally(() => {
if (decryptSeqRef.current === seq) setIsDecrypting(false);
});
} else {
decryptSeqRef.current++;
setApiKeyInput("");
setIsDecrypting(false);
}
}, [config.apiKey, config.providerId]);
const updateConfig = useCallback(
(updates: Partial<WebSearchConfig>) => {
setWebSearchConfig({ ...configRef.current, ...updates });
},
[setWebSearchConfig],
);
const handleProviderChange = useCallback(
(val: string) => {
const providerId = val as WebSearchProviderId;
const newPreset = WEB_SEARCH_PROVIDER_PRESETS[providerId];
setWebSearchConfig({
...configRef.current,
providerId,
apiKey: undefined,
apiHost: newPreset.defaultApiHost || undefined,
});
setApiKeyInput("");
},
[setWebSearchConfig],
);
// Sequence counter for blur saves — prevents out-of-order encryption results
const blurSeqRef = useRef(0);
const handleApiKeyBlur = useCallback(async () => {
if (!apiKeyInput.trim()) {
blurSeqRef.current++;
updateConfig({ apiKey: undefined });
return;
}
const seq = ++blurSeqRef.current;
const providerAtBlur = configRef.current.providerId;
const encrypted = await encryptField(apiKeyInput.trim());
// Only apply if this is still the latest blur and provider hasn't changed
if (blurSeqRef.current === seq && configRef.current.providerId === providerAtBlur) {
updateConfig({ apiKey: encrypted });
}
}, [apiKeyInput, updateConfig]);
return (
<SettingsSection title={t("ai.webSearch.title")}>
<SettingCard divided>
<SettingRow
anchorId="ai-web-search-enable"
label={t("ai.webSearch.enable")}
description={t("ai.webSearch.enable.description")}
>
<Toggle
checked={config.enabled}
onChange={(enabled) => updateConfig({ enabled })}
/>
</SettingRow>
{/* Provider */}
<SettingRow
anchorId="ai-web-search-provider"
label={t("ai.webSearch.provider")}
description={t("ai.webSearch.provider.description")}
>
<Select
value={config.providerId}
options={PROVIDER_OPTIONS}
onChange={handleProviderChange}
className="w-48"
/>
</SettingRow>
{/* API Key (hidden for SearXNG) */}
{preset.requiresApiKey && (
<SettingRow
label={t("ai.webSearch.apiKey")}
description={t("ai.webSearch.apiKey.description")}
>
<div className="flex items-center gap-1.5">
<input
type={showApiKey ? "text" : "password"}
value={isDecrypting ? "" : apiKeyInput}
placeholder={isDecrypting ? t("ai.providers.apiKey.decrypting") : t("ai.webSearch.apiKey.placeholder")}
onChange={(e) => setApiKeyInput(e.target.value)}
onBlur={() => void handleApiKeyBlur()}
className="w-64 h-9 rounded-md border border-input bg-background px-3 text-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
disabled={isDecrypting}
/>
<button
type="button"
onClick={() => setShowApiKey(!showApiKey)}
className="p-1.5 rounded hover:bg-muted text-muted-foreground"
>
{showApiKey ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
</div>
</SettingRow>
)}
{/* API Host */}
<SettingRow
label={t("ai.webSearch.apiHost")}
description={
config.providerId === "searxng"
? t("ai.webSearch.apiHost.searxngDescription")
: t("ai.webSearch.apiHost.description")
}
>
<input
type="text"
value={config.apiHost ?? preset.defaultApiHost}
onChange={(e) => updateConfig({ apiHost: e.target.value || undefined })}
placeholder={preset.defaultApiHost || "https://..."}
className="w-64 h-9 rounded-md border border-input bg-background px-3 text-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
</SettingRow>
{/* Max Results */}
<SettingRow
label={t("ai.webSearch.maxResults")}
description={t("ai.webSearch.maxResults.description")}
>
<input
type="number"
value={config.maxResults ?? 5}
onChange={(e) => {
const val = parseInt(e.target.value, 10);
if (!isNaN(val) && val >= 1 && val <= 20) {
updateConfig({ maxResults: val });
}
}}
min={1}
max={20}
className="w-20 h-9 rounded-md border border-input bg-background px-3 text-sm text-right focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
</SettingRow>
</SettingCard>
</SettingsSection>
);
};

View File

@@ -0,0 +1,75 @@
/**
* Pure helpers for the Claude Code card's "config directory + environment
* variables" editor. The managed Claude agent stores everything in its
* ExternalAgentConfig.env; this splits that into the editable pieces and
* recombines them. CLAUDE_CODE_EXECUTABLE is owned by path discovery, so it
* is preserved across edits but never shown in the env editor.
*/
const CONFIG_DIR_KEY = "CLAUDE_CONFIG_DIR";
// netcatty marker carrying the claude SDK `settings` option (a settings.json
// path or inline JSON). Extracted in the main process and passed to the SDK as
// `options.settings`; never sent to the agent as a real env var. Additive to —
// and independent of — CLAUDE_CONFIG_DIR.
const SETTINGS_KEY = "NETCATTY_CLAUDE_SETTINGS";
const MANAGED_KEYS = new Set(["CLAUDE_CODE_EXECUTABLE", CONFIG_DIR_KEY, SETTINGS_KEY]);
export function parseEnvLines(text: string): Record<string, string> {
const out: Record<string, string> = {};
for (const rawLine of String(text || "").split("\n")) {
const line = rawLine.trim();
if (!line || line.startsWith("#")) continue;
const eq = line.indexOf("=");
if (eq <= 0) continue;
const key = line.slice(0, eq).trim();
const value = line.slice(eq + 1).trim();
if (key) out[key] = value;
}
return out;
}
export function serializeEnvLines(env: Record<string, string>): string {
return Object.entries(env)
.map(([k, v]) => `${k}=${v}`)
.join("\n");
}
export function splitClaudeEnv(
env: Record<string, string> | undefined,
): { configDir: string; settingsPath: string; envText: string } {
if (!env) return { configDir: "", settingsPath: "", envText: "" };
const configDir = env[CONFIG_DIR_KEY] ?? "";
const settingsPath = env[SETTINGS_KEY] ?? "";
const rest: Record<string, string> = {};
for (const [k, v] of Object.entries(env)) {
if (MANAGED_KEYS.has(k)) continue;
rest[k] = v;
}
return { configDir, settingsPath, envText: serializeEnvLines(rest) };
}
export function buildClaudeEnv(
prevEnv: Record<string, string> | undefined,
configDir: string,
settingsPath: string,
envText: string,
): Record<string, string> | undefined {
const next: Record<string, string> = {};
// Preserve discovery-owned key if present.
const exe = prevEnv?.CLAUDE_CODE_EXECUTABLE;
if (exe) next.CLAUDE_CODE_EXECUTABLE = exe;
const trimmedDir = String(configDir || "").trim();
if (trimmedDir) next[CONFIG_DIR_KEY] = trimmedDir;
const trimmedSettings = String(settingsPath || "").trim();
if (trimmedSettings) next[SETTINGS_KEY] = trimmedSettings;
// Drop managed keys if a user typed them into the free-text editor — the
// dedicated fields and path discovery own these keys.
const parsed = parseEnvLines(envText);
for (const key of MANAGED_KEYS) delete parsed[key];
Object.assign(next, parsed);
return Object.keys(next).length > 0 ? next : undefined;
}

View File

@@ -0,0 +1,73 @@
/**
* Pure helpers for the CodeBuddy card's environment variables editor.
* The managed CodeBuddy agent stores everything in its
* ExternalAgentConfig.env; this splits that into the editable pieces and
* recombines them.
*
* CODEBUDDY_CODE_PATH is owned by path discovery, so it is preserved across
* edits but never shown in the env editor.
*
* The SDK supports CODEBUDDY_API_KEY (via options.env), but the CLI itself
* does not. CODEBUDDY_INTERNET_ENVIRONMENT is managed as a first-class field.
* Users who need CODEBUDDY_API_KEY or CODEBUDDY_AUTH_TOKEN should set them
* in the free-text environment editor or in their shell profile.
*/
const INTERNET_ENV_VAR = "CODEBUDDY_INTERNET_ENVIRONMENT";
const CODE_PATH_KEY = "CODEBUDDY_CODE_PATH";
const MANAGED_KEYS = new Set([INTERNET_ENV_VAR, CODE_PATH_KEY]);
export function parseEnvLines(text: string): Record<string, string> {
const out: Record<string, string> = {};
for (const rawLine of String(text || "").split("\n")) {
const line = rawLine.trim();
if (!line || line.startsWith("#")) continue;
const eq = line.indexOf("=");
if (eq <= 0) continue;
const key = line.slice(0, eq).trim();
const value = line.slice(eq + 1).trim();
if (key) out[key] = value;
}
return out;
}
export function serializeEnvLines(env: Record<string, string>): string {
return Object.entries(env)
.map(([k, v]) => `${k}=${v}`)
.join("\n");
}
export function splitCodebuddyEnv(
env: Record<string, string> | undefined,
): { internetEnv: string; envText: string } {
if (!env) return { internetEnv: "", envText: "" };
const internetEnv = env[INTERNET_ENV_VAR] ?? "";
const rest: Record<string, string> = {};
for (const [k, v] of Object.entries(env)) {
if (MANAGED_KEYS.has(k)) continue;
rest[k] = v;
}
return { internetEnv, envText: serializeEnvLines(rest) };
}
export function buildCodebuddyEnv(
prevEnv: Record<string, string> | undefined,
internetEnv: string,
envText: string,
): Record<string, string> | undefined {
const next: Record<string, string> = {};
const trimmedInternetEnv = String(internetEnv || "").trim();
if (trimmedInternetEnv) next[INTERNET_ENV_VAR] = trimmedInternetEnv;
// Preserve auto-injected CODEBUDDY_CODE_PATH across edits.
const codePath = prevEnv?.[CODE_PATH_KEY];
if (codePath) next[CODE_PATH_KEY] = codePath;
// Drop managed keys if a user typed them into the free-text editor
const parsed = parseEnvLines(envText);
for (const key of MANAGED_KEYS) delete parsed[key];
Object.assign(next, parsed);
return Object.keys(next).length > 0 ? next : undefined;
}

View File

@@ -0,0 +1,47 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { ADD_PROVIDER_MENU_CLASS } from "./AddProviderDropdown.tsx";
import { getModelSuggestionClassName, getModelSuggestionsPresentation } from "./ModelSelector.tsx";
const modelSelectorSource = readFileSync(new URL("./ModelSelector.tsx", import.meta.url), "utf8");
test("add provider menu opens toward the left edge of the button and stays width-bounded", () => {
assert.match(ADD_PROVIDER_MENU_CLASS, /right-0/);
assert.doesNotMatch(ADD_PROVIDER_MENU_CLASS, /left-0/);
assert.match(ADD_PROVIDER_MENU_CLASS, /max-w-\[calc\(100vw-2rem\)\]/);
});
test("preset model suggestions stay visible while remote models are loading", () => {
assert.deepEqual(
getModelSuggestionsPresentation({
suggestionsLength: 2,
isLoading: true,
error: null,
hasFetched: false,
hasPresetModels: true,
}),
{ showSuggestions: true, emptyState: null, footerState: "loading" },
);
});
test("preset model suggestions stay visible when remote model discovery fails", () => {
assert.deepEqual(
getModelSuggestionsPresentation({
suggestionsLength: 2,
isLoading: false,
error: "Failed to fetch models",
hasFetched: false,
hasPresetModels: true,
}),
{ showSuggestions: true, emptyState: null, footerState: "error" },
);
});
test("selected model suggestions use the matching accent foreground", () => {
assert.match(getModelSuggestionClassName(true), /bg-accent/);
assert.match(getModelSuggestionClassName(true), /text-accent-foreground/);
assert.match(getModelSuggestionClassName(false), /hover:text-accent-foreground/);
assert.match(modelSelectorSource, /<Check size=\{12\} className="text-accent-foreground shrink-0" \/>/);
});

View File

@@ -0,0 +1,239 @@
import type { ExternalAgentConfig } from "../../../../infrastructure/ai/types";
import {
type ManagedAgentKey,
isPathLikeCommand,
} from "../../../../infrastructure/ai/managedAgents";
import type { AgentPathInfo } from "./types";
import { AGENT_DEFAULTS, isCursorAvailableForMode } from "./types";
import { buildCodebuddyEnv } from "./codebuddyConfigEnv";
function getAutoManagedAgentStoredPath(
agents: ExternalAgentConfig[],
agentKey: ManagedAgentKey,
): string | null {
const managed = agents.find((agent) => agent.id === `discovered_${agentKey}`);
if (managed?.commandSource === "auto") return null;
return isPathLikeCommand(managed?.command) ? managed?.command ?? null : null;
}
export function areExternalAgentListsEqual(
left: ExternalAgentConfig[],
right: ExternalAgentConfig[],
): boolean {
if (left.length !== right.length) return false;
return left.every((agent, index) => JSON.stringify(agent) === JSON.stringify(right[index]));
}
export function buildManagedAgentState(
prevAgents: ExternalAgentConfig[],
defaultAgentId: string,
agentKey: ManagedAgentKey,
pathInfo: AgentPathInfo | null,
commandSource: "manual" | "auto" = "auto",
): { agents: ExternalAgentConfig[]; defaultAgentId: string } {
const managedId = `discovered_${agentKey}`;
const managedAgents = prevAgents.filter((agent) => agent.id === managedId);
const otherAgents = prevAgents.filter((agent) => agent.id !== managedId);
if (!pathInfo?.available || !pathInfo.path) {
const existingManaged = managedAgents.find((agent) => agent.id === managedId);
if (agentKey === "cursor" && (existingManaged?.apiKey || existingManaged?.cursorAuthMode === "cli-login")) {
const defaults = AGENT_DEFAULTS[agentKey];
const {
acpCommand: _legacyCommand,
acpArgs: _legacyArgs,
...existingManagedWithoutLegacy
} = existingManaged;
return {
agents: [
...otherAgents,
{
...existingManagedWithoutLegacy,
...defaults,
id: managedId,
command: pathInfo?.path || existingManaged.command || "cursor",
// Preserve enable preference when probe is temporarily unavailable
// (e.g. wrong apiKeyPresent gating). Send requires available too.
enabled: existingManaged.enabled ?? true,
available: false,
// Preserve stored API key across mode / temporary unavailability.
...(existingManaged.apiKey ? { apiKey: existingManaged.apiKey } : {}),
cursorAuthMode: existingManaged.cursorAuthMode === "cli-login" ? "cli-login" : "api-key",
},
],
defaultAgentId: existingManaged.id === defaultAgentId ? "catty" : defaultAgentId,
};
}
if (agentKey === "codebuddy") {
const hasSavedCodebuddyConfig = Boolean(
(existingManaged?.env && Object.keys(existingManaged.env).length > 0) ||
(
existingManaged?.codebuddyOptions &&
Object.keys(existingManaged.codebuddyOptions).length > 0
),
);
if (hasSavedCodebuddyConfig) {
return {
agents: [
...otherAgents,
{
...existingManaged,
...AGENT_DEFAULTS.codebuddy,
id: managedId,
command: existingManaged.command || "codebuddy",
enabled: false,
available: false,
},
],
defaultAgentId: existingManaged.id === defaultAgentId ? "catty" : defaultAgentId,
};
}
}
return {
agents: otherAgents,
defaultAgentId: managedAgents.some((agent) => agent.id === defaultAgentId)
? "catty"
: defaultAgentId,
};
}
const existingManaged = managedAgents.find((agent) => agent.id === managedId);
const {
acpCommand: _legacyCommand,
acpArgs: _legacyArgs,
...existingManagedWithoutLegacy
} = existingManaged ?? {};
const defaults = AGENT_DEFAULTS[agentKey];
const managedEnv =
agentKey === "claude"
? { ...(existingManaged?.env ?? {}), CLAUDE_CODE_EXECUTABLE: pathInfo.path }
: agentKey === "codebuddy"
? { ...(existingManaged?.env ?? {}), CODEBUDDY_CODE_PATH: pathInfo.path }
: agentKey === "opencode"
? { ...(existingManaged?.env ?? {}), OPENCODE_BIN: pathInfo.path }
: existingManaged?.env;
const cursorAuthMode = agentKey === "cursor"
? (existingManaged?.cursorAuthMode
?? (pathInfo.authSource === "cli-login" || pathInfo.cliLoginOk ? "cli-login" : "api-key"))
: undefined;
const cursorModeAvailable = agentKey === "cursor"
? isCursorAvailableForMode(pathInfo, cursorAuthMode === "cli-login" ? "cli-login" : "api-key", {
hasStoredApiKey: Boolean(existingManaged?.apiKey),
})
: true;
const nextManagedAgent: ExternalAgentConfig = {
...existingManagedWithoutLegacy,
...defaults,
id: managedId,
command: agentKey === "cursor" && cursorAuthMode === "cli-login"
? (pathInfo.cliBinPath || pathInfo.path)
: pathInfo.path,
commandSource,
// Persist probed --version so the chat model picker can gate GPT-5.6+
// even when this custom path is not the PATH discovery binary.
...(pathInfo.version ? { cliVersion: pathInfo.version } : {}),
...(managedEnv ? { env: managedEnv } : {}),
available: cursorModeAvailable,
// Do not force-disable when only the current auth mode is temporarily
// unavailable (user may switch modes). Send paths already require available.
enabled: managedAgents.length === 0
|| (agentKey === "codebuddy" && existingManaged && !isPathLikeCommand(existingManaged.command))
? true
: managedAgents.some((agent) => agent.enabled) || managedAgents.every((agent) => agent.available === false),
...(agentKey === "cursor" ? {
cursorAuthMode,
// Keep stored API key in both modes; CLI turns omit it via env wiring.
...(existingManaged?.apiKey ? { apiKey: existingManaged.apiKey } : {}),
} : {}),
};
return {
agents: [...otherAgents, nextManagedAgent],
defaultAgentId: managedAgents.some((agent) => agent.id === defaultAgentId)
? managedId
: defaultAgentId,
};
}
export function updateCodebuddyManagedEnv(
prevAgents: ExternalAgentConfig[],
internetEnv: string,
envText: string,
): ExternalAgentConfig[] {
const managedId = "discovered_codebuddy";
const existingManaged = prevAgents.find((agent) => agent.id === managedId);
const nextEnv = buildCodebuddyEnv(existingManaged?.env, internetEnv, envText);
if (existingManaged) {
if (!nextEnv && !isPathLikeCommand(existingManaged.command)) {
return prevAgents.filter((agent) => agent.id !== managedId);
}
return prevAgents.map((agent) =>
agent.id === managedId
? { ...agent, ...(nextEnv ? { env: nextEnv } : { env: undefined }) }
: agent,
);
}
if (!nextEnv) return prevAgents;
return [
...prevAgents,
{
...AGENT_DEFAULTS.codebuddy,
id: managedId,
command: "codebuddy",
enabled: false,
env: nextEnv,
},
];
}
export function updateCodebuddyManagedOptions(
prevAgents: ExternalAgentConfig[],
options: ExternalAgentConfig['codebuddyOptions'],
): ExternalAgentConfig[] {
const managedId = "discovered_codebuddy";
const existingManaged = prevAgents.find((agent) => agent.id === managedId);
if (existingManaged) {
if (
!options &&
(!existingManaged.env || Object.keys(existingManaged.env).length === 0) &&
!isPathLikeCommand(existingManaged.command)
) {
return prevAgents.filter((agent) => agent.id !== managedId);
}
return prevAgents.map((agent) =>
agent.id === managedId
? { ...agent, codebuddyOptions: options }
: agent,
);
}
if (!options) return prevAgents;
return [
...prevAgents,
{
...AGENT_DEFAULTS.codebuddy,
id: managedId,
command: "codebuddy",
enabled: false,
codebuddyOptions: options,
},
];
}
export function getInitialManagedAgentPaths(agents: ExternalAgentConfig[]) {
return {
codex: getAutoManagedAgentStoredPath(agents, "codex") ?? "",
claude: getAutoManagedAgentStoredPath(agents, "claude") ?? "",
copilot: getAutoManagedAgentStoredPath(agents, "copilot") ?? "",
cursor: getAutoManagedAgentStoredPath(agents, "cursor") ?? "",
codebuddy: getAutoManagedAgentStoredPath(agents, "codebuddy") ?? "",
opencode: getAutoManagedAgentStoredPath(agents, "opencode") ?? "",
grok: getAutoManagedAgentStoredPath(agents, "grok") ?? "",
};
}

View File

@@ -0,0 +1,63 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
mergeModelContextWindow,
parseFetchedModels,
} from "./modelMetadata.ts";
import { buildModelSuggestions } from "./ModelSelector.tsx";
test("parseFetchedModels reads common context window fields from model list responses", () => {
assert.deepEqual(
parseFetchedModels({
data: [
{ id: "openrouter/model", name: "OpenRouter Model", context_length: 131072 },
{ id: "vercel/model", context_window: 262144 },
{ id: "custom/model", contextWindow: 65536 },
],
}),
[
{ id: "openrouter/model", name: "OpenRouter Model", contextWindow: 131072 },
{ id: "vercel/model", contextWindow: 262144 },
{ id: "custom/model", contextWindow: 65536 },
],
);
});
test("mergeModelContextWindow stores valid discovered model windows only", () => {
assert.deepEqual(
mergeModelContextWindow(undefined, "qwen", 262144),
{ qwen: 262144 },
);
assert.deepEqual(
mergeModelContextWindow({ old: 8192 }, "qwen", undefined),
{ old: 8192 },
);
});
test("buildModelSuggestions uses provider presets before fetched model discovery", () => {
assert.deepEqual(
buildModelSuggestions({
presetModels: ["qwen3.6-plus", "qwen3.6-flash"],
fetchedModels: [],
hasFetched: false,
value: "plus",
}),
[{ id: "qwen3.6-plus" }],
);
});
test("buildModelSuggestions merges fetched and preset models without duplicates", () => {
assert.deepEqual(
buildModelSuggestions({
presetModels: ["kimi-k2.6", "moonshot-v1-128k"],
fetchedModels: [
{ id: "kimi-k2.6", name: "Kimi K2.6" },
{ id: "moonshot-v1-8k", name: "Moonshot 8K" },
],
hasFetched: true,
value: "",
}).map((model) => model.id),
["kimi-k2.6", "moonshot-v1-128k", "moonshot-v1-8k"],
);
});

View File

@@ -0,0 +1,44 @@
import { sanitizeContextWindow } from "../../../../infrastructure/ai/contextCompaction";
import type { FetchedModel } from "./types";
export function parseFetchedModels(parsed: unknown): FetchedModel[] {
const record = parsed && typeof parsed === "object" ? parsed as Record<string, unknown> : {};
const rawModels = Array.isArray(record.data)
? record.data
: Array.isArray(record.models)
? record.models
: [];
return rawModels
.map((raw): FetchedModel | null => {
if (!raw || typeof raw !== "object") return null;
const model = raw as Record<string, unknown>;
if (typeof model.id !== "string" || !model.id) return null;
return {
id: model.id,
...(typeof model.name === "string" ? { name: model.name } : {}),
...(resolveModelContextWindow(model) != null ? { contextWindow: resolveModelContextWindow(model) } : {}),
};
})
.filter((model): model is FetchedModel => model != null);
}
export function mergeModelContextWindow(
current: Record<string, number> | undefined,
modelId: string,
contextWindow: number | undefined,
): Record<string, number> | undefined {
const sanitized = sanitizeContextWindow(contextWindow);
if (!modelId || sanitized == null) return current;
return { ...(current ?? {}), [modelId]: sanitized };
}
function resolveModelContextWindow(model: Record<string, unknown>): number | undefined {
return sanitizeContextWindow(
model.context_length
?? model.context_window
?? model.contextWindow
?? model.context
?? model.max_context_tokens,
);
}

View File

@@ -0,0 +1,67 @@
import assert from "node:assert/strict";
import test from "node:test";
import { isCursorAvailableForMode, isCursorRuntimeInstalled } from "./types";
test("isCursorRuntimeInstalled ignores bundled SDK flags", () => {
assert.equal(isCursorRuntimeInstalled({
path: "cursor",
version: "Cursor SDK",
available: true,
installed: true,
sdkInstalled: true,
}), false);
assert.equal(isCursorRuntimeInstalled({
path: "cursor",
version: "Cursor SDK",
available: false,
installed: false,
sdkInstalled: true,
cliBinPath: null,
cliLoginOk: false,
}), false);
});
test("isCursorRuntimeInstalled is true for Agent CLI path or CLI login", () => {
assert.equal(isCursorRuntimeInstalled({
path: "/usr/local/bin/cursor-agent",
version: "Cursor Agent CLI",
available: false,
sdkInstalled: true,
cliBinPath: "/usr/local/bin/cursor-agent",
cliLoginOk: false,
}), true);
assert.equal(isCursorRuntimeInstalled({
path: "cursor",
version: "Cursor Agent CLI",
available: true,
sdkInstalled: true,
cliLoginOk: true,
}), true);
});
test("isCursorAvailableForMode still allows API-key mode from bundled SDK", () => {
assert.equal(isCursorAvailableForMode({
path: "cursor",
version: "Cursor SDK",
available: true,
installed: false,
sdkInstalled: true,
apiKeyOk: true,
}, "api-key"), true);
assert.equal(isCursorAvailableForMode({
path: "cursor",
version: "Cursor SDK",
available: true,
installed: false,
apiKeyOk: true,
}, "api-key"), true);
assert.equal(isCursorAvailableForMode({
path: "cursor",
version: "Cursor SDK",
available: false,
installed: false,
sdkInstalled: true,
cliLoginOk: false,
}, "cli-login"), false);
});

View File

@@ -0,0 +1,339 @@
/**
* Shared types for AI settings sub-components
*/
import type {
AIProviderId,
ExternalAgentConfig,
ProviderAdvancedParams,
OpenAIApiFormat,
ProviderStyle,
} from "../../../../infrastructure/ai/types";
export type CodexIntegrationState =
| "connected_chatgpt"
| "connected_api_key"
| "connected_custom_config"
| "not_logged_in"
| "unknown";
export interface CodexCustomProviderConfig {
providerName: string;
displayName: string;
baseUrl: string | null;
envKey: string | null;
envKeyPresent: boolean;
hasHardcodedApiKey: boolean;
model: string | null;
authHash: string | null;
}
export interface CodexIntegrationStatus {
state: CodexIntegrationState;
isConnected: boolean;
rawOutput: string;
exitCode: number | null;
customConfig?: CodexCustomProviderConfig | null;
}
export interface CodexAppServerStatus {
available: boolean;
checking?: boolean;
error?: string;
}
export type CodexLoginState = "running" | "success" | "error" | "cancelled";
export interface CodexLoginSession {
sessionId: string;
state: CodexLoginState;
url: string | null;
output: string;
error: string | null;
exitCode: number | null;
codexPath?: string | null;
}
export interface AgentPathInfo {
path: string | null;
binPath?: string | null;
version: string | null;
available: boolean;
/** True when the user's Cursor Agent CLI is on PATH or logged in. */
installed?: boolean;
authenticated?: boolean;
authSource?: string | null;
cliEmail?: string | null;
cliBinPath?: string | null;
/** True when local Cursor Agent CLI is logged in (subscription session). */
cliLoginOk?: boolean;
/** True when settings or env API key is present. */
apiKeyOk?: boolean;
/** True when @cursor/sdk platform package is importable. */
sdkInstalled?: boolean;
}
/** User-environment Cursor Agent CLI, not Netcatty's bundled @cursor/sdk. */
export function isCursorRuntimeInstalled(pathInfo: AgentPathInfo | null | undefined): boolean {
return Boolean(pathInfo?.cliBinPath || pathInfo?.cliLoginOk);
}
/** Mode-aware Cursor availability for Settings enablement. */
export function isCursorAvailableForMode(
pathInfo: AgentPathInfo | null | undefined,
mode: "api-key" | "cli-login",
options?: { hasStoredApiKey?: boolean },
): boolean {
if (!pathInfo) return false;
if (mode === "cli-login") {
return Boolean(pathInfo.cliLoginOk || pathInfo.authSource === "cli-login");
}
const hasKey = Boolean(
options?.hasStoredApiKey
|| pathInfo.apiKeyOk
|| pathInfo.authSource === "settings"
|| pathInfo.authSource === "CURSOR_API_KEY",
);
// Missing sdkInstalled means the probe has not filled it yet. API-key mode
// uses Netcatty's bundled SDK and must not wait for Cursor.app.
const sdkOk = pathInfo.sdkInstalled !== undefined
? Boolean(pathInfo.sdkInstalled)
: true;
return hasKey && sdkOk;
}
export interface UserSkillStatusItem {
id: string;
slug: string;
directoryName: string;
directoryPath: string;
skillPath: string;
name: string;
description: string;
status: "ready" | "warning";
warnings: string[];
}
export interface UserSkillsStatusResult {
ok: boolean;
directoryPath?: string;
readyCount?: number;
warningCount?: number;
skills?: UserSkillStatusItem[];
warnings?: string[];
error?: string;
}
export interface ProviderFormState {
name: string;
apiKey: string;
baseURL: string;
defaultModel: string;
contextWindow: string;
modelContextWindows: Record<string, number>;
skipTLSVerify: boolean;
advancedParams: ProviderAdvancedParams;
style: ProviderStyle | ""; // "" means inherit-from-providerId
openaiApi: OpenAIApiFormat;
iconId: string; // "" means no built-in pick (fall back to providerId)
iconDataUrl: string; // "" means no upload override
}
export interface FetchedModel {
id: string;
name?: string;
contextWindow?: number;
}
export interface FetchBridge {
aiFetch?: (url: string, method?: string, headers?: Record<string, string>, body?: string, providerId?: string, skipHostCheck?: boolean, followRedirects?: boolean, skipTLSVerify?: boolean) => Promise<{ ok: boolean; status?: number; data: string; error?: string }>;
aiAllowlistAddHost?: (baseURL: string) => Promise<{ ok: boolean }>;
}
export interface NetcattyAiBridge {
aiDiscoverAgents?: (options?: { refreshShellEnv?: boolean; apiKeyPresent?: boolean }) => Promise<Array<AgentPathInfo & { command: string }>>;
aiPrewarmShellEnv?: () => Promise<{ ok: boolean; error?: string }>;
aiCodexGetIntegration?: (options?: { refreshShellEnv?: boolean; validateChatGptAuth?: boolean; codexPath?: string }) => Promise<CodexIntegrationStatus>;
aiCodexStartLogin?: (options?: { codexPath?: string }) => Promise<{ ok: boolean; session?: CodexLoginSession; error?: string }>;
aiCodexGetLoginSession?: (sessionId: string) => Promise<{ ok: boolean; session?: CodexLoginSession; error?: string }>;
aiCodexCancelLogin?: (sessionId: string) => Promise<{ ok: boolean; found?: boolean; session?: CodexLoginSession; error?: string }>;
aiCodexLogout?: (options?: { codexPath?: string }) => Promise<{ ok: boolean; state?: CodexIntegrationState; isConnected?: boolean; rawOutput?: string; logoutOutput?: string; error?: string }>;
aiResolveCli?: (params: { command: string; customPath?: string; refreshShellEnv?: boolean; apiKeyPresent?: boolean }) => Promise<AgentPathInfo>;
aiSdkAgentListModels?: (sdkBackend: string, cwd?: string, providerId?: string, chatSessionId?: string, agentEnv?: Record<string, string>, agentCommand?: string, codexRuntime?: 'sdk' | 'app-server') => Promise<{ ok: boolean; models?: Array<{ id: string; name: string; description?: string; thinkingLevels?: string[]; defaultThinkingLevel?: string }>; currentModelId?: string | null; error?: string }>;
codexAppServerGetStatus?: (agentCommand?: string, agentEnv?: Record<string, string>) => Promise<{ ok: boolean; available: boolean; error?: string }>;
aiUserSkillsGetStatus?: () => Promise<UserSkillsStatusResult>;
aiUserSkillsOpenFolder?: () => Promise<UserSkillsStatusResult>;
aiSkillsCliGetInvocation?: () => Promise<{
ok: boolean;
skillPath?: string | null;
commandPrefix?: string;
launcherPath?: string | null;
usesLauncher?: boolean;
error?: string;
}>;
openExternal?: (url: string) => Promise<void>;
externalMcpGetStatus?: () => Promise<Record<string, unknown>>;
externalMcpSetEnabled?: (enabled: boolean) => Promise<Record<string, unknown>>;
externalMcpSetConfig?: (config: {
mode?: 'temporary' | 'persistent';
idleTimeoutMinutes?: number;
sessionIdleTimeoutMinutes?: number;
}) => Promise<Record<string, unknown>>;
externalMcpCodexGetStatus?: () => Promise<Record<string, unknown>>;
externalMcpCodexAdd?: () => Promise<Record<string, unknown>>;
externalMcpClaudeGetStatus?: () => Promise<Record<string, unknown>>;
externalMcpClaudeAdd?: () => Promise<Record<string, unknown>>;
externalMcpGrokGetStatus?: () => Promise<Record<string, unknown>>;
externalMcpGrokAdd?: () => Promise<Record<string, unknown>>;
}
// Agent default configs for registration in externalAgents
export const AGENT_DEFAULTS: Record<string, Omit<ExternalAgentConfig, "id" | "command" | "enabled">> = {
codex: {
name: "Codex CLI",
args: ["exec", "--full-auto", "--json", "{prompt}"],
icon: "openai",
sdkBackend: "codex",
},
claude: {
name: "Claude Code",
args: ["-p", "--output-format", "text", "{prompt}"],
icon: "claude",
sdkBackend: "claude",
},
copilot: {
name: "GitHub Copilot CLI",
args: ["-p", "{prompt}"],
icon: "copilot",
sdkBackend: "copilot",
},
cursor: {
name: "Cursor",
args: ["{prompt}"],
icon: "cursor",
sdkBackend: "cursor",
},
codebuddy: {
name: "CodeBuddy Code",
args: [],
icon: "codebuddy",
sdkBackend: "codebuddy",
},
opencode: {
name: "OpenCode",
args: [],
icon: "opencode",
sdkBackend: "opencode",
},
grok: {
name: "Grok Build",
args: [],
icon: "grok",
sdkBackend: "grok",
},
};
// ---------------------------------------------------------------------------
// Bridge helpers
// ---------------------------------------------------------------------------
export function getBridge(): NetcattyAiBridge | undefined {
return (window as unknown as { netcatty?: NetcattyAiBridge }).netcatty;
}
export function getFetchBridge(): FetchBridge | undefined {
return (window as unknown as { netcatty?: FetchBridge }).netcatty;
}
export function normalizeCodexBridgeError(error: unknown): string {
const message = error instanceof Error ? error.message : String(error);
if (message.includes("No handler registered for 'netcatty:ai:codex:")) {
return "Codex main-process handlers are not loaded yet. Fully restart Netcatty, or restart the Electron dev process, then try again.";
}
return message;
}
// ---------------------------------------------------------------------------
// Provider icon helper
// ---------------------------------------------------------------------------
export type SettingsIconId = AIProviderId | "claude" | "copilot" | "codebuddy" | "opencode";
export const SETTINGS_ICON_PATHS: Record<SettingsIconId, string> = {
openai: "/ai/providers/openai.svg",
anthropic: "/ai/providers/anthropic.svg",
claude: "/ai/agents/claude.svg",
copilot: "/ai/agents/copilot.svg",
codebuddy: "/ai/agents/codebuddy.svg",
opencode: "/ai/agents/opencode.svg",
google: "/ai/providers/google.svg",
ollama: "/ai/providers/ollama.svg",
openrouter: "/ai/providers/openrouter.svg",
qwen: "/ai/providers/qwen.svg",
deepseek: "/ai/providers/deepseek.svg",
kimi: "/ai/providers/kimi.svg",
zhipu: "/ai/providers/zhipu.svg",
doubao: "/ai/providers/doubao.svg",
mimo: "/ai/providers/xiaomi.svg",
custom: "/ai/providers/custom.svg",
};
export const SETTINGS_ICON_COLORS: Record<SettingsIconId, string> = {
openai: "bg-emerald-600",
anthropic: "bg-orange-600",
claude: "bg-orange-600",
copilot: "border border-zinc-300 bg-white",
codebuddy: "bg-indigo-600",
opencode: "bg-teal-600",
google: "bg-blue-600",
ollama: "bg-purple-600",
openrouter: "bg-pink-600",
qwen: "bg-[#615CED]",
deepseek: "bg-[#4D6BFE]",
kimi: "bg-zinc-800",
zhipu: "bg-[#3859FF]",
doubao: "bg-[#0066FF]",
mimo: "bg-[#FF6900]",
custom: "bg-zinc-600",
};
// ---------------------------------------------------------------------------
// Extra brand icons (lobe-icons subset, MIT) for ProviderConfig.iconId
// See public/ai/providers/NOTICE.md for attribution.
// ---------------------------------------------------------------------------
export interface BuiltinProviderIcon {
/** Identifier stored as ProviderConfig.iconId. */
id: string;
/** Display label shown in the icon picker. */
label: string;
/** Suggested display name when picking this preset (auto-fills ProviderConfig.name). */
name: string;
/** Absolute URL of the SVG asset. */
path: string;
/** Background tint applied behind the monochrome glyph. */
bgColor: string;
}
export const BUILTIN_PROVIDER_ICONS: BuiltinProviderIcon[] = [
{ id: "anthropic", label: "Anthropic", name: "Anthropic", path: "/ai/providers/anthropic.svg", bgColor: "bg-orange-600" },
{ id: "openai", label: "OpenAI", name: "OpenAI", path: "/ai/providers/openai.svg", bgColor: "bg-emerald-600" },
{ id: "google", label: "Google", name: "Google", path: "/ai/providers/google.svg", bgColor: "bg-blue-600" },
{ id: "ollama", label: "Ollama", name: "Ollama", path: "/ai/providers/ollama.svg", bgColor: "bg-purple-600" },
{ id: "openrouter", label: "OpenRouter", name: "OpenRouter", path: "/ai/providers/openrouter.svg", bgColor: "bg-pink-600" },
{ id: "deepseek", label: "DeepSeek", name: "DeepSeek", path: "/ai/providers/deepseek.svg", bgColor: "bg-[#4D6BFE]" },
{ id: "moonshot", label: "Moonshot", name: "Moonshot", path: "/ai/providers/moonshot.svg", bgColor: "bg-zinc-800" },
{ id: "kimi", label: "Kimi", name: "Kimi", path: "/ai/providers/kimi.svg", bgColor: "bg-zinc-800" },
{ id: "qwen", label: "Qwen / 通义", name: "Qwen", path: "/ai/providers/qwen.svg", bgColor: "bg-[#615CED]" },
{ id: "zhipu", label: "Zhipu / 智谱", name: "Zhipu", path: "/ai/providers/zhipu.svg", bgColor: "bg-[#3859FF]" },
{ id: "doubao", label: "Doubao / 豆包", name: "Doubao", path: "/ai/providers/doubao.svg", bgColor: "bg-[#0066FF]" },
{ id: "xiaomi", label: "Xiaomi / 小米", name: "Xiaomi MiMo", path: "/ai/providers/xiaomi.svg", bgColor: "bg-[#FF6900]" },
{ id: "mistral", label: "Mistral", name: "Mistral", path: "/ai/providers/mistral.svg", bgColor: "bg-[#FA520F]" },
{ id: "cohere", label: "Cohere", name: "Cohere", path: "/ai/providers/cohere.svg", bgColor: "bg-[#39594D]" },
{ id: "grok", label: "Grok / xAI", name: "Grok", path: "/ai/providers/grok.svg", bgColor: "bg-zinc-900" },
{ id: "perplexity", label: "Perplexity", name: "Perplexity", path: "/ai/providers/perplexity.svg", bgColor: "bg-[#1F8A8C]" },
{ id: "groq", label: "Groq", name: "Groq", path: "/ai/providers/groq.svg", bgColor: "bg-[#F55036]" },
{ id: "huggingface", label: "Hugging Face", name: "Hugging Face", path: "/ai/providers/huggingface.svg", bgColor: "bg-[#FF9D00]" },
];
export const BUILTIN_PROVIDER_ICON_BY_ID: Record<string, BuiltinProviderIcon> =
Object.fromEntries(BUILTIN_PROVIDER_ICONS.map((icon) => [icon.id, icon]));