返回 DeepSeek-Reasonix
CapabilitiesPanel.tsx
根目录 / desktop / frontend / src / components / CapabilitiesPanel.tsx
1 import { SettingsOptions } from "./SettingsOptions";
2 import { SettingsSelect } from "./SettingsSelect";
3 import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4 import { ArrowLeft, ChevronDown, ChevronRight, CircleAlert, Folder, Plus, RefreshCw, Search, Server as ServerIcon } from "lucide-react";
5 import { asArray } from "../lib/array";
6 import { app } from "../lib/bridge";
7 import { activeWorkBusyNoticeText, installMCPServer } from "../lib/capabilityMutations";
8 import { useT } from "../lib/i18n";
9 import { mcpServerLifecycleActions, mcpServerRetryableFromAvailableList } from "../lib/mcpServerLifecycle";
10 import { mcpSessionStateLabel, mcpSettingsSearchText } from "../lib/mcpSessionStatus";
11 import { canUseNativeMCPOAuth } from "../lib/mcpOAuthEligibility";
12 import type { CapabilitiesView, MCPMarketplaceEntry, MCPMarketplaceView, MCPServerInput, PluginAgentView, PluginCommandView, PluginCompatibilityIssue, PluginHookView, PluginInstallOptions, PluginMCPServerView, PluginSkillView, PluginView, ServerView, SkillRootSkillView, SkillRootView, SkillsSettingsView, SkillView, TabMeta } from "../lib/types";
13 import { InlineConfirmButton } from "./InlineConfirmButton";
14 import { ResizableDrawer } from "./ResizableDrawer";
15 import { Tooltip } from "./Tooltip";
16 import { ModalCloseButton } from "./ModalCloseButton";
17
18 // CapabilitiesPanel is the desktop MCP & Skills drawer — the GUI counterpart to
19 // the CLI's /mcp + /skill, aligning with Claude Code's Customize → Connectors:
20 // each server shows a connected/failed dot, transport, and tool/prompt/resource
21 // counts, with add / remove / retry; skills list their scope and run mode.
22 type CapTab = "servers" | "skills";
23
24 type SettingsSnapshot<T> = { key: string; value: T };
25
26 function connectMCPServer(name: string, servers: ServerView[]): Promise<void> {
27 const server = servers.find((candidate) => candidate.name === name);
28 if (server && shouldOpenAuth(server)) return app.AuthenticateMCPServer(name);
29 return app.ReconnectMCPServer(name);
30 }
31
32 let mcpSettingsSnapshot: SettingsSnapshot<ServerView[]> | null = null;
33 let skillsSettingsSnapshot: SettingsSnapshot<SkillsSettingsView> | null = null;
34 let pluginsSettingsSnapshot: SettingsSnapshot<PluginView[]> | null = null;
35
36 function settingsSnapshotKey(meta: Awaited<ReturnType<typeof app.Meta>> | null | undefined, tabs: TabMeta[] | null | undefined): string {
37 const active = tabs?.find((tab) => tab.active);
38 const tabID = (active?.id || "").trim();
39 const root = (active?.workspaceRoot || active?.workspacePath || active?.cwd || meta?.workspaceRoot || meta?.workspacePath || meta?.cwd || "").trim();
40 const channel = (meta?.eventChannel || "").trim();
41 return `${channel}|${tabID}|${root}`;
42 }
43
44 export function CapabilitiesPanel({
45 onClose,
46 initialTab = "servers",
47 }: {
48 onClose: () => void;
49 initialTab?: CapTab;
50 }) {
51 const t = useT();
52 const [view, setView] = useState<CapabilitiesView | null>(null);
53 const [busy, setBusy] = useState(false);
54 const [err, setErr] = useState<string | null>(null);
55 const [adding, setAdding] = useState(false);
56 const [editing, setEditing] = useState<string | null>(null);
57 const [tab, setTab] = useState<CapTab>(initialTab);
58 const [skillQuery, setSkillQuery] = useState("");
59 const [expandedSkills, setExpandedSkills] = useState<Set<string>>(() => new Set());
60 const [expandedErrors, setExpandedErrors] = useState<Set<string>>(() => new Set());
61 const [expandedServers, setExpandedServers] = useState<Set<string>>(() => new Set());
62 const [expandedServerTools, setExpandedServerTools] = useState<Set<string>>(() => new Set());
63
64 const reload = useCallback(async () => {
65 setView(normalizeCapabilitiesView(await app.Capabilities().catch(() => ({ servers: [], skills: [], skillRoots: [], plugins: [] }))));
66 }, []);
67 useEffect(() => {
68 void reload();
69 }, [reload]);
70 useEffect(() => {
71 if (tab !== "servers" || !view?.servers.some((s) => s.status === "initializing" || s.status === "deferred")) return;
72 const id = window.setInterval(() => void reload(), 2500);
73 return () => window.clearInterval(id);
74 }, [reload, tab, view?.servers]);
75
76 // mutate runs an MCP edit, re-reads the snapshot, and surfaces any failure as an
77 // inline banner (a connect error, a missing binary, a bad URL).
78 const mutate = async (fn: () => Promise<unknown>) => {
79 setBusy(true);
80 setErr(null);
81 try {
82 await fn();
83 await reload();
84 return true;
85 } catch (e) {
86 setErr(activeWorkBusyNoticeText(e, t) ?? String((e as Error)?.message ?? e));
87 await reload();
88 return false;
89 } finally {
90 setBusy(false);
91 }
92 };
93
94 const summary = useMemo(() => {
95 if (!view) return "";
96 return t("caps.summary", {
97 connected: view.servers.filter((s) => s.status === "connected").length,
98 failed: view.servers.filter((s) => s.status === "failed").length,
99 skills: view.skills.length,
100 });
101 }, [view, t]);
102
103 const filteredSkills = useMemo(() => {
104 if (!view) return [];
105 const q = skillQuery.trim().toLowerCase();
106 if (!q) return view.skills;
107 return view.skills.filter((sk) => {
108 const text = [sk.name, `/${sk.name}`, sk.invocation, sk.plugin, sk.description, sk.scope, sk.sourceDir, sk.runAs].join(" ").toLowerCase();
109 return text.includes(q);
110 });
111 }, [view, skillQuery]);
112 const skillSummary = useMemo(() => {
113 if (!view) return "";
114 return skillListSummary(view.skills, filteredSkills, skillQuery.trim().length > 0, t);
115 }, [filteredSkills, skillQuery, t, view]);
116
117 const serverGroups = useMemo(() => {
118 const servers = sortServersForDisplay(view?.servers ?? []);
119 return {
120 failed: servers.filter((s) => s.status === "failed"),
121 active: servers.filter((s) => s.status !== "failed"),
122 };
123 }, [view]);
124 const retryableActiveServerNames = useMemo(() => retryableAvailableServerNames(serverGroups.active), [serverGroups.active]);
125 const toggleSkill = useCallback((name: string) => {
126 setExpandedSkills((prev) => {
127 const next = new Set(prev);
128 if (next.has(name)) next.delete(name);
129 else next.add(name);
130 return next;
131 });
132 }, []);
133
134 const toggleError = useCallback((name: string) => {
135 setExpandedErrors((prev) => {
136 const next = new Set(prev);
137 if (next.has(name)) next.delete(name);
138 else next.add(name);
139 return next;
140 });
141 }, []);
142
143 const toggleServer = useCallback((name: string) => {
144 setExpandedServers((prev) => {
145 const next = new Set(prev);
146 if (next.has(name)) next.delete(name);
147 else next.add(name);
148 return next;
149 });
150 }, []);
151
152 const toggleServerTools = useCallback((name: string) => {
153 setExpandedServerTools((prev) => {
154 const next = new Set(prev);
155 if (next.has(name)) next.delete(name);
156 else next.add(name);
157 return next;
158 });
159 }, []);
160
161 return (
162 <ResizableDrawer onClose={onClose} subtle>
163 <header className="drawer__head">
164 <div>
165 <div className="drawer__title">{t("caps.title")}</div>
166 {view && <div className="drawer__summary">{summary}</div>}
167 </div>
168 <div className="drawer__actions">
169 <Tooltip label={t("caps.refresh")}>
170 <button className="chip" disabled={busy} onClick={() => void reload()}>
171
172 </button>
173 </Tooltip>
174 <ModalCloseButton label={t("common.close")} onClick={onClose} />
175 </div>
176 </header>
177
178 {!view ? (
179 <div className="empty">{t("caps.loading")}</div>
180 ) : (
181 <div className="drawer__body">
182 {err && <div className="banner banner--error">{err}</div>}
183
184 <div className="cap-tabs" role="tablist" aria-label={t("caps.title")}>
185 <button
186 className={`cap-tab${tab === "servers" ? " cap-tab--active" : ""}`}
187 role="tab"
188 aria-selected={tab === "servers"}
189 onClick={() => setTab("servers")}
190 >
191 {t("caps.connectorsTab")}
192 </button>
193 <button
194 className={`cap-tab${tab === "skills" ? " cap-tab--active" : ""}`}
195 role="tab"
196 aria-selected={tab === "skills"}
197 onClick={() => setTab("skills")}
198 >
199 {t("caps.skillsTab")}
200 </button>
201 </div>
202
203 {tab === "servers" ? (
204 <section className="mem-section">
205 <div className="cap-mcp-toolbar cap-mcp-toolbar--drawer">
206 {!adding && (
207 <button className="btn btn--small" disabled={busy} onClick={() => setAdding(true)}>
208 {t("caps.addServer")}
209 </button>
210 )}
211 </div>
212 {serverGroups.failed.length > 0 && (
213 <FailedServersNotice
214 servers={serverGroups.failed}
215 expanded={expandedErrors}
216 onToggle={toggleError}
217 onRetry={(name) => void mutate(() => connectMCPServer(name, view.servers))}
218 onRetryMany={(names) => void mutate(() => Promise.allSettled(names.map((name) => app.ReconnectMCPServer(name))))}
219 onConfirmClearAuth={(name) => void mutate(() => app.ClearMCPServerAuthentication(name))}
220 onConfirm={(name) => void mutate(() => app.RemoveMCPServer(name))}
221 onConfirmMany={(names) => void mutate(() => Promise.allSettled(names.map((name) => app.RemoveMCPServer(name))))}
222 busy={busy}
223 />
224 )}
225 {view.servers.length === 0 && !adding && (
226 <div className="mem-empty">{t("caps.noServers")}</div>
227 )}
228 {serverGroups.active.length > 0 && (
229 <div className="cap-server-section">
230 <div className="cap-server-section__head settings-toolbar">
231 <div className="cap-server-section__title">{t("caps.availableServers")}</div>
232 <button
233 className="btn btn--small"
234 disabled={busy || retryableActiveServerNames.length === 0}
235 type="button"
236 onClick={() => void mutate(() => Promise.allSettled(retryableActiveServerNames.map((name) => app.ReconnectMCPServer(name))))}
237 >
238 {t("caps.retryAll")}
239 </button>
240 </div>
241 <ServerGroup
242 busy={busy}
243 servers={serverGroups.active}
244 expanded={expandedServers}
245 expandedTools={expandedServerTools}
246 editing={editing}
247 onConfirm={(name) => void mutate(() => app.RemoveMCPServer(name))}
248 onEdit={(name) => {
249 setEditing(name);
250 }}
251 onCancelEdit={() => setEditing(null)}
252 onRetry={(name) => void mutate(() => connectMCPServer(name, view.servers))}
253 onReconnect={(name) => void mutate(() => app.ReconnectMCPServer(name))}
254 onConfirmClearAuth={(name) => void mutate(() => app.ClearMCPServerAuthentication(name))}
255 onToggle={(name, on) => void mutate(() => app.SetMCPServerEnabled(name, on))}
256 onUpdate={(name, input) =>
257 void mutate(() => app.UpdateMCPServer(name, input)).then((ok) => {
258 if (ok) setEditing(null);
259 })
260 }
261 onToggleDetails={toggleServer}
262 onToggleTools={toggleServerTools}
263 />
264 </div>
265 )}
266 {adding ? (
267 <MCPServerSettingsEditor
268 busy={busy}
269 onCancel={() => setAdding(false)}
270 onSubmit={(input) => void mutate(() => installMCPServer(input)).then((ok) => { if (ok) setAdding(false); })}
271 />
272 ) : null}
273 </section>
274 ) : (
275 <section className="mem-section">
276 <div className="cap-search settings-toolbar">
277 <input
278 className="mem-input"
279 type="search"
280 placeholder={t("caps.searchSkills")}
281 value={skillQuery}
282 onChange={(e) => setSkillQuery(e.target.value)}
283 />
284 </div>
285 <SkillSources
286 roots={view.skillRoots ?? []}
287 busy={busy}
288 onAdd={() => mutate(async () => {
289 const path = await app.PickSkillFolder();
290 if (path) await app.AddSkillPath(path);
291 })}
292 onRefresh={() => mutate(() => app.RefreshSkills())}
293 onToggle={(path, enabled) => mutate(() => app.SetSkillPathEnabled(path, enabled))}
294 />
295 <div className="cap-skills-head settings-toolbar">
296 <div className="cap-skills-head__copy">
297 <div className="cap-skills-head__title">{t("caps.skills")}</div>
298 <div className="cap-skills-head__summary">{skillSummary}</div>
299 </div>
300 </div>
301 {view.skills.length === 0 ? (
302 <div className="mem-empty">{t("caps.noSkills")}</div>
303 ) : filteredSkills.length === 0 ? (
304 <div className="mem-empty">{t("caps.noSkillMatches")}</div>
305 ) : (
306 <div className="cap-skills">
307 {filteredSkills.map((sk) => (
308 <SkillRow
309 key={sk.name}
310 skill={sk}
311 busy={busy}
312 expanded={expandedSkills.has(sk.name)}
313 onToggle={() => toggleSkill(sk.name)}
314 onToggleEnabled={(enabled) => void mutate(() => app.SetSkillEnabled(sk.name, enabled))}
315 />
316 ))}
317 </div>
318 )}
319 </section>
320 )}
321 </div>
322 )}
323 </ResizableDrawer>
324 );
325 }
326
327 function normalizeCapabilitiesView(view: CapabilitiesView | null | undefined): CapabilitiesView {
328 return {
329 servers: normalizeServerViews(view?.servers),
330 plugins: asArray(view?.plugins),
331 ...normalizeSkillsSettingsView(view),
332 };
333 }
334
335 function normalizeServerViews(servers: ServerView[] | null | undefined): ServerView[] {
336 return sortServersForDisplay(
337 asArray(servers).map((server) => ({
338 ...server,
339 args: asArray(server.args),
340 envKeys: asArray(server.envKeys),
341 headerKeys: asArray(server.headerKeys),
342 toolList: asArray(server.toolList),
343 })),
344 );
345 }
346
347 function normalizeSkillsSettingsView(view: SkillsSettingsView | CapabilitiesView | null | undefined): SkillsSettingsView {
348 return {
349 skills: asArray(view?.skills),
350 skillRoots: asArray(view?.skillRoots).map((root) => ({
351 ...root,
352 enabled: root.enabled !== false,
353 removable: Boolean(root.removable),
354 skillItems: asArray(root.skillItems),
355 })),
356 allowImplicitInvocation: view?.allowImplicitInvocation !== false,
357 };
358 }
359
360 function sortServersForDisplay(servers: ServerView[]): ServerView[] {
361 return [...servers].sort((a, b) => {
362 const priority = serverDisplayPriority(a) - serverDisplayPriority(b);
363 if (priority !== 0) return priority;
364 return a.name.localeCompare(b.name, undefined, { sensitivity: "base" });
365 });
366 }
367
368 function serverDisplayPriority(server: ServerView): number {
369 if (server.status === "failed" || server.authStatus === "required") return 0;
370 if (server.builtIn) return 1;
371 if (server.status !== "disabled") return 2;
372 return 3;
373 }
374
375 function skillListSummary(skills: SkillView[], filtered: SkillView[], searching: boolean, t: ReturnType<typeof useT>): string {
376 if (searching) {
377 return t("caps.skillsSummaryMatches", { matched: filtered.length, total: skills.length });
378 }
379 const parts = [t("caps.skillsSummaryAvailable", { skills: skills.length })];
380 const scopes = ["project", "custom", "global", "builtin"];
381 for (const scope of scopes) {
382 const count = skills.filter((skill) => skill.scope === scope).length;
383 if (count > 0) parts.push(skillScopeSummary(scope, count, t));
384 }
385 return parts.join(" · ");
386 }
387
388 function mcpServerSummary(servers: ServerView[], t: ReturnType<typeof useT>): string {
389 return t("caps.mcpSummary", {
390 connected: servers.filter((s) => s.status === "connected").length,
391 failed: servers.filter((s) => s.status === "failed").length,
392 tools: servers.reduce((total, server) => total + (server.tools || 0), 0),
393 unavailable: servers.reduce((total, server) => total + mcpServerSchemaIssueCount(server), 0),
394 });
395 }
396
397 function skillScopeSummary(scope: string, count: number, t: ReturnType<typeof useT>): string {
398 switch (scope) {
399 case "builtin":
400 return t("caps.skillsSummaryBuiltin", { count });
401 case "project":
402 return t("caps.skillsSummaryProject", { count });
403 case "custom":
404 return t("caps.skillsSummaryCustom", { count });
405 case "global":
406 return t("caps.skillsSummaryGlobal", { count });
407 default:
408 return `${count} ${scope}`;
409 }
410 }
411
412 function skillSourceSummary(active: number, missing: number, empty: number, disabled: number, t: ReturnType<typeof useT>): string {
413 const parts: string[] = [];
414 if (active > 0) parts.push(t("caps.sourcesSummaryActive", { active }));
415 if (missing > 0) parts.push(t("caps.sourcesSummaryMissing", { missing }));
416 if (empty > 0) parts.push(t("caps.sourcesSummaryEmpty", { empty }));
417 if (disabled > 0) parts.push(t("caps.sourcesSummaryDisabled", { disabled }));
418 return parts.length > 0 ? parts.join(" · ") : t("caps.sourcesSummaryNone");
419 }
420
421 function SkillSources({
422 roots,
423 busy,
424 onAdd,
425 onRefresh,
426 onToggle,
427 }: {
428 roots: SkillRootView[];
429 busy: boolean;
430 onAdd: () => void;
431 onRefresh: () => void;
432 onToggle: (path: string, enabled: boolean) => void;
433 }) {
434 const t = useT();
435 // Sources are a core part of the Skills page, so expose them on first visit.
436 // Users can still collapse the section when they need more room for the list.
437 const [expanded, setExpanded] = useState(false);
438 const [expandedRootSkills, setExpandedRootSkills] = useState<Set<string>>(() => new Set());
439 const [fullRootSkills, setFullRootSkills] = useState<Set<string>>(() => new Set());
440 const primaryRoots = roots.filter(isPrimarySkillRoot);
441 const enabledRoots = primaryRoots.filter((root) => root.enabled !== false && root.status !== "disabled");
442 const disabledRoots = primaryRoots.filter((root) => root.enabled === false || root.status === "disabled");
443 const shownRoots = [
444 ...enabledRoots,
445 ...disabledRoots,
446 ];
447 const summaryRoots = roots;
448 const active = summaryRoots.filter((root) => root.skills > 0).length;
449 const missing = summaryRoots.filter((root) => root.status === "missing").length;
450 const empty = summaryRoots.filter((root) => root.status === "ok" && root.skills === 0).length;
451 const disabled = summaryRoots.filter((root) => root.enabled === false || root.status === "disabled").length;
452 const toggleRootSkills = (key: string) => {
453 setExpandedRootSkills((prev) => {
454 const next = new Set(prev);
455 if (next.has(key)) next.delete(key);
456 else next.add(key);
457 return next;
458 });
459 };
460 const toggleRootSkillFull = (key: string) => {
461 setFullRootSkills((prev) => {
462 const next = new Set(prev);
463 if (next.has(key)) next.delete(key);
464 else next.add(key);
465 return next;
466 });
467 };
468 return (
469 <div className={`cap-sources${expanded ? " cap-sources--expanded" : ""}`}>
470 <button
471 className="cap-sources__head"
472 type="button"
473 onClick={() => setExpanded((value) => !value)}
474 aria-expanded={expanded}
475 >
476 <span className="cap-sources__copy">
477 <span className="cap-sources__title">{t("caps.sources")}</span>
478 <span className="cap-sources__summary">{skillSourceSummary(active, missing, empty, disabled, t)}</span>
479 </span>
480 <ChevronDown className={`cap-sources__chevron${expanded ? " cap-sources__chevron--expanded" : ""}`} aria-hidden size={16} />
481 </button>
482 {expanded && (
483 <>
484 <div className="cap-sources__manage">
485 <div className="cap-sources__manage-actions">
486 <button className="btn btn--small" disabled={busy} onClick={onRefresh}>
487 <RefreshCw aria-hidden size={13} />
488 {t("caps.refreshSkills")}
489 </button>
490 <button className="btn btn--small" disabled={busy} onClick={onAdd}>
491 <Plus aria-hidden size={13} />
492 {t("caps.addSkillFolder")}
493 </button>
494 </div>
495 <button
496 className="btn btn--small"
497 type="button"
498 onClick={() => {
499 setExpanded(false);
500 }}
501 aria-expanded={expanded}
502 >
503 {t("common.collapse")}
504 </button>
505 </div>
506 {roots.length === 0 ? (
507 <div className="mem-empty">{t("caps.noSkillRoots")}</div>
508 ) : shownRoots.length > 0 ? (
509 <div className="cap-source-list">
510 {shownRoots.map((root) => {
511 const key = skillRootKey(root);
512 const rootSkills = root.skillItems ?? [];
513 const rootSkillsExpanded = expandedRootSkills.has(key);
514 const rootSkillsFull = fullRootSkills.has(key);
515 const canShowRootSkills = rootSkills.length > 0;
516 return (
517 <div className={`cap-source cap-source--${skillRootTone(root)}`} key={key}>
518 <span className={`cap-dot cap-dot--${skillRootDot(root)}`} />
519 <div className="cap-source__text">
520 <div className="cap-source__head">
521 <div className="cap-source__label" title={root.dir}>
522 {skillRootLabel(root)}
523 </div>
524 <div className="cap-source__badges">
525 {skillRootBadges(root, t).map((badge) => (
526 <span className={`cap-source-badge cap-source-badge--${badge.tone}`} key={badge.label}>
527 {badge.label}
528 </span>
529 ))}
530 </div>
531 </div>
532 <div className="cap-source__meta">
533 <span>{skillRootStatus(root, t)}</span>
534 <span>{t("caps.skillRootCount", { skills: root.skills })}</span>
535 {root.configured && <span>{t("caps.skillRootConfigured")}</span>}
536 </div>
537 {canShowRootSkills && (
538 <div className="cap-source-actions">
539 <button
540 className="btn btn--small"
541 disabled={busy}
542 type="button"
543 aria-expanded={rootSkillsExpanded}
544 onClick={() => toggleRootSkills(key)}
545 >
546 {rootSkillsExpanded ? t("caps.hideSkills") : t("caps.showSkills")}
547 </button>
548 </div>
549 )}
550 {rootSkillsExpanded && rootSkills.length > 0 && (
551 <SkillRootSkillsList
552 skills={rootSkills}
553 showAll={rootSkillsFull}
554 onToggleAll={() => toggleRootSkillFull(key)}
555 />
556 )}
557 {root.warning && <div className="cap-source__warning">{root.warning}</div>}
558 </div>
559 <div className="cap-source__side">
560 {root.removable && (
561 <input
562 className="provider-capability-row__switch cap-source__switch"
563 type="checkbox"
564 role="switch"
565 checked={root.enabled !== false}
566 disabled={busy}
567 aria-label={`${root.enabled === false ? t("caps.skillRootEnable") : t("caps.skillRootDisable")} ${root.dir}`}
568 onChange={(event) => onToggle(root.dir, event.currentTarget.checked)}
569 />
570 )}
571 </div>
572 </div>
573 );
574 })}
575 </div>
576 ) : null}
577 </>
578 )}
579 </div>
580 );
581 }
582
583 const skillRootPreviewLimit = 5;
584
585 function SkillRootSkillsList({
586 skills,
587 showAll,
588 onToggleAll,
589 }: {
590 skills: SkillRootSkillView[];
591 showAll: boolean;
592 onToggleAll: () => void;
593 }) {
594 const t = useT();
595 const visible = showAll ? skills : skills.slice(0, skillRootPreviewLimit);
596 return (
597 <div className="cap-source-skills">
598 {visible.map((skill) => (
599 <div className="cap-source-skill" key={`${skill.scope}:${skill.invocation || skill.name}`}>
600 <div className="cap-source-skill__head">
601 <span className="cap-source-skill__name">{skill.invocation || `/${skill.name}`}</span>
602 <span className="cap-source-skill__badges">
603 <span className={`cap-skill-badge cap-skill-badge--${skill.scope}`}>{skillScopeLabel(skill.scope, t)}</span>
604 {skill.plugin && <span className="cap-skill-badge">{t("slash.plugin", { name: skill.plugin })}</span>}
605 {skill.runAs === "subagent" && <span className="cap-skill-badge cap-skill-badge--run">{t("caps.subagent")}</span>}
606 </span>
607 </div>
608 {skill.description && <div className="cap-source-skill__desc">{skill.description}</div>}
609 </div>
610 ))}
611 {skills.length > skillRootPreviewLimit && (
612 <button className="cap-source-skills__more" type="button" onClick={onToggleAll}>
613 {showAll ? t("common.collapse") : t("caps.skillRootShowAllSkills", { count: skills.length })}
614 </button>
615 )}
616 </div>
617 );
618 }
619
620 function skillRootKey(root: SkillRootView): string {
621 return `${root.scope}:${root.priority}:${root.dir}`;
622 }
623
624 function isPrimarySkillRoot(root: SkillRootView): boolean {
625 return root.skills > 0 || root.configured || root.status === "disabled" || Boolean(root.warning);
626 }
627
628 function skillRootTone(root: SkillRootView): "active" | "empty" | "problem" {
629 if (root.warning || root.status === "inactive" || root.status === "missing" || root.status === "unreadable") return "problem";
630 if (root.skills > 0) return "active";
631 return "empty";
632 }
633
634 function skillRootDot(root: SkillRootView): "connected" | "disabled" | "failed" {
635 const tone = skillRootTone(root);
636 if (tone === "active") return "connected";
637 if (tone === "empty") return "disabled";
638 return "failed";
639 }
640
641 function skillRootStatus(root: SkillRootView, t: ReturnType<typeof useT>): string {
642 if (root.status === "disabled") return t("caps.skillRootDisabled");
643 if (root.status === "ok" && root.skills > 0) return t("caps.skillRootActive");
644 if (root.status === "ok") return t("caps.skillRootEmpty");
645 if (root.status === "missing") return t("caps.skillRootMissing");
646 return root.status;
647 }
648
649 function skillRootLabel(root: SkillRootView): string {
650 return root.dir;
651 }
652
653 function skillRootBadges(root: SkillRootView, t: ReturnType<typeof useT>): Array<{ label: string; tone: "scope" | "builtin" | "configured" | "missing" }> {
654 const badges: Array<{ label: string; tone: "scope" | "builtin" | "configured" | "missing" }> = [
655 { label: skillScopeLabel(root.scope, t), tone: "scope" },
656 root.scope === "custom"
657 ? { label: root.configured ? t("caps.skillRootUserConfigured") : t("caps.skillRootConfiguredPath"), tone: "configured" }
658 : { label: t("caps.skillRootBuiltinPath"), tone: "builtin" },
659 ];
660 if (root.status === "missing") {
661 badges.push({ label: t("caps.skillRootMissing"), tone: "missing" });
662 }
663 return badges;
664 }
665
666 function ServerGroup({
667 servers,
668 expanded,
669 expandedTools,
670 busy,
671 editing,
672 onConfirm,
673 onEdit,
674 onCancelEdit,
675 onRetry,
676 onReconnect,
677 onConfirmClearAuth,
678 onToggle,
679 onUpdate,
680 onToggleDetails,
681 onToggleTools,
682 }: {
683 servers: ServerView[];
684 expanded: Set<string>;
685 expandedTools: Set<string>;
686 busy: boolean;
687 editing: string | null;
688 onConfirm: (name: string) => void;
689 onEdit: (name: string) => void;
690 onCancelEdit: () => void;
691 onRetry: (name: string) => void;
692 onReconnect: (name: string) => void;
693 onConfirmClearAuth: (name: string) => void;
694 onToggle: (name: string, on: boolean) => void;
695 onUpdate: (name: string, input: MCPServerInput) => void;
696 onToggleDetails: (name: string) => void;
697 onToggleTools: (name: string) => void;
698 }) {
699 if (servers.length === 0) return null;
700 return (
701 <div className="cap-server-group">
702 {servers.map((s) => (
703 <ServerRow
704 key={s.name}
705 s={s}
706 expanded={expanded.has(s.name)}
707 toolsExpanded={expandedTools.has(s.name)}
708 busy={busy}
709 editing={editing === s.name}
710 onConfirm={() => onConfirm(s.name)}
711 onEdit={() => onEdit(s.name)}
712 onCancelEdit={onCancelEdit}
713 onRetry={() => onRetry(s.name)}
714 onReconnect={() => onReconnect(s.name)}
715 onConfirmClearAuth={() => onConfirmClearAuth(s.name)}
716 onToggle={(on) => onToggle(s.name, on)}
717 onUpdate={(input) => onUpdate(s.name, input)}
718 onToggleDetails={() => onToggleDetails(s.name)}
719 onToggleTools={() => onToggleTools(s.name)}
720 />
721 ))}
722 </div>
723 );
724 }
725
726 function FailedServersNotice({
727 servers,
728 expanded,
729 busy,
730 onToggle,
731 onRetry,
732 onRetryMany,
733 onConfirmClearAuth,
734 onConfirm,
735 onConfirmMany,
736 }: {
737 servers: ServerView[];
738 expanded: Set<string>;
739 busy: boolean;
740 onToggle: (name: string) => void;
741 onRetry: (name: string) => void;
742 onRetryMany: (names: string[]) => void;
743 onConfirmClearAuth: (name: string) => void;
744 onConfirm: (name: string) => void;
745 onConfirmMany: (names: string[]) => void;
746 }) {
747 const t = useT();
748 const [detailsOpen, setDetailsOpen] = useState(false);
749 const [bulkOpen, setBulkOpen] = useState(false);
750 const groups = useMemo(() => failureGroups(servers, t), [servers, t]);
751 const removableFailures = useMemo(() => servers.filter(canBulkRemoveFailure), [servers]);
752 const retryNames = useMemo(() => servers.map((s) => s.name), [servers]);
753 return (
754 <div className="cap-failures" role="region" aria-label={t("caps.failureTitle", { failed: servers.length })}>
755 <div className="cap-failures__head">
756 <div>
757 <div className="cap-failures__title">{t("caps.failureTitle", { failed: servers.length })}</div>
758 <div className="cap-failures__hint">{t("caps.failureHint")}</div>
759 </div>
760 <div className="cap-failures__actions">
761 <button className="btn btn--small" disabled={busy} type="button" onClick={() => setDetailsOpen((v) => !v)} aria-expanded={detailsOpen}>
762 {detailsOpen ? t("caps.hideFailureDetails") : t("caps.showFailureDetails")}
763 </button>
764 <button className="btn btn--small" disabled={busy || retryNames.length === 0} type="button" onClick={() => onRetryMany(retryNames)}>
765 {t("caps.retryAll")}
766 </button>
767 {removableFailures.length > 0 && (
768 <button className="btn btn--small" disabled={busy} type="button" onClick={() => setBulkOpen((v) => !v)} aria-expanded={bulkOpen}>
769 {t("caps.bulkActions")}
770 </button>
771 )}
772 </div>
773 </div>
774 <div className="cap-failures__meta">
775 <div className="cap-failures__chips" aria-label={t("caps.failureGroups")}>
776 {groups.map((group) => (
777 <span className="cap-failure-chip" key={group.kind}>{group.label}</span>
778 ))}
779 </div>
780 </div>
781 {bulkOpen && removableFailures.length > 0 && (
782 <div className="cap-failures__bulk">
783 <InlineConfirmButton
784 label={t("caps.removeInvalid", { count: removableFailures.length })}
785 confirmLabel={t("caps.confirmRemoveInvalid", { count: removableFailures.length })}
786 cancelLabel={t("common.cancel")}
787 disabled={busy}
788 danger
789 onConfirm={() => onConfirmMany(removableFailures.map((s) => s.name))}
790 />
791 </div>
792 )}
793 {detailsOpen && <div className="cap-failures__list">
794 {servers.map((s) => {
795 const open = expanded.has(s.name);
796 const error = s.error || t("caps.failed");
797 const actionLabel = serverActionLabel(s, t);
798 const handlePrimaryAction = () => {
799 onRetry(s.name);
800 };
801 return (
802 <div className="cap-failure" key={s.name}>
803 <div className="cap-failure__main">
804 <span className="cap-dot cap-dot--failed" />
805 <div className="cap-failure__text">
806 <div className="cap-failure__name">{s.name}</div>
807 <div className="cap-failure__summary">{s.authStatus === "required" ? t("caps.authRequiredSummary") : summarizeServerError(error)}</div>
808 </div>
809 </div>
810 <div className="cap-failure__actions">
811 <button className="btn btn--small" disabled={busy} onClick={handlePrimaryAction}>
812 {actionLabel}
813 </button>
814 {canClearAuth(s) && (
815 <InlineConfirmButton
816 label={t("caps.clearAuth")}
817 confirmLabel={t("caps.confirmClearAuth")}
818 cancelLabel={t("common.cancel")}
819 disabled={busy}
820 onConfirm={() => onConfirmClearAuth(s.name)}
821 />
822 )}
823 <button className="btn btn--small" onClick={() => onToggle(s.name)} aria-expanded={open}>
824 {open ? t("common.collapse") : t("caps.showLog")}
825 </button>
826 {!s.builtIn && !s.managedByPlugin && s.configured && (
827 <InlineConfirmButton
828 label={t("caps.remove")}
829 confirmLabel={t("caps.confirmRemove")}
830 cancelLabel={t("common.cancel")}
831 disabled={busy}
832 danger
833 onConfirm={() => onConfirm(s.name)}
834 />
835 )}
836 </div>
837 {open && (
838 <div className="cap-failure__logbox">
839 <div className="cap-failure__logbar">
840 <span>{t("caps.rawLog")}</span>
841 <button className="btn btn--small" onClick={() => void navigator.clipboard?.writeText(error)}>
842 {t("caps.copyLog")}
843 </button>
844 </div>
845 <pre className="cap-failure__log">{error}</pre>
846 </div>
847 )}
848 </div>
849 );
850 })}
851 </div>}
852 </div>
853 );
854 }
855
856 function ServerRow({
857 s,
858 expanded,
859 toolsExpanded,
860 busy,
861 editing,
862 onConfirm,
863 onEdit,
864 onCancelEdit,
865 onRetry,
866 onReconnect,
867 onConfirmClearAuth,
868 onToggle,
869 onUpdate,
870 onToggleDetails,
871 onToggleTools,
872 }: {
873 s: ServerView;
874 expanded: boolean;
875 toolsExpanded: boolean;
876 busy: boolean;
877 editing: boolean;
878 onConfirm: () => void;
879 onEdit: () => void;
880 onCancelEdit: () => void;
881 onRetry: () => void;
882 onReconnect: () => void;
883 onConfirmClearAuth: () => void;
884 onToggle: (on: boolean) => void;
885 onUpdate: (input: MCPServerInput) => void;
886 onToggleDetails: () => void;
887 onToggleTools: () => void;
888 }) {
889 const t = useT();
890 const actionLabel = serverActionLabel(s, t);
891 const lifecycle = mcpServerLifecycleActions(s);
892 const tools = s.toolList ?? [];
893 const schemaIssueCount = tools.filter((tool) => tool.schemaError).length;
894 let sub =
895 s.status === "failed"
896 ? s.error || t("caps.failed")
897 : s.status === "initializing"
898 ? t("caps.initializing")
899 : s.status === "deferred"
900 ? t("caps.deferred")
901 : s.status === "disabled"
902 ? s.configured && !s.autoStart
903 ? t("caps.disabledAutoStart")
904 : t("caps.disabled")
905 : t("caps.counts", { tools: s.tools, prompts: s.prompts, resources: s.resources });
906 if (schemaIssueCount > 0) {
907 sub = `${sub} · ${t("caps.schemaIssues", { count: schemaIssueCount })}`;
908 }
909 if (s.managedByPlugin) {
910 sub = `${sub} · ${t("caps.managedByPlugin", { plugin: s.managedByPlugin })}`;
911 }
912 if (s.authStatus === "possible" && s.status !== "failed") {
913 sub = `${sub} · ${t("caps.authPossibleShort")}`;
914 }
915 const handlePrimaryAction = () => {
916 onRetry();
917 };
918 return (
919 <div className={`cap-server-entry${s.status === "disabled" ? " cap-server-entry--disabled" : ""}`}>
920 <Tooltip label={s.error} disabled={!s.error} fill block>
921 <div className={`cap-row${s.status === "disabled" ? " cap-row--disabled" : ""}`}>
922 <Tooltip label={expanded ? t("caps.collapseDetails") : t("caps.expandDetails")}>
923 <button
924 className="cap-disclosure"
925 aria-expanded={expanded}
926 onClick={onToggleDetails}
927 >
928 {expanded ? "⌄" : "›"}
929 </button>
930 </Tooltip>
931 <span className={`cap-dot cap-dot--${s.status}`} />
932 <div className="cap-row__text">
933 <div className="cap-row__head">
934 <span className="cap-row__name">{s.name}</span>
935 <span className="cap-row__transport">{s.transport}</span>
936 {s.builtIn && <span className="cap-row__builtin">{t("caps.builtIn")}</span>}
937 </div>
938 <div className="cap-row__sub">{sub}</div>
939 </div>
940 <div className="cap-row__actions">
941 {lifecycle.showRetryInRow ? (
942 <button className="btn btn--small" disabled={busy} onClick={handlePrimaryAction}>
943 {actionLabel}
944 </button>
945 ) : (
946 <Tooltip label={lifecycle.enabled ? t("caps.disable") : t("caps.enable")}>
947 <label className="cap-switch">
948 <input
949 type="checkbox"
950 checked={lifecycle.enabled}
951 disabled={busy}
952 onChange={(e) => onToggle(e.target.checked)}
953 />
954 <span className="cap-switch__track" />
955 </label>
956 </Tooltip>
957 )}
958 </div>
959 </div>
960 </Tooltip>
961 {expanded && (
962 <ServerDetails
963 s={s}
964 tools={tools}
965 busy={busy}
966 onConfirm={onConfirm}
967 onConnectNow={onRetry}
968 onReconnect={onReconnect}
969 onConfirmClearAuth={onConfirmClearAuth}
970 toolsExpanded={toolsExpanded}
971 editing={editing}
972 onEdit={onEdit}
973 onCancelEdit={onCancelEdit}
974 onUpdate={onUpdate}
975 onToggleTools={onToggleTools}
976 />
977 )}
978 </div>
979 );
980 }
981
982 function ServerDetails({
983 s,
984 tools,
985 busy,
986 onConfirm,
987 onConnectNow,
988 onReconnect,
989 onConfirmClearAuth,
990 toolsExpanded,
991 editing,
992 onEdit,
993 onCancelEdit,
994 onUpdate,
995 onToggleTools,
996 standalone = false,
997 showToolsToggle = true,
998 }: {
999 s: ServerView;
1000 tools: ServerView["toolList"];
1001 busy: boolean;
1002 onConfirm: () => void;
1003 onConnectNow: () => void;
1004 onReconnect: () => void;
1005 onConfirmClearAuth: () => void;
1006 toolsExpanded: boolean;
1007 editing: boolean;
1008 onEdit: () => void;
1009 onCancelEdit: () => void;
1010 onUpdate: (input: MCPServerInput) => void;
1011 onToggleTools: () => void;
1012 standalone?: boolean;
1013 showToolsToggle?: boolean;
1014 }) {
1015 const t = useT();
1016 const command = serverCommand(s);
1017 const canMutateConfig = s.configured && !s.builtIn && !s.managedByPlugin;
1018 const canEditConfig = canMutateConfig;
1019 const lifecycle = mcpServerLifecycleActions(s);
1020 const canConnectNow = lifecycle.canConnectNow;
1021 const canReconnect = lifecycle.canReconnect;
1022 const canShowTools = s.status === "connected" && ((s.tools ?? 0) > 0 || (tools?.length ?? 0) > 0);
1023 const showClearAuth = canMutateConfig && canClearAuth(s);
1024 const authLabel = serverAuthLabel(s, t);
1025 if (editing && canEditConfig) {
1026 return (
1027 <div className={`cap-server-details${standalone ? " cap-server-details--page" : ""}`}>
1028 <EditServerForm s={s} busy={busy} onCancel={onCancelEdit} onSave={onUpdate} />
1029 </div>
1030 );
1031 }
1032 return (
1033 <div className={`cap-server-details${standalone ? " cap-server-details--page" : ""}`}>
1034 <div className="cap-detail-grid">
1035 <div className="cap-detail">
1036 <span className="cap-detail__label">{t("caps.status")}</span>
1037 <span className="cap-detail__value">{serverStatusLabel(s, t)}</span>
1038 </div>
1039 {s.source && (
1040 <div className="cap-detail">
1041 <span className="cap-detail__label">{t("caps.serverSource")}</span>
1042 <span className="cap-detail__value">{mcpServerSourceLabel(s, t)}</span>
1043 </div>
1044 )}
1045 <div className="cap-detail">
1046 <span className="cap-detail__label">{t("caps.transport")}</span>
1047 <span className="cap-detail__value">{s.transport}</span>
1048 </div>
1049 {authLabel && (
1050 <div className="cap-detail">
1051 <span className="cap-detail__label">{t("caps.auth")}</span>
1052 <span className="cap-detail__value">{authLabel}</span>
1053 </div>
1054 )}
1055 {command && (
1056 <div className="cap-detail cap-detail--wide">
1057 <span className="cap-detail__label">{s.transport === "stdio" ? t("caps.command") : t("caps.url")}</span>
1058 <span className="cap-detail__code">{command}</span>
1059 </div>
1060 )}
1061 {s.envKeys && s.envKeys.length > 0 && (
1062 <div className="cap-detail cap-detail--wide">
1063 <span className="cap-detail__label">{t("caps.envKeys")}</span>
1064 <span className="cap-detail__value">{s.envKeys.join(", ")}</span>
1065 </div>
1066 )}
1067 {s.headerKeys && s.headerKeys.length > 0 && (
1068 <div className="cap-detail cap-detail--wide">
1069 <span className="cap-detail__label">{t("caps.headerKeys")}</span>
1070 <span className="cap-detail__value">{s.headerKeys.join(", ")}</span>
1071 </div>
1072 )}
1073 </div>
1074 <div className="cap-detail-actions">
1075 {canConnectNow && (
1076 <button className="btn btn--small" disabled={busy} onClick={onConnectNow}>
1077 {t("caps.connectNow")}
1078 </button>
1079 )}
1080 {canReconnect && (
1081 <button className="btn btn--small" disabled={busy} onClick={onReconnect}>
1082 {t("caps.reconnect")}
1083 </button>
1084 )}
1085 {canShowTools && showToolsToggle && (
1086 <button className="btn btn--small" disabled={busy} onClick={onToggleTools} aria-expanded={toolsExpanded}>
1087 {toolsExpanded ? t("caps.hideTools") : t("caps.showTools")}
1088 </button>
1089 )}
1090 {showClearAuth && (
1091 <InlineConfirmButton
1092 label={t("caps.clearAuth")}
1093 confirmLabel={t("caps.confirmClearAuth")}
1094 cancelLabel={t("common.cancel")}
1095 disabled={busy}
1096 onConfirm={onConfirmClearAuth}
1097 />
1098 )}
1099 {canEditConfig && (
1100 <>
1101 <button className="btn btn--small" disabled={busy} onClick={onEdit}>
1102 {t("caps.editConfig")}
1103 </button>
1104 <InlineConfirmButton
1105 label={t("caps.remove")}
1106 confirmLabel={t("caps.confirmRemove")}
1107 cancelLabel={t("common.cancel")}
1108 disabled={busy}
1109 danger
1110 onConfirm={onConfirm}
1111 />
1112 </>
1113 )}
1114 </div>
1115 {toolsExpanded && (
1116 tools && tools.length > 0 ? (
1117 <div className="cap-tool-list">
1118 <div className="cap-tool-list__title">{t("caps.tools")}</div>
1119 {tools.map((tool) => {
1120 const unavailable = Boolean(tool.schemaError);
1121 return (
1122 <div className={`cap-tool${unavailable ? " cap-tool--unavailable" : ""}`} key={tool.name}>
1123 <div className="cap-tool__name">{tool.name}</div>
1124 <div className="cap-tool__desc">
1125 <span>{unavailable ? tool.schemaError : tool.description}</span>
1126 {unavailable ? (
1127 <span className="cap-tool-hint cap-tool-hint--error" title={tool.schemaError}>
1128 <CircleAlert aria-hidden size={11} strokeWidth={2.2} />
1129 {t("caps.toolUnavailable")}
1130 </span>
1131 ) : null}
1132 </div>
1133 </div>
1134 );
1135 })}
1136 </div>
1137 ) : (
1138 <div className="cap-tool-empty">{t("caps.noToolDetails")}</div>
1139 )
1140 )}
1141 </div>
1142 );
1143 }
1144
1145 function EditServerForm({
1146 s,
1147 busy,
1148 onCancel,
1149 onSave,
1150 }: {
1151 s: ServerView;
1152 busy: boolean;
1153 onCancel: () => void;
1154 onSave: (input: MCPServerInput) => void;
1155 }) {
1156 const t = useT();
1157 const initialTransport = normalizeTransportValue(s.transport);
1158 const [transport, setTransport] = useState(initialTransport);
1159 const [command, setCommand] = useState(initialTransport === "stdio" ? serverCommand(s) : "");
1160 const [url, setUrl] = useState(initialTransport === "stdio" ? "" : s.url || serverCommand(s));
1161 const [headers, setHeaders] = useState("");
1162 const [env, setEnv] = useState("");
1163 const isStdio = transport === "stdio";
1164 const ready = isStdio ? command.trim() !== "" : url.trim() !== "";
1165
1166 const submit = () => {
1167 const envText = env.trim();
1168 const headerText = headers.trim();
1169 onSave({
1170 name: s.name,
1171 transport,
1172 command: isStdio ? command.trim() : "",
1173 args: [],
1174 url: isStdio ? "" : url.trim(),
1175 env: envText === "" ? null : parseKeyValueText(envText),
1176 headers: isStdio || headerText === "" ? null : parseKeyValueText(headerText),
1177 });
1178 };
1179
1180 return (
1181 <div className="cap-config-edit">
1182 <div className="cap-detail-grid">
1183 <div className="cap-detail">
1184 <span className="cap-detail__label">{t("caps.name")}</span>
1185 <span className="cap-detail__value">{s.name}</span>
1186 </div>
1187 <label className="cap-detail cap-detail--select">
1188 <span className="cap-detail__label">{t("caps.transport")}</span>
1189 <SettingsSelect className="mem-select" value={transport} disabled={busy} onValueChange={(value) => setTransport(value)}>
1190 <option value="stdio">stdio</option>
1191 <option value="http">http</option>
1192 <option value="sse">sse</option>
1193 </SettingsSelect>
1194 </label>
1195 {isStdio ? (
1196 <label className="cap-detail cap-detail--wide">
1197 <span className="cap-detail__label">{t("caps.command")}</span>
1198 <input className="mem-input" value={command} disabled={busy} onChange={(e) => setCommand(e.target.value)} placeholder={t("caps.commandPlaceholder")} />
1199 </label>
1200 ) : (
1201 <label className="cap-detail cap-detail--wide">
1202 <span className="cap-detail__label">{t("caps.url")}</span>
1203 <input className="mem-input" value={url} disabled={busy} onChange={(e) => setUrl(e.target.value)} placeholder={t("caps.urlPlaceholder")} />
1204 </label>
1205 )}
1206 {!isStdio && (
1207 <label className="cap-detail cap-detail--wide">
1208 <span className="cap-detail__label">{t("caps.headersLabel")}</span>
1209 <textarea className="mem-textarea cap-config-edit__env" value={headers} disabled={busy} onChange={(e) => setHeaders(e.target.value)} placeholder={t("caps.headersPlaceholder")} spellCheck={false} />
1210 </label>
1211 )}
1212 {!isStdio && s.headerKeys && s.headerKeys.length > 0 && (
1213 <div className="cap-detail cap-detail--wide">
1214 <span className="cap-detail__label">{t("caps.headerKeys")}</span>
1215 <span className="cap-detail__value">{s.headerKeys.join(", ")}</span>
1216 <span className="cap-edit-hint">{t("caps.headersPreserveHint")}</span>
1217 </div>
1218 )}
1219 <label className="cap-detail cap-detail--wide">
1220 <span className="cap-detail__label">{t("caps.envLabel")}</span>
1221 <textarea className="mem-textarea cap-config-edit__env" value={env} disabled={busy} onChange={(e) => setEnv(e.target.value)} placeholder={t("caps.envPlaceholder")} spellCheck={false} />
1222 </label>
1223 {s.envKeys && s.envKeys.length > 0 && (
1224 <div className="cap-detail cap-detail--wide">
1225 <span className="cap-detail__label">{t("caps.envKeys")}</span>
1226 <span className="cap-detail__value">{s.envKeys.join(", ")}</span>
1227 <span className="cap-edit-hint">{t("caps.envPreserveHint")}</span>
1228 </div>
1229 )}
1230 </div>
1231 <div className="cap-detail-actions">
1232 <button className="btn btn--small" disabled={busy} onClick={onCancel}>
1233 {t("common.cancel")}
1234 </button>
1235 <button className="btn btn--primary btn--small" disabled={busy || !ready} onClick={submit}>
1236 {t("caps.saveConfig")}
1237 </button>
1238 </div>
1239 </div>
1240 );
1241 }
1242
1243 function serverCommand(s: ServerView): string {
1244 if (s.transport === "stdio") return [s.command, ...(s.args ?? [])].filter(Boolean).join(" ").trim();
1245 return (s.url || "").trim();
1246 }
1247
1248 function normalizeTransportValue(transport: string): string {
1249 const value = transport.trim().toLowerCase();
1250 if (value === "http" || value === "streamable-http") return "http";
1251 if (value === "sse") return "sse";
1252 if (value === "" || value === "stdio") return "stdio";
1253 return value;
1254 }
1255
1256 function parseKeyValueText(text: string): Record<string, string> {
1257 const values: Record<string, string> = {};
1258 for (const rawLine of text.split("\n")) {
1259 const line = rawLine.trim();
1260 if (!line) continue;
1261 const eq = line.indexOf("=");
1262 if (eq > 0) values[line.slice(0, eq).trim()] = line.slice(eq + 1).trim();
1263 }
1264 return values;
1265 }
1266
1267 function serverStatusLabel(s: ServerView, t: ReturnType<typeof useT>): string {
1268 // Prefer product availability so idle enabled servers are not shown as disconnected.
1269 const availability = s.availability
1270 || (s.enabled === false || s.status === "disabled"
1271 ? "disabled"
1272 : s.status === "connected"
1273 ? "connected"
1274 : s.status === "initializing"
1275 ? "starting"
1276 : s.status === "failed"
1277 ? (s.authStatus === "required" ? "auth_required" : "start_failed")
1278 : s.status === "deferred"
1279 ? "available_on_demand"
1280 : s.status);
1281 switch (availability) {
1282 case "connected":
1283 return t("caps.connected");
1284 case "available_on_demand":
1285 return t("caps.deferred");
1286 case "starting":
1287 return t("caps.initializing");
1288 case "disabled":
1289 return t("caps.disabled");
1290 case "auth_required":
1291 return t("caps.authRequired");
1292 case "project_auth_changed":
1293 return t("caps.projectAuthChanged");
1294 case "start_failed":
1295 return t("caps.failed");
1296 default:
1297 switch (s.status) {
1298 case "connected":
1299 return t("caps.connected");
1300 case "deferred":
1301 return t("caps.deferred");
1302 case "initializing":
1303 return t("caps.initializing");
1304 case "disabled":
1305 return t("caps.disabled");
1306 case "failed":
1307 if (s.authStatus === "required") return t("caps.authRequired");
1308 return t("caps.failed");
1309 default:
1310 return s.status;
1311 }
1312 }
1313 }
1314
1315 export function summarizeServerError(error: string): string {
1316 const normalized = error.replace(/\s+/g, " ").trim();
1317 const plugin = normalized.match(/plugin "([^"]+)"/i)?.[1];
1318 const npmCode = normalized.match(/\bnpm (?:error|ERR!) code ([A-Z0-9_]+)/i)?.[1];
1319 const errno = normalized.match(/\berrno (-?\d+)/i)?.[1];
1320 const networkContext = npmCode ? npmNetworkContext(normalized, npmCode) : "";
1321 const reason = npmCode
1322 ? `npm ${npmCode}${errno ? ` (${errno})` : ""}${networkContext}`
1323 : normalized.split(/(?:\.\s+|\n)/)[0];
1324 const summary = plugin ? `${plugin}: ${reason}` : reason;
1325 return summary.length > 180 ? `${summary.slice(0, 176).trim()}…` : summary;
1326 }
1327
1328 function npmNetworkContext(error: string, code: string): string {
1329 if (!/^(?:ECONNREFUSED|ECONNRESET|ENETUNREACH|ETIMEDOUT|EAI_AGAIN|ENOTFOUND)$/i.test(code)) return "";
1330
1331 let registry = "";
1332 const requestURL = error.match(/\brequest to (https?:\/\/[^\s]+)/i)?.[1]?.replace(/[),.;]+$/, "");
1333 if (requestURL) {
1334 try {
1335 registry = new URL(requestURL).host;
1336 } catch {
1337 registry = "";
1338 }
1339 }
1340
1341 let endpoint = error.match(
1342 /\b(?:connect\s+)?(?:ECONNREFUSED|ECONNRESET|ENETUNREACH|ETIMEDOUT|EAI_AGAIN|ENOTFOUND)\s+((?:\[[0-9a-f:]+\]|[a-z0-9._-]+):\d{1,5})\b/i,
1343 )?.[1] ?? "";
1344 if (!endpoint) {
1345 const address = error.match(/\baddress\s+([^\s,;]+)/i)?.[1];
1346 const port = error.match(/\bport\s+(\d{1,5})\b/i)?.[1];
1347 if (address && port) endpoint = `${address}:${port}`;
1348 }
1349
1350 if (registry && endpoint && registry.toLowerCase() !== endpoint.toLowerCase()) return ` · ${registry} → ${endpoint}`;
1351 if (registry || endpoint) return ` · ${registry || endpoint}`;
1352 return "";
1353 }
1354
1355 export type FailureKind = "auth" | "missing-command" | "command-unavailable" | "network" | "other";
1356
1357 export function failureKind(server: ServerView): FailureKind {
1358 if (server.authStatus === "required") return "auth";
1359 const err = (server.error || "").toLowerCase();
1360 if (err.includes("command is required")) return "missing-command";
1361 if (
1362 err.includes("command not found") ||
1363 err.includes("executable file not found") ||
1364 err.includes("no such file") ||
1365 err.includes("enoent")
1366 ) {
1367 return "command-unavailable";
1368 }
1369 if (
1370 err.includes("401") ||
1371 err.includes("403") ||
1372 err.includes("unauthorized") ||
1373 err.includes("forbidden") ||
1374 err.includes("timeout") ||
1375 err.includes("network") ||
1376 err.includes("econnrefused") ||
1377 err.includes("econnreset") ||
1378 err.includes("enetunreach") ||
1379 err.includes("etimedout") ||
1380 err.includes("eai_again") ||
1381 err.includes("enotfound")
1382 ) {
1383 return "network";
1384 }
1385 return "other";
1386 }
1387
1388 function failureGroups(servers: ServerView[], t: ReturnType<typeof useT>): Array<{ kind: FailureKind; label: string }> {
1389 const counts = new Map<FailureKind, number>();
1390 for (const server of servers) {
1391 const kind = failureKind(server);
1392 counts.set(kind, (counts.get(kind) ?? 0) + 1);
1393 }
1394 const order: FailureKind[] = ["missing-command", "command-unavailable", "auth", "network", "other"];
1395 return order.flatMap((kind) => {
1396 const count = counts.get(kind) ?? 0;
1397 if (count === 0) return [];
1398 return [{ kind, label: failureGroupLabel(kind, count, t) }];
1399 });
1400 }
1401
1402 function failureGroupLabel(kind: FailureKind, count: number, t: ReturnType<typeof useT>): string {
1403 switch (kind) {
1404 case "auth":
1405 return t("caps.failureGroupAuth", { count });
1406 case "missing-command":
1407 return t("caps.failureGroupMissingCommand", { count });
1408 case "command-unavailable":
1409 return t("caps.failureGroupCommandUnavailable", { count });
1410 case "network":
1411 return t("caps.failureGroupNetwork", { count });
1412 default:
1413 return t("caps.failureGroupOther", { count });
1414 }
1415 }
1416
1417 function canBulkRemoveFailure(server: ServerView): boolean {
1418 if (server.builtIn || server.managedByPlugin || !server.configured) return false;
1419 const kind = failureKind(server);
1420 return kind === "missing-command" || kind === "command-unavailable";
1421 }
1422
1423 function retryableAvailableServerNames(servers: ServerView[]): string[] {
1424 return servers.filter(mcpServerRetryableFromAvailableList).map((s) => s.name);
1425 }
1426
1427 function serverActionLabel(s: ServerView, t: ReturnType<typeof useT>): string {
1428 const err = (s.error || "").toLowerCase();
1429 if (shouldOpenAuth(s)) return t("caps.reauthorize");
1430 if (
1431 err.includes("command not found") ||
1432 err.includes("executable file not found") ||
1433 err.includes("no such file") ||
1434 err.includes("enoent")
1435 ) {
1436 return t("caps.checkCommand");
1437 }
1438 return t("caps.retry");
1439 }
1440
1441 function serverAuthLabel(s: ServerView, t: ReturnType<typeof useT>): string {
1442 if (s.authStatus === "required") return t("caps.authRequired");
1443 if (s.authStatus === "possible") return t("caps.authPossible");
1444 return "";
1445 }
1446
1447 function shouldOpenAuth(s: ServerView): boolean {
1448 return s.authStatus === "required" && canUseNativeMCPOAuth(s);
1449 }
1450
1451 function canClearAuth(s: ServerView): boolean {
1452 if (!s.configured || s.builtIn || s.managedByPlugin) return false;
1453 return Boolean(s.authConfigured || s.authStatus === "required" || s.authStatus === "possible" || isRemoteTransport(s.transport));
1454 }
1455
1456 function isRemoteTransport(transport?: string): boolean {
1457 const value = (transport || "").trim().toLowerCase();
1458 return value === "http" || value === "streamable-http" || value === "sse";
1459 }
1460
1461 function SkillRow({
1462 skill,
1463 busy,
1464 expanded,
1465 onToggle,
1466 onToggleEnabled,
1467 }: {
1468 skill: SkillView;
1469 busy: boolean;
1470 expanded: boolean;
1471 onToggle: () => void;
1472 onToggleEnabled: (enabled: boolean) => void;
1473 }) {
1474 const t = useT();
1475 const summary = summarizeSkillDescription(skill.description);
1476 const canExpand = summary !== skill.description;
1477 return (
1478 <div
1479 className={`cap-skill-card${expanded ? " cap-skill-card--expanded" : ""}${canExpand ? " cap-skill-card--expandable" : ""}${!skill.enabled ? " cap-skill-card--disabled" : ""}`}
1480 >
1481 <div className="cap-skill-card__top">
1482 <button className="cap-skill-card__toggle" type="button" onClick={onToggle} aria-expanded={expanded}>
1483 <span className="cap-skill-card__head">
1484 <span className="cap-skill-card__icon">/</span>
1485 <span className="cap-skill-card__main">
1486 <span className="cap-skill-card__identity">
1487 <span className="cap-skill-card__command">{(skill.invocation || `/${skill.name}`).replace(/^\//, "")}</span>
1488 {skill.sourceDir && (
1489 <span className="cap-skill-card__source" title={skill.sourceDir}>
1490 <Folder aria-hidden size={11} />
1491 <span>{skill.sourceDir}</span>
1492 </span>
1493 )}
1494 </span>
1495 <span className="cap-skill-card__badges">
1496 <span className={`cap-skill-badge cap-skill-badge--${skill.scope}`}>{skillScopeLabel(skill.scope, t)}</span>
1497 {skill.plugin && <span className="cap-skill-badge">{t("slash.plugin", { name: skill.plugin })}</span>}
1498 {skill.runAs === "subagent" && <span className="cap-skill-badge cap-skill-badge--run">{t("caps.subagent")}</span>}
1499 {!skill.enabled && <span className="cap-skill-badge cap-skill-badge--off">{t("caps.skillDisabled")}</span>}
1500 </span>
1501 </span>
1502 </span>
1503 </button>
1504 <Tooltip label={skill.enabled ? t("caps.disableSkill") : t("caps.enableSkill")}>
1505 <label className="cap-switch">
1506 <input
1507 type="checkbox"
1508 checked={skill.enabled}
1509 disabled={busy}
1510 onChange={(e) => onToggleEnabled(e.target.checked)}
1511 />
1512 <span className="cap-switch__track" />
1513 </label>
1514 </Tooltip>
1515 </div>
1516 <div className="cap-skill-card__desc">{expanded ? skill.description : summary}</div>
1517 {canExpand && (
1518 <button className="cap-skill-card__more" type="button" onClick={onToggle} aria-expanded={expanded}>
1519 {expanded ? t("common.collapse") : t("common.expand")}
1520 </button>
1521 )}
1522 </div>
1523 );
1524 }
1525
1526 function skillScopeLabel(scope: string, t: ReturnType<typeof useT>): string {
1527 switch (scope) {
1528 case "builtin":
1529 return t("caps.skillScopeBuiltin");
1530 case "project":
1531 return t("caps.skillScopeProject");
1532 case "custom":
1533 return t("caps.skillScopeCustom");
1534 case "global":
1535 return t("caps.skillScopeGlobal");
1536 default:
1537 return scope;
1538 }
1539 }
1540
1541 function summarizeSkillDescription(description: string): string {
1542 const normalized = description.replace(/\s+/g, " ").trim();
1543 if (normalized.length <= 132) return normalized;
1544 const sentence = normalized.match(/^.{48,132}?[。.!?;;,,]/u)?.[0]?.trim();
1545 if (sentence && sentence.length >= 48) return sentence.replace(/[。.!?;;,,]$/u, "");
1546 return `${normalized.slice(0, 128).trim()}…`;
1547 }
1548
1549 function tokenizeMCPCommand(raw: string): string[] {
1550 const tokens: string[] = [];
1551 let token = "";
1552 let quote = "";
1553 for (let i = 0; i < raw.length; i += 1) {
1554 const ch = raw[i];
1555 if (quote) {
1556 if (ch === quote) {
1557 quote = "";
1558 continue;
1559 }
1560 if (ch === "\\" && quote === '"' && i + 1 < raw.length && /["\\]/.test(raw[i + 1])) {
1561 token += raw[i + 1];
1562 i += 1;
1563 continue;
1564 }
1565 token += ch;
1566 continue;
1567 }
1568 if (ch === '"' || ch === "'") {
1569 quote = ch;
1570 continue;
1571 }
1572 if (ch === "\\" && i + 1 < raw.length && /\s/.test(raw[i + 1])) {
1573 token += raw[i + 1];
1574 i += 1;
1575 continue;
1576 }
1577 if (/\s/.test(ch)) {
1578 if (token) tokens.push(token);
1579 token = "";
1580 continue;
1581 }
1582 token += ch;
1583 }
1584 if (token) tokens.push(token);
1585 return tokens;
1586 }
1587
1588 function firstMCPCommandOperand(args: string[]): string {
1589 const valueFlags = new Set(["-p", "--package", "-c", "--call", "--node-options", "--python"]);
1590 let options = true;
1591 for (let i = 0; i < args.length; i += 1) {
1592 const arg = args[i];
1593 if (options && arg === "--") {
1594 options = false;
1595 continue;
1596 }
1597 if (options && arg.startsWith("-")) {
1598 if (valueFlags.has(arg)) i += 1;
1599 continue;
1600 }
1601 return arg;
1602 }
1603 return "";
1604 }
1605
1606 function quickMCPName(raw: string): string {
1607 const argv = tokenizeMCPCommand(raw);
1608 const executable = argv[0]?.split(/[\\/]/).pop()?.toLowerCase().replace(/\.(?:cmd|exe|bat)$/i, "") || "";
1609 let candidate = argv[0] || "mcp-server";
1610 if (["npx", "bunx", "uvx"].includes(executable)) {
1611 candidate = firstMCPCommandOperand(argv.slice(1)) || candidate;
1612 } else if (["python", "python3", "py"].includes(executable)) {
1613 const moduleIndex = argv.findIndex((arg) => arg === "-m");
1614 candidate = moduleIndex >= 0 ? argv[moduleIndex + 1] || candidate : firstMCPCommandOperand(argv.slice(1)) || candidate;
1615 } else if (executable === "node") {
1616 candidate = firstMCPCommandOperand(argv.slice(1)) || candidate;
1617 } else if (executable === "uv" && argv[1] === "run") {
1618 candidate = firstMCPCommandOperand(argv.slice(2)) || candidate;
1619 }
1620 const base = candidate.split(/[\\/]/).pop() || candidate;
1621 const unversioned = base.replace(/@[^@]+$/, "").replace(/\.(?:cmd|exe|bat)$/i, "");
1622 const sanitized = unversioned.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
1623 return sanitized && /[a-z0-9]/.test(sanitized) && !["npx", "uvx", "uv", "node", "bunx", "python", "python3", "py"].includes(sanitized)
1624 ? sanitized
1625 : "mcp-server";
1626 }
1627
1628 export function parseMCPQuickDefinition(raw: string): MCPServerInput {
1629 const definition = raw.trim();
1630 if (definition.startsWith("{")) return parseMCPServerJSON(definition).input;
1631 if (/^https?:\/\//i.test(definition)) {
1632 let name = "remote-mcp";
1633 try {
1634 name = new URL(definition).hostname.replace(/^www\./, "").split(".")[0] || name;
1635 } catch {
1636 throw new Error("invalid" satisfies MCPServerJSONError);
1637 }
1638 return { name, transport: "http", command: "", args: [], url: definition, env: null, headers: null };
1639 }
1640 return { name: quickMCPName(definition), transport: "stdio", command: definition, args: [], url: "", env: null, headers: null };
1641 }
1642
1643 type PluginRuntimePlan = {
1644 command?: string;
1645 args?: string[];
1646 intercepts?: string[];
1647 replaces?: string[];
1648 capabilities?: string[];
1649 fullTrust?: boolean;
1650 };
1651
1652 type PluginInstallPlanAction = {
1653 action?: string;
1654 kind?: string;
1655 name?: string;
1656 source?: string;
1657 status?: string;
1658 message?: string;
1659 error?: string;
1660 compatibility?: string;
1661 mappedCapabilities?: string[];
1662 skippedCapabilities?: PluginCompatibilityIssue[];
1663 runtime?: PluginRuntimePlan;
1664 agentCount?: number;
1665 skillCount?: number;
1666 commandCount?: number;
1667 hookCount?: number;
1668 toolCount?: number;
1669 };
1670
1671 type PluginInstallPlanView = {
1672 raw: string;
1673 ok?: boolean;
1674 status?: string;
1675 name?: string;
1676 actions: PluginInstallPlanAction[];
1677 warnings: string[];
1678 error?: string;
1679 };
1680
1681 type PluginInstallMode = "local" | "git";
1682
1683 // PluginsSettingsPage is the desktop plugin package manager embedded inside
1684 // Settings. It mirrors the MCP/Skills density: install planning on top, package
1685 // rows below, and diagnostics/details only when a row is expanded.
1686 export function PluginsSettingsPage() {
1687 const t = useT();
1688 const [installOpen, setInstallOpen] = useState(false);
1689 const [snapshotKey, setSnapshotKey] = useState("");
1690 const [plugins, setPlugins] = useState<PluginView[] | null>(null);
1691 const [busy, setBusy] = useState(false);
1692 const [err, setErr] = useState<string | null>(null);
1693 const [installMode, setInstallMode] = useState<PluginInstallMode>("local");
1694 const [localSource, setLocalSource] = useState("");
1695 const [gitSource, setGitSource] = useState("");
1696 const [name, setName] = useState("");
1697 const [link, setLink] = useState(false);
1698 const [replace, setReplace] = useState(false);
1699 const [plan, setPlan] = useState<PluginInstallPlanView | null>(null);
1700 const [notice, setNotice] = useState<string | null>(null);
1701 const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
1702 const [diagnostics, setDiagnostics] = useState<Record<string, PluginView>>({});
1703
1704 const reload = useCallback(async () => {
1705 const [meta, tabs] = await Promise.all([
1706 app.Meta().catch(() => null),
1707 app.ListTabs().catch(() => []),
1708 ]);
1709 const key = settingsSnapshotKey(meta, tabs);
1710 setSnapshotKey(key);
1711 const cached = key ? pluginsSettingsSnapshot : null;
1712 if (cached?.key === key) {
1713 setPlugins(cached.value);
1714 } else {
1715 setPlugins(null);
1716 }
1717 const next = normalizePluginViews(await app.Plugins().catch(() => []));
1718 pluginsSettingsSnapshot = { key, value: next };
1719 setPlugins(next);
1720 }, []);
1721 useEffect(() => { void reload(); }, [reload]);
1722
1723 const run = async (fn: () => Promise<unknown>, reloadAfter = true) => {
1724 setBusy(true);
1725 setErr(null);
1726 setNotice(null);
1727 try {
1728 const result = await fn();
1729 if (typeof result === "string" && result.trim()) {
1730 const parsed = parsePluginInstallPlan(result);
1731 setNotice(pluginPlanNotice(parsed, t));
1732 }
1733 if (reloadAfter) await reload();
1734 return true;
1735 } catch (e) {
1736 setErr(activeWorkBusyNoticeText(e, t) ?? String((e as Error)?.message ?? e));
1737 if (reloadAfter) await reload();
1738 return false;
1739 } finally {
1740 setBusy(false);
1741 }
1742 };
1743
1744 const sourceValue = (installMode === "local" ? localSource : gitSource).trim();
1745 const installOptions = (): PluginInstallOptions => ({
1746 dryRun: false,
1747 link: installMode === "local" ? link : false,
1748 replace,
1749 name: installMode === "git" ? name.trim() || undefined : undefined,
1750 });
1751 const actionBusy = busy || !snapshotKey || !plugins;
1752 const canPlan = sourceValue.length > 0 && !actionBusy;
1753 const summary = plugins ? pluginListSummary(plugins, t) : "";
1754 const togglePlugin = useCallback((pluginName: string) => {
1755 setExpanded((prev) => { const next = new Set(prev); if (next.has(pluginName)) next.delete(pluginName); else next.add(pluginName); return next; });
1756 }, []);
1757 const setMode = (mode: PluginInstallMode) => {
1758 setInstallMode(mode);
1759 setPlan(null);
1760 };
1761 const previewInstall = () => {
1762 if (!sourceValue) return;
1763 void run(async () => {
1764 const raw = await app.PlanPluginInstall(sourceValue, { ...installOptions(), dryRun: true });
1765 setPlan(parsePluginInstallPlan(raw));
1766 }, false);
1767 };
1768 const install = () => {
1769 if (!sourceValue) return;
1770 void run(async () => {
1771 const raw = await app.InstallPlugin(sourceValue, installOptions());
1772 setPlan(parsePluginInstallPlan(raw));
1773 return raw;
1774 });
1775 };
1776 const runDoctor = (pluginName: string) => {
1777 void run(async () => {
1778 const view = normalizePluginView(await app.PluginDoctor(pluginName));
1779 setDiagnostics((prev) => ({ ...prev, [pluginName]: view }));
1780 setExpanded((prev) => {
1781 const next = new Set(prev);
1782 next.add(pluginName);
1783 return next;
1784 });
1785 }, false);
1786 };
1787 const updateLocalSource = (value: string) => {
1788 setLocalSource(value);
1789 setPlan(null);
1790 };
1791 const updateGitSource = (value: string) => {
1792 setGitSource(value);
1793 setPlan(null);
1794 };
1795 const pickPluginFolder = () => {
1796 void run(async () => {
1797 const path = await app.PickPluginFolder();
1798 if (path) {
1799 setInstallMode("local");
1800 updateLocalSource(path);
1801 }
1802 }, false);
1803 };
1804
1805 return (
1806 <section className="mem-section">
1807 {err && <div className="banner banner--error">{err}</div>}
1808 {notice && !err && <div className="banner banner--success">{notice}</div>}
1809 <div className="settings-toolbar">
1810 <div><strong>{t("caps.installedPlugins")}</strong>{plugins && plugins.length > 0 && <div className="drawer__summary">{summary}</div>}</div>
1811 <div className="settings-toolbar__actions"><button className="btn btn--small" disabled={actionBusy} onClick={() => void reload()}>{t("caps.pluginRefresh")}</button>
1812 <button className="btn btn--primary" aria-expanded={installOpen} aria-controls="settings-plugin-install" disabled={actionBusy} onClick={() => setInstallOpen(!installOpen)}>{installOpen ? t("common.cancel") : t("caps.pluginInstall")}</button></div>
1813 </div>
1814 <div id="settings-plugin-install" hidden={!installOpen}>
1815 <div className="cap-plugin-installer">
1816 <div className="cap-plugin-installer__head">
1817 <div className="cap-plugin-installer__copy">
1818 <div className="cap-plugin-installer__title">{t("caps.pluginInstallTitle")}</div>
1819 <div className="cap-plugin-installer__hint">{t("caps.pluginInstallHint")}</div>
1820 </div>
1821 <div className="cap-tabs cap-plugin-installer__mode" role="group" aria-label={t("caps.pluginInstallMethod")}>
1822 <button
1823 className={`cap-tab${installMode === "local" ? " cap-tab--active" : ""}`}
1824 type="button"
1825 aria-pressed={installMode === "local"}
1826 onClick={() => setMode("local")}
1827 >
1828 {t("caps.pluginInstallLocal")}
1829 </button>
1830 <button
1831 className={`cap-tab${installMode === "git" ? " cap-tab--active" : ""}`}
1832 type="button"
1833 aria-pressed={installMode === "git"}
1834 onClick={() => setMode("git")}
1835 >
1836 {t("caps.pluginInstallGit")}
1837 </button>
1838 </div>
1839 </div>
1840 <div className="cap-plugin-form-grid">
1841 {installMode === "local" ? (
1842 <div className="cap-plugin-fields cap-plugin-fields--local">
1843 <div className="cap-plugin-folder-field">
1844 <button className="btn btn--small" disabled={actionBusy} type="button" onClick={pickPluginFolder}>
1845 {t("caps.pluginChooseLocalFolder")}
1846 </button>
1847 <div
1848 className={`cap-plugin-path${localSource ? "" : " cap-plugin-path--empty"}`}
1849 aria-label={t("caps.pluginLocalFolder")}
1850 >
1851 {localSource || t("caps.pluginNoLocalFolder")}
1852 </div>
1853 </div>
1854 </div>
1855 ) : (
1856 <div className="cap-plugin-fields cap-plugin-fields--git">
1857 <input
1858 className="mem-input"
1859 aria-label={t("caps.pluginGitSource")}
1860 placeholder={t("caps.pluginSourcePlaceholder")}
1861 value={gitSource}
1862 onInput={(e) => updateGitSource(e.currentTarget.value)}
1863 onChange={(e) => updateGitSource(e.target.value)}
1864 />
1865 <div className="cap-plugin-field">
1866 <input
1867 className="mem-input"
1868 aria-label={t("caps.pluginInstallName")}
1869 placeholder={t("caps.pluginInstallNamePlaceholder")}
1870 value={name}
1871 onChange={(e) => setName(e.target.value)}
1872 />
1873 </div>
1874 </div>
1875 )}
1876 <div className="cap-plugin-installer__options">
1877 <div className="cap-plugin-option-block">
1878 <label className="cap-plugin-option">
1879 <input type="checkbox" checked={replace} disabled={actionBusy} onChange={(e) => setReplace(e.target.checked)} />
1880 <span>{t("caps.pluginReplace")}</span>
1881 </label>
1882 <div className="cap-plugin-option-hint">{t("caps.pluginReplaceHint")}</div>
1883 </div>
1884 {installMode === "local" && (
1885 <div className="cap-plugin-option-block">
1886 <label className="cap-plugin-option">
1887 <input type="checkbox" checked={link} disabled={actionBusy} onChange={(e) => setLink(e.target.checked)} />
1888 <span>{t("caps.pluginLink")}</span>
1889 </label>
1890 <div className="cap-plugin-option-hint">{t("caps.pluginLinkHint")}</div>
1891 </div>
1892 )}
1893 </div>
1894 <div className="cap-plugin-installer__actions">
1895 <button className="btn btn--small" type="button" disabled={!canPlan} onClick={previewInstall}>
1896 {t("caps.pluginPreview")}
1897 </button>
1898 <button className="btn btn--primary btn--small" type="button" disabled={!canPlan} onClick={install}>
1899 {t("caps.pluginInstall")}
1900 </button>
1901 </div>
1902 </div>
1903 </div>
1904 {plan && <PluginPlanPreview plan={plan} />}
1905 </div>
1906 <div className="cap-server-section cap-plugin-section">
1907
1908 {!plugins ? (
1909 <div className="mem-empty">{t("caps.loading")}</div>
1910 ) : plugins.length === 0 ? (
1911 <div className="mem-empty mem-empty--cta">
1912 <strong>{t("caps.noPluginsTitle")}</strong>
1913 <span>{t("caps.noPluginsHint")}</span>
1914 </div>
1915 ) : (
1916 <div className="cap-server-group">
1917 {plugins.map((plugin) => (
1918 <PluginRow
1919 key={plugin.name}
1920 plugin={plugin}
1921 diagnostic={diagnostics[plugin.name]}
1922 busy={actionBusy}
1923 expanded={expanded.has(plugin.name)}
1924 onToggleDetails={() => togglePlugin(plugin.name)}
1925 onToggleEnabled={(enabled) => void run(() => app.SetPluginEnabled(plugin.name, enabled))}
1926 onUpdate={() => void run(() => app.UpdatePlugin(plugin.name))}
1927 onDoctor={() => runDoctor(plugin.name)}
1928 onRemove={() => void run(() => app.RemovePlugin(plugin.name))}
1929 />
1930 ))}
1931 </div>
1932 )}
1933 </div>
1934 </section>
1935 );
1936 }
1937
1938 function PluginPlanPreview({ plan }: { plan: PluginInstallPlanView }) {
1939 const t = useT();
1940 return (
1941 <div className={`cap-plugin-plan${plan.error ? " cap-plugin-plan--error" : ""}`}>
1942 <div className="cap-plugin-plan__head">
1943 <div className="cap-plugin-plan__title">{plan.error ? t("caps.pluginPlanError") : t("caps.pluginPlanReady")}</div>
1944 {plan.status && <span className="cap-source-badge">{plan.status}</span>}
1945 </div>
1946 {plan.name && <div className="cap-plugin-plan__meta">{plan.name}</div>}
1947 {plan.error && <div className="cap-plugin-plan__warning">{plan.error}</div>}
1948 {plan.warnings.map((warning, idx) => (
1949 <div className="cap-plugin-plan__warning" key={`${warning}-${idx}`}>{warning}</div>
1950 ))}
1951 {plan.actions.length > 0 ? (
1952 <div className="cap-plugin-actions">
1953 {plan.actions.map((action, idx) => (
1954 <div className="cap-plugin-action" key={`${action.action || action.kind || "action"}-${idx}`}>
1955 <span className="cap-plugin-action__name">{pluginPlanActionLabel(action, t)}</span>
1956 {action.status && <span className="cap-source-badge">{action.status}</span>}
1957 {action.compatibility && <span className="cap-source-badge">{pluginCompatibilityLabel(action.compatibility, t)}</span>}
1958 {action.source && <span className="cap-plugin-action__source">{action.source}</span>}
1959 {asArray(action.mappedCapabilities).length > 0 && <span className="cap-plugin-action__source">{t("caps.pluginMappedCapabilities", { capabilities: asArray(action.mappedCapabilities).join(", ") })}</span>}
1960 {asArray(action.skippedCapabilities).map((issue, issueIndex) => <span className="cap-plugin-plan__warning" key={`${issue.capability}-${issue.path || ""}-${issueIndex}`}>{issue.capability}: {issue.reason}</span>)}
1961 {action.message && <span className="cap-plugin-action__source">{action.message}</span>}
1962 {action.error && <span className="cap-plugin-plan__warning">{action.error}</span>}
1963 {action.runtime ? <PluginRuntimeTrustBlock runtime={action.runtime} /> : null}
1964 </div>
1965 ))}
1966 </div>
1967 ) : (
1968 <pre className="cap-plugin-plan__raw">{plan.raw}</pre>
1969 )}
1970 </div>
1971 );
1972 }
1973
1974 // PluginRuntimeTrustBlock renders the prominent FULL TRUST warning for a
1975 // plugin that declares a runtime process. Install/update/replace/--link
1976 // already imply full trust, so this is disclosure, not a second confirmation.
1977 function PluginRuntimeTrustBlock({ runtime }: { runtime: PluginRuntimePlan }) {
1978 const t = useT();
1979 const commandLine = [runtime.command, ...asArray(runtime.args)].filter(Boolean).join(" ");
1980 const groups: { label: string; values: string[] }[] = [
1981 { label: t("caps.pluginRuntimeIntercepts"), values: asArray(runtime.intercepts) },
1982 { label: t("caps.pluginRuntimeReplaces"), values: asArray(runtime.replaces) },
1983 { label: t("caps.pluginRuntimeCapabilities"), values: asArray(runtime.capabilities) },
1984 ];
1985 return (
1986 <div className="cap-plugin-runtime" role="alert">
1987 <div className="cap-plugin-runtime__title">{t("caps.pluginRuntimeFullTrust")}</div>
1988 {commandLine ? (
1989 <div className="cap-plugin-runtime__row">
1990 <span className="cap-plugin-runtime__label">{t("caps.pluginRuntimeCommand")}</span>
1991 <code className="cap-plugin-runtime__cmd">{commandLine}</code>
1992 </div>
1993 ) : null}
1994 {groups
1995 .filter((group) => group.values.length > 0)
1996 .map((group) => (
1997 <div className="cap-plugin-runtime__row" key={group.label}>
1998 <span className="cap-plugin-runtime__label">{group.label}</span>
1999 <span>{group.values.join(", ")}</span>
2000 </div>
2001 ))}
2002 <div className="cap-plugin-runtime__risk">{t("caps.pluginRuntimeRisk")}</div>
2003 </div>
2004 );
2005 }
2006
2007 function PluginRow({
2008 plugin,
2009 diagnostic,
2010 busy,
2011 expanded,
2012 onToggleDetails,
2013 onToggleEnabled,
2014 onUpdate,
2015 onDoctor,
2016 onRemove,
2017 }: {
2018 plugin: PluginView;
2019 diagnostic?: PluginView;
2020 busy: boolean;
2021 expanded: boolean;
2022 onToggleDetails: () => void;
2023 onToggleEnabled: (enabled: boolean) => void;
2024 onUpdate: () => void;
2025 onDoctor: () => void;
2026 onRemove: () => void;
2027 }) {
2028 const t = useT();
2029 const status = plugin.error ? "failed" : plugin.enabled ? "connected" : "disabled";
2030 const warnings = pluginWarnings(plugin, diagnostic);
2031 const sub = plugin.error || pluginCapabilitiesSummary(plugin, t);
2032 return (
2033 <div className={`cap-server-entry cap-plugin-entry${plugin.enabled ? "" : " cap-server-entry--disabled"}`}>
2034 <Tooltip label={plugin.error} disabled={!plugin.error} fill block>
2035 <div className={`cap-row${plugin.enabled ? "" : " cap-row--disabled"}`}>
2036 <Tooltip label={expanded ? t("caps.collapseDetails") : t("caps.expandDetails")}>
2037 <button
2038 className="cap-disclosure"
2039 aria-expanded={expanded}
2040 type="button"
2041 onClick={onToggleDetails}
2042 >
2043 {expanded ? "⌄" : "›"}
2044 </button>
2045 </Tooltip>
2046 <span className={`cap-dot cap-dot--${status}`} />
2047 <div className="cap-row__text">
2048 <div className="cap-row__head">
2049 <span className="cap-row__name">{plugin.name}</span>
2050 {plugin.manifestKind && <span className="cap-row__transport">{plugin.manifestKind}</span>}
2051 {plugin.compatibility && <span className="cap-source-badge">{pluginCompatibilityLabel(plugin.compatibility, t)}</span>}
2052 {plugin.version && <span className="cap-source-badge">{plugin.version}</span>}
2053 {warnings.length > 0 && <span className="cap-row__update cap-row__update--error">{t("caps.pluginWarnings", { count: warnings.length })}</span>}
2054 </div>
2055 <div className="cap-row__sub">{sub}</div>
2056 </div>
2057 <div className="cap-row__actions">
2058 <Tooltip label={plugin.enabled ? t("caps.pluginDisable") : t("caps.pluginEnable")}>
2059 <label className="cap-switch">
2060 <input
2061 type="checkbox"
2062 checked={plugin.enabled}
2063 disabled={busy}
2064 onChange={(e) => onToggleEnabled(e.target.checked)}
2065 />
2066 <span className="cap-switch__track" />
2067 </label>
2068 </Tooltip>
2069 </div>
2070 </div>
2071 </Tooltip>
2072 {expanded && (
2073 <div className="cap-server-details">
2074 <div className="cap-detail-grid">
2075 <div className="cap-detail">
2076 <span className="cap-detail__label">{t("caps.status")}</span>
2077 <span className="cap-detail__value">{plugin.enabled ? t("caps.pluginEnabled") : t("caps.pluginDisabled")}</span>
2078 </div>
2079 {plugin.version && (
2080 <div className="cap-detail">
2081 <span className="cap-detail__label">{t("caps.pluginVersion")}</span>
2082 <span className="cap-detail__value">{plugin.version}</span>
2083 </div>
2084 )}
2085 {plugin.source && (
2086 <div className="cap-detail cap-detail--wide">
2087 <span className="cap-detail__label">{t("caps.pluginSource")}</span>
2088 <span className="cap-detail__code">{plugin.source}</span>
2089 </div>
2090 )}
2091 {plugin.root && (
2092 <div className="cap-detail cap-detail--wide">
2093 <span className="cap-detail__label">{t("caps.pluginRoot")}</span>
2094 <span className="cap-detail__code">{plugin.root}</span>
2095 </div>
2096 )}
2097 </div>
2098 {plugin.description && <div className="cap-plugin-description">{plugin.description}</div>}
2099 {asArray(plugin.mappedCapabilities).length > 0 && <div className="cap-plugin-description">{t("caps.pluginMappedCapabilities", { capabilities: asArray(plugin.mappedCapabilities).join(", ") })}</div>}
2100 <PluginUsageDetails plugin={plugin} />
2101 {asArray(plugin.skippedCapabilities).map((issue, idx) => (
2102 <div className="cap-source__warning" key={`${issue.capability}-${issue.path || ""}-${idx}`}>{t("caps.pluginSkippedCapability", { capability: issue.capability, reason: issue.reason })}</div>
2103 ))}
2104 {diagnostic?.error && <div className="cap-source__warning">{diagnostic.error}</div>}
2105 {warnings.map((warning, idx) => (
2106 <div className="cap-source__warning" key={`${plugin.name}-warning-${idx}`}>{warning}</div>
2107 ))}
2108 <div className="cap-detail-actions">
2109 <button className="btn btn--small" disabled={busy} type="button" onClick={onUpdate}>
2110 {t("caps.pluginUpdate")}
2111 </button>
2112 <button className="btn btn--small" disabled={busy} type="button" onClick={onDoctor}>
2113 {t("caps.pluginDoctor")}
2114 </button>
2115 <InlineConfirmButton
2116 label={t("caps.pluginRemove")}
2117 confirmLabel={t("caps.pluginConfirmRemove")}
2118 cancelLabel={t("common.cancel")}
2119 disabled={busy}
2120 danger
2121 onConfirm={onRemove}
2122 />
2123 </div>
2124 </div>
2125 )}
2126 </div>
2127 );
2128 }
2129
2130 function PluginUsageDetails({ plugin }: { plugin: PluginView }) {
2131 const t = useT();
2132 const skills = asArray(plugin.skillDetails);
2133 const agents = asArray(plugin.agentDetails);
2134 const commands = asArray(plugin.commandDetails);
2135 const hooks = asArray(plugin.hookDetails);
2136 const mcps = asArray(plugin.mcpServerDetails);
2137 const hasDetails = skills.length > 0 || agents.length > 0 || commands.length > 0 || hooks.length > 0 || mcps.length > 0;
2138 return (
2139 <div className="cap-plugin-usage">
2140 <div className="cap-plugin-usage__title">{t("caps.pluginUsageTitle")}</div>
2141 <div className="cap-plugin-usage__hint">
2142 {plugin.enabled ? t("caps.pluginUsageEnabledHint") : t("caps.pluginUsageDisabledHint")}
2143 </div>
2144 {hasDetails ? (
2145 <div className="cap-plugin-capabilities">
2146 {commands.length > 0 && <PluginCommandList commands={commands} />}
2147 {skills.length > 0 && <PluginSkillList skills={skills} />}
2148 {agents.length > 0 && <PluginAgentList agents={agents} />}
2149 {hooks.length > 0 && <PluginHookList hooks={hooks} />}
2150 {mcps.length > 0 && <PluginMCPList servers={mcps} />}
2151 </div>
2152 ) : (
2153 <div className="cap-plugin-usage__empty">{t("caps.pluginNoCapabilityDetails")}</div>
2154 )}
2155 </div>
2156 );
2157 }
2158
2159 function PluginAgentList({ agents }: { agents: PluginAgentView[] }) {
2160 const t = useT();
2161 return (
2162 <div className="cap-plugin-capability">
2163 <div className="cap-plugin-capability__head">{t("caps.pluginAgentsTitle")}</div>
2164 <div className="cap-plugin-capability__hint">{t("caps.pluginAgentsHint")}</div>
2165 <div className="cap-plugin-capability__list">
2166 {agents.map((agent) => (
2167 <div className="cap-plugin-capability__item" key={`${agent.name}-${agent.path || ""}`}>
2168 <div className="cap-plugin-capability__line">
2169 <span className="cap-plugin-capability__name">{agent.invocation || agent.name}</span>
2170 {agent.model && <span className="cap-source-badge">{agent.model}</span>}
2171 </div>
2172 <div className="cap-plugin-capability__desc">{agent.description || t("caps.pluginNoDescription")}</div>
2173 </div>
2174 ))}
2175 </div>
2176 </div>
2177 );
2178 }
2179
2180 function PluginCommandList({ commands }: { commands: PluginCommandView[] }) {
2181 const t = useT();
2182 return (
2183 <div className="cap-plugin-capability">
2184 <div className="cap-plugin-capability__head">{t("caps.pluginCommandsTitle")}</div>
2185 <div className="cap-plugin-capability__hint">{t("caps.pluginCommandsHint")}</div>
2186 <div className="cap-plugin-capability__list">
2187 {commands.map((command) => (
2188 <div className="cap-plugin-capability__item" key={`${command.name}-${command.path || command.invocation || ""}`}>
2189 <div className="cap-plugin-capability__line">
2190 <span className="cap-plugin-capability__name">{command.invocation || `/${command.name}`}</span>
2191 {command.argHint && <span className="cap-source-badge">{command.argHint}</span>}
2192 {command.shadowed && <span className="cap-source-badge">{t("caps.pluginCommandShadowed")}</span>}
2193 </div>
2194 <div className="cap-plugin-capability__desc">{command.description || t("caps.pluginNoDescription")}</div>
2195 {command.shadowed && (
2196 <div className="cap-plugin-capability__hint">
2197 {command.shadowedByPlugin
2198 ? t("caps.pluginCommandQualifiedOccupiedByPlugin", { plugin: command.shadowedByPlugin })
2199 : t("caps.pluginCommandQualifiedOccupiedByCustom")}
2200 </div>
2201 )}
2202 </div>
2203 ))}
2204 </div>
2205 </div>
2206 );
2207 }
2208
2209 function PluginSkillList({ skills }: { skills: PluginSkillView[] }) {
2210 const t = useT();
2211 return (
2212 <div className="cap-plugin-capability">
2213 <div className="cap-plugin-capability__head">{t("caps.pluginSkillsTitle")}</div>
2214 <div className="cap-plugin-capability__hint">{t("caps.pluginSkillsHint")}</div>
2215 <div className="cap-plugin-capability__list">
2216 {skills.map((skill) => (
2217 <div className="cap-plugin-capability__item" key={`${skill.name}-${skill.path || skill.invocation || ""}`}>
2218 <div className="cap-plugin-capability__line">
2219 <span className="cap-plugin-capability__name">{skill.invocation || `/${skill.name}`}</span>
2220 {skill.runAs && <span className="cap-source-badge">{skill.runAs}</span>}
2221 </div>
2222 <div className="cap-plugin-capability__desc">{skill.description || t("caps.pluginNoDescription")}</div>
2223 </div>
2224 ))}
2225 </div>
2226 </div>
2227 );
2228 }
2229
2230 function PluginHookList({ hooks }: { hooks: PluginHookView[] }) {
2231 const t = useT();
2232 return (
2233 <div className="cap-plugin-capability">
2234 <div className="cap-plugin-capability__head">{t("caps.pluginHooksTitle")}</div>
2235 <div className="cap-plugin-capability__hint">{t("caps.pluginHooksHint")}</div>
2236 <div className="cap-plugin-capability__list">
2237 {hooks.map((hook, idx) => {
2238 const target = hook.command || hook.contextFile || t("caps.pluginHookNoTarget");
2239 return (
2240 <div className="cap-plugin-capability__item" key={`${hook.event}-${hook.match || "*"}-${target}-${idx}`}>
2241 <div className="cap-plugin-capability__line">
2242 <span className="cap-plugin-capability__name">{hook.event}</span>
2243 <span className="cap-source-badge">{hook.match || "*"}</span>
2244 </div>
2245 <div className="cap-plugin-capability__desc">{hook.description || target}</div>
2246 </div>
2247 );
2248 })}
2249 </div>
2250 </div>
2251 );
2252 }
2253
2254 function PluginMCPList({ servers }: { servers: PluginMCPServerView[] }) {
2255 const t = useT();
2256 return (
2257 <div className="cap-plugin-capability">
2258 <div className="cap-plugin-capability__head">{t("caps.pluginMCPTitle")}</div>
2259 <div className="cap-plugin-capability__hint">{t("caps.pluginMCPHint")}</div>
2260 <div className="cap-plugin-capability__list">
2261 {servers.map((server) => (
2262 <div className="cap-plugin-capability__item" key={server.name}>
2263 <div className="cap-plugin-capability__line">
2264 <span className="cap-plugin-capability__name">{server.displayName || server.name}</span>
2265 {server.transport && <span className="cap-source-badge">{server.transport}</span>}
2266 <span className="cap-source-badge">{server.autoStart ? t("caps.pluginMCPAutoStart") : t("caps.pluginMCPOnDemand")}</span>
2267 </div>
2268 <div className="cap-plugin-capability__desc">{server.description || server.command || server.url || t("caps.pluginMCPNoTarget")}</div>
2269 </div>
2270 ))}
2271 </div>
2272 </div>
2273 );
2274 }
2275
2276 function normalizePluginViews(plugins: PluginView[] | null | undefined): PluginView[] {
2277 return sortPluginsForDisplay(asArray(plugins).map(normalizePluginView));
2278 }
2279
2280 function normalizePluginView(plugin: PluginView): PluginView {
2281 return {
2282 ...plugin,
2283 name: plugin.name || "plugin",
2284 root: plugin.root || "",
2285 enabled: Boolean(plugin.enabled),
2286 skills: Number.isFinite(plugin.skills) ? plugin.skills : 0,
2287 commands: Number.isFinite(plugin.commands) ? plugin.commands : 0,
2288 agents: Number.isFinite(plugin.agents) ? plugin.agents : 0,
2289 hooks: Number.isFinite(plugin.hooks) ? plugin.hooks : 0,
2290 mcpServers: Number.isFinite(plugin.mcpServers) ? plugin.mcpServers : 0,
2291 skillDetails: asArray(plugin.skillDetails),
2292 agentDetails: asArray(plugin.agentDetails),
2293 commandDetails: asArray(plugin.commandDetails),
2294 hookDetails: asArray(plugin.hookDetails),
2295 mcpServerDetails: asArray(plugin.mcpServerDetails),
2296 warnings: asArray(plugin.warnings),
2297 };
2298 }
2299
2300 function sortPluginsForDisplay(plugins: PluginView[]): PluginView[] {
2301 return [...plugins].sort((a, b) => {
2302 const priority = pluginDisplayPriority(a) - pluginDisplayPriority(b);
2303 if (priority !== 0) return priority;
2304 return a.name.localeCompare(b.name, undefined, { sensitivity: "base" });
2305 });
2306 }
2307
2308 function pluginDisplayPriority(plugin: PluginView): number {
2309 if (plugin.error) return 0;
2310 if (plugin.enabled) return 1;
2311 return 2;
2312 }
2313
2314 function pluginListSummary(plugins: PluginView[], t: ReturnType<typeof useT>): string {
2315 const enabled = plugins.filter((plugin) => plugin.enabled && !plugin.error).length;
2316 const issues = plugins.filter((plugin) => Boolean(plugin.error) || asArray(plugin.warnings).length > 0).length;
2317 return t("caps.pluginsSummary", { enabled, total: plugins.length, issues });
2318 }
2319
2320 function pluginCapabilitiesSummary(plugin: PluginView, t: ReturnType<typeof useT>): string {
2321 if (plugin.skills === 0 && (plugin.agents || 0) === 0 && (plugin.commands || 0) === 0 && plugin.hooks === 0 && plugin.mcpServers === 0) return t("caps.pluginNoCapabilities");
2322 return t("caps.pluginCounts", { skills: plugin.skills, agents: plugin.agents || 0, commands: plugin.commands || 0, hooks: plugin.hooks, mcps: plugin.mcpServers });
2323 }
2324
2325 function pluginCompatibilityLabel(status: string, t: ReturnType<typeof useT>): string {
2326 if (status === "full") return t("caps.pluginCompatibilityFull");
2327 if (status === "partial") return t("caps.pluginCompatibilityPartial");
2328 if (status === "none") return t("caps.pluginCompatibilityNone");
2329 return status;
2330 }
2331
2332 function pluginWarnings(plugin: PluginView, diagnostic?: PluginView): string[] {
2333 const warnings = [...asArray(plugin.warnings), ...asArray(diagnostic?.warnings)];
2334 return Array.from(new Set(warnings.filter((warning) => warning.trim().length > 0)));
2335 }
2336
2337 function parsePluginInstallPlan(raw: string): PluginInstallPlanView {
2338 try {
2339 const value = JSON.parse(raw) as Record<string, unknown>;
2340 const actions = (Array.isArray(value.actions) ? value.actions : []).flatMap((action) => {
2341 if (!action || typeof action !== "object") return [];
2342 const item = action as Record<string, unknown>;
2343 return [{
2344 action: stringValue(item.action),
2345 kind: stringValue(item.kind),
2346 name: stringValue(item.name),
2347 source: stringValue(item.source),
2348 status: stringValue(item.status),
2349 message: stringValue(item.message),
2350 error: stringValue(item.error),
2351 compatibility: stringValue(item.compatibility),
2352 mappedCapabilities: (Array.isArray(item.mappedCapabilities) ? item.mappedCapabilities : []).filter((value): value is string => typeof value === "string"),
2353 skippedCapabilities: (Array.isArray(item.skippedCapabilities) ? item.skippedCapabilities : []) as PluginCompatibilityIssue[],
2354 runtime: parsePluginRuntimePlan(item.runtime),
2355 agentCount: numericValue(item.agentCount), skillCount: numericValue(item.skillCount), commandCount: numericValue(item.commandCount), hookCount: numericValue(item.hookCount), toolCount: numericValue(item.toolCount),
2356 }];
2357 });
2358 return {
2359 raw,
2360 ok: typeof value.ok === "boolean" ? value.ok : undefined,
2361 status: stringValue(value.status),
2362 name: stringValue(value.name),
2363 actions,
2364 warnings: (Array.isArray(value.warnings) ? value.warnings : []).flatMap((warning) => typeof warning === "string" ? [warning] : []),
2365 error: stringValue(value.error),
2366 };
2367 } catch {
2368 return { raw, actions: [], warnings: [] };
2369 }
2370 }
2371
2372 function numericValue(value: unknown): number | undefined {
2373 return typeof value === "number" && Number.isFinite(value) ? value : undefined;
2374 }
2375
2376 function stringValue(value: unknown): string | undefined {
2377 return typeof value === "string" && value.trim() ? value.trim() : undefined;
2378 }
2379
2380 // parsePluginRuntimePlan extracts the FULL TRUST runtime block a plugin
2381 // install plan carries (installsource.RuntimePlanInfo). Anything malformed
2382 // simply drops out — the risk UI is additive and must never break planning.
2383 function parsePluginRuntimePlan(value: unknown): PluginRuntimePlan | undefined {
2384 if (!value || typeof value !== "object") return undefined;
2385 const item = value as Record<string, unknown>;
2386 const command = stringValue(item.command);
2387 if (!command) return undefined;
2388 const list = (v: unknown): string[] => (Array.isArray(v) ? v : []).filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0);
2389 return {
2390 command,
2391 args: list(item.args),
2392 intercepts: list(item.intercepts),
2393 replaces: list(item.replaces),
2394 capabilities: list(item.capabilities),
2395 fullTrust: item.fullTrust === true,
2396 };
2397 }
2398
2399 function pluginPlanActionLabel(action: PluginInstallPlanAction, t: ReturnType<typeof useT>): string {
2400 const verb = action.action || action.kind || t("caps.pluginAction");
2401 return [verb, action.name].filter(Boolean).join(" · ");
2402 }
2403
2404 function pluginPlanNotice(plan: PluginInstallPlanView, t: ReturnType<typeof useT>): string {
2405 if (plan.error) return plan.error;
2406 if (plan.status === "done" || plan.status === "applied" || plan.status === "complete") return t("caps.pluginPlanInstalled");
2407 return plan.status ? t("caps.pluginPlanStatus", { status: plan.status }) : t("caps.pluginPlanComplete");
2408 }
2409
2410 type MCPSettingsScreen =
2411 | { kind: "list" }
2412 | { kind: "add" }
2413 | { kind: "marketplace" }
2414 | { kind: "detail"; name: string }
2415 | { kind: "edit"; name: string };
2416
2417 type MCPServerEditorDraft = {
2418 name: string;
2419 transport: string;
2420 command: string;
2421 structuredCommand?: {
2422 display: string;
2423 command: string;
2424 args: string[];
2425 };
2426 url: string;
2427 env: string;
2428 headers: string;
2429 autoStart?: boolean;
2430 callTimeoutSeconds?: number;
2431 toolTimeoutSeconds?: Record<string, number>;
2432 };
2433
2434 type MCPServerJSONError = "invalid" | "single" | "name" | "required" | "unsupported";
2435
2436 function mcpServerSchemaIssueCount(server: ServerView): number {
2437 return (server.toolList ?? []).filter((tool) => tool.schemaError).length;
2438 }
2439
2440 function mcpSettingsServerSummary(server: ServerView, t: ReturnType<typeof useT>): string {
2441 if (server.status === "failed") {
2442 return server.authStatus === "required" ? t("caps.authRequiredSummary") : summarizeServerError(server.error || t("caps.failed"));
2443 }
2444 if (server.status !== "connected") return serverStatusLabel(server, t);
2445 const unavailable = mcpServerSchemaIssueCount(server);
2446 const parts = [mcpSessionStateLabel(server, t, serverStatusLabel(server, t)), t("caps.serverToolSummary", { tools: server.tools || 0 })];
2447 if (unavailable > 0) parts.push(t("caps.schemaIssues", { count: unavailable }));
2448 return parts.join(" · ");
2449 }
2450
2451 function mcpServerSourceLabel(server: ServerView, t: ReturnType<typeof useT>): string {
2452 switch (server.source) {
2453 case "project":
2454 return server.configSource
2455 ? t("caps.sourceProjectConfig", { config: server.configSource })
2456 : t("caps.sourceProject");
2457 case "plugin":
2458 return t("caps.sourcePlugin");
2459 case "builtin":
2460 return t("caps.sourceBuiltin");
2461 default:
2462 return t("caps.sourceUser");
2463 }
2464 }
2465
2466 function MCPSettingsSubpageHeader({
2467 title,
2468 description,
2469 onBack,
2470 }: {
2471 title: string;
2472 description: string;
2473 onBack: () => void;
2474 }) {
2475 const t = useT();
2476 return (
2477 <header className="cap-mcp-subpage__header">
2478 <button className="cap-mcp-subpage__back" type="button" onClick={onBack}>
2479 <ArrowLeft aria-hidden size={14} />
2480 {t("caps.backToServers")}
2481 </button>
2482 <h3 className="cap-mcp-subpage__title">{title}</h3>
2483 <p className="cap-mcp-subpage__desc">{description}</p>
2484 </header>
2485 );
2486 }
2487
2488 function MCPSettingsServerRow({
2489 server,
2490 busy,
2491 onOpen,
2492 onRetry,
2493 onToggle,
2494 onRemove,
2495 }: {
2496 server: ServerView;
2497 busy: boolean;
2498 onOpen: () => void;
2499 onRetry: () => void;
2500 onToggle: (enabled: boolean) => void;
2501 onRemove: () => void;
2502 }) {
2503 const t = useT();
2504 const lifecycle = mcpServerLifecycleActions(server);
2505 const target = serverCommand(server);
2506 const actionLabel = serverActionLabel(server, t);
2507 const canRemove = server.configured && !server.builtIn && !server.managedByPlugin;
2508 const handlePrimaryAction = () => {
2509 onRetry();
2510 };
2511
2512 return (
2513 <div className={`cap-mcp-list-row${server.status === "disabled" ? " cap-mcp-list-row--disabled" : ""}`} data-status={server.status}>
2514 <button className="cap-mcp-list-row__main" type="button" onClick={onOpen}>
2515 <span className="cap-mcp-list-row__icon" aria-hidden>
2516 <ServerIcon size={16} strokeWidth={1.8} />
2517 </span>
2518 <span className="cap-mcp-list-row__copy">
2519 <span className="cap-mcp-list-row__head">
2520 <span className={`cap-dot cap-dot--${server.status}`} aria-hidden />
2521 <span className="cap-mcp-list-row__name">{server.name}</span>
2522 <span className="cap-mcp-list-row__transport">{server.transport}</span>
2523 {server.source === "project" && <span className="cap-row__builtin">{t("caps.projectServerBadge")}</span>}
2524 {server.builtIn && <span className="cap-row__builtin">{t("caps.builtIn")}</span>}
2525 </span>
2526 <span className={`cap-mcp-list-row__summary${server.status === "failed" ? " cap-mcp-list-row__summary--error" : ""}`}>
2527 {mcpSettingsServerSummary(server, t)}
2528 </span>
2529 {target && <span className="cap-mcp-list-row__target">{target}</span>}
2530 {server.managedByPlugin && (
2531 <span className="cap-mcp-list-row__owner">{t("caps.managedByPlugin", { plugin: server.managedByPlugin })}</span>
2532 )}
2533 </span>
2534 <ChevronRight className="cap-mcp-list-row__chevron" aria-hidden size={16} />
2535 </button>
2536 <div className="cap-mcp-list-row__actions">
2537 {canRemove && (
2538 <InlineConfirmButton
2539 label={t("caps.remove")}
2540 confirmLabel={t("caps.confirmRemove")}
2541 cancelLabel={t("common.cancel")}
2542 disabled={busy}
2543 danger
2544 onConfirm={onRemove}
2545 />
2546 )}
2547 {lifecycle.showRetryInRow ? (
2548 <button className="btn btn--small" disabled={busy} type="button" onClick={handlePrimaryAction}>
2549 {actionLabel}
2550 </button>
2551 ) : !server.managedByPlugin ? (
2552 <Tooltip label={lifecycle.enabled ? t("caps.disable") : t("caps.enable")}>
2553 <label className="cap-switch">
2554 <input
2555 type="checkbox"
2556 checked={lifecycle.enabled}
2557 disabled={busy}
2558 onChange={(event) => onToggle(event.target.checked)}
2559 />
2560 <span className="cap-switch__track" />
2561 </label>
2562 </Tooltip>
2563 ) : null}
2564 </div>
2565 </div>
2566 );
2567 }
2568
2569 function MCPSettingsServerGroup({
2570 title,
2571 hint,
2572 servers,
2573 busy,
2574 onOpen,
2575 onRetry,
2576 onToggle,
2577 onRemove,
2578 }: {
2579 title: string;
2580 hint?: string;
2581 servers: ServerView[];
2582 busy: boolean;
2583 onOpen: (name: string) => void;
2584 onRetry: (name: string) => void;
2585 onToggle: (name: string, enabled: boolean) => void;
2586 onRemove: (name: string) => void;
2587 }) {
2588 if (servers.length === 0) return null;
2589 return (
2590 <section className="cap-mcp-list-section">
2591 <div className="cap-mcp-list-section__head">
2592 <div>
2593 <div className="cap-mcp-list-section__title">{title} <span>{servers.length}</span></div>
2594 {hint && <div className="cap-mcp-list-section__hint">{hint}</div>}
2595 </div>
2596 </div>
2597 <div className="cap-mcp-list">
2598 {servers.map((server) => (
2599 <MCPSettingsServerRow
2600 key={server.name}
2601 server={server}
2602 busy={busy}
2603 onOpen={() => onOpen(server.name)}
2604 onRetry={() => onRetry(server.name)}
2605 onToggle={(enabled) => onToggle(server.name, enabled)}
2606 onRemove={() => onRemove(server.name)}
2607 />
2608 ))}
2609 </div>
2610 </section>
2611 );
2612 }
2613
2614 function mcpServerEditorDraft(server?: ServerView): MCPServerEditorDraft {
2615 const transport = normalizeTransportValue(server?.transport || "stdio");
2616 const command = server && transport === "stdio" ? serverCommand(server) : "";
2617 return {
2618 name: server?.name || "",
2619 transport,
2620 command,
2621 structuredCommand: server && transport === "stdio" ? {
2622 display: command,
2623 command: server.command || "",
2624 args: [...(server.args ?? [])],
2625 } : undefined,
2626 url: server && transport !== "stdio" ? server.url || serverCommand(server) : "",
2627 env: "",
2628 headers: "",
2629 autoStart: server?.autoStart,
2630 callTimeoutSeconds: server?.callTimeoutSeconds,
2631 toolTimeoutSeconds: server?.toolTimeoutSeconds ? { ...server.toolTimeoutSeconds } : undefined,
2632 };
2633 }
2634
2635 function mcpServerInputDraft(input: MCPServerInput): MCPServerEditorDraft {
2636 const transport = normalizeTransportValue(input.transport);
2637 const command = transport === "stdio" ? [input.command, ...input.args].filter(Boolean).join(" ").trim() : "";
2638 return {
2639 name: input.name,
2640 transport,
2641 command,
2642 structuredCommand: transport === "stdio" ? {
2643 display: command,
2644 command: input.command,
2645 args: [...input.args],
2646 } : undefined,
2647 url: transport === "stdio" ? "" : input.url,
2648 env: input.env ? Object.entries(input.env).map(([key, value]) => `${key}=${value}`).join("\n") : "",
2649 headers: input.headers ? Object.entries(input.headers).map(([key, value]) => `${key}=${value}`).join("\n") : "",
2650 autoStart: input.autoStart ?? undefined,
2651 callTimeoutSeconds: input.callTimeoutSeconds ?? undefined,
2652 toolTimeoutSeconds: input.toolTimeoutSeconds ? { ...input.toolTimeoutSeconds } : undefined,
2653 };
2654 }
2655
2656 function mcpServerDraftInput(draft: MCPServerEditorDraft): MCPServerInput {
2657 const isStdio = draft.transport === "stdio";
2658 const structuredCommand = draft.structuredCommand?.display === draft.command ? draft.structuredCommand : undefined;
2659 const envText = draft.env.trim();
2660 const headerText = draft.headers.trim();
2661 return {
2662 name: draft.name.trim(),
2663 transport: draft.transport,
2664 command: isStdio ? structuredCommand?.command || draft.command.trim() : "",
2665 args: isStdio ? structuredCommand?.args ?? [] : [],
2666 url: isStdio ? "" : draft.url.trim(),
2667 env: envText ? parseKeyValueText(envText) : null,
2668 headers: !isStdio && headerText ? parseKeyValueText(headerText) : null,
2669 autoStart: draft.autoStart ?? null,
2670 callTimeoutSeconds: draft.callTimeoutSeconds ?? null,
2671 toolTimeoutSeconds: draft.toolTimeoutSeconds ?? null,
2672 };
2673 }
2674
2675 function mcpMarketplaceServerInput(entry: MCPMarketplaceEntry, servers: ServerView[]): MCPServerInput {
2676 const used = new Set(servers.map((server) => server.name));
2677 const base = entry.suggestedName || entry.name.split("/").filter(Boolean).pop() || "mcp-server";
2678 let name = base;
2679 for (let suffix = 2; used.has(name); suffix += 1) name = `${base}-${suffix}`;
2680 const transport = entry.transport || "stdio";
2681 return {
2682 name,
2683 transport,
2684 command: transport === "stdio" ? entry.command || "" : "",
2685 args: transport === "stdio" ? [...(entry.args ?? [])] : [],
2686 url: transport === "stdio" ? "" : entry.url || "",
2687 env: null,
2688 headers: null,
2689 autoStart: null,
2690 callTimeoutSeconds: null,
2691 toolTimeoutSeconds: null,
2692 };
2693 }
2694
2695 export function mcpServerDraftJSON(draft: MCPServerEditorDraft): string {
2696 const input = mcpServerDraftInput(draft);
2697 const entry: Record<string, unknown> = { type: input.transport };
2698 if (input.transport === "stdio") {
2699 entry.command = input.command;
2700 if (input.args.length > 0) entry.args = input.args;
2701 }
2702 else entry.url = input.url;
2703 if (input.env && Object.keys(input.env).length > 0) entry.env = input.env;
2704 if (input.headers && Object.keys(input.headers).length > 0) entry.headers = input.headers;
2705 if (input.autoStart != null) entry.auto_start = input.autoStart;
2706 if (input.callTimeoutSeconds != null) entry.call_timeout_seconds = input.callTimeoutSeconds;
2707 if (input.toolTimeoutSeconds && Object.keys(input.toolTimeoutSeconds).length > 0) entry.tool_timeout_seconds = input.toolTimeoutSeconds;
2708 return JSON.stringify({ [input.name || "server-name"]: entry }, null, 2);
2709 }
2710
2711 function isRecord(value: unknown): value is Record<string, unknown> {
2712 return Boolean(value) && typeof value === "object" && !Array.isArray(value);
2713 }
2714
2715 function stringRecord(value: unknown): Record<string, string> | null {
2716 if (value == null) return null;
2717 if (!isRecord(value) || Object.values(value).some((item) => typeof item !== "string")) throw new Error("invalid");
2718 return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, item as string]));
2719 }
2720
2721 function assertSupportedKeys(value: Record<string, unknown>, supported: readonly string[]) {
2722 const allowed = new Set(supported);
2723 if (Object.keys(value).some((key) => !allowed.has(key))) throw new Error("unsupported" satisfies MCPServerJSONError);
2724 }
2725
2726 function nonNegativeInteger(value: unknown): number | undefined {
2727 if (value == null) return undefined;
2728 if (typeof value !== "number" || !Number.isInteger(value) || value < 0) throw new Error("invalid" satisfies MCPServerJSONError);
2729 return value;
2730 }
2731
2732 function nonNegativeIntegerRecord(value: unknown): Record<string, number> | undefined {
2733 if (value == null) return undefined;
2734 if (!isRecord(value)) throw new Error("invalid" satisfies MCPServerJSONError);
2735 const out: Record<string, number> = {};
2736 for (const [name, item] of Object.entries(value)) {
2737 if (!name.trim()) throw new Error("invalid" satisfies MCPServerJSONError);
2738 const seconds = nonNegativeInteger(item);
2739 if (seconds === undefined) throw new Error("invalid" satisfies MCPServerJSONError);
2740 out[name] = seconds;
2741 }
2742 return out;
2743 }
2744
2745 // withExplicitMCPClears finalizes an edit of an existing server. The editor
2746 // seeds every non-secret setting into the draft/JSON, so a field the user
2747 // removed must clear the persisted value instead of being preserved as
2748 // "absent". env/headers stay preserve-on-absent because their values are
2749 // deliberately never seeded into the editor.
2750 export function withExplicitMCPClears(input: MCPServerInput): MCPServerInput {
2751 return {
2752 ...input,
2753 autoStart: input.autoStart ?? true,
2754 callTimeoutSeconds: input.callTimeoutSeconds ?? 0,
2755 toolTimeoutSeconds: input.toolTimeoutSeconds ?? {},
2756 };
2757 }
2758
2759 export function parseMCPServerJSON(raw: string, fixedName?: string, options?: { allowIncomplete?: boolean }): { input: MCPServerInput; draft: MCPServerEditorDraft } {
2760 let parsed: unknown;
2761 try {
2762 parsed = JSON.parse(raw);
2763 } catch {
2764 throw new Error("invalid" satisfies MCPServerJSONError);
2765 }
2766 if (!isRecord(parsed)) throw new Error("single" satisfies MCPServerJSONError);
2767 if (isRecord(parsed.mcpServers)) assertSupportedKeys(parsed, ["mcpServers"]);
2768 const container = isRecord(parsed.mcpServers) ? parsed.mcpServers : parsed;
2769 const entries = Object.entries(container);
2770 if (entries.length !== 1) throw new Error("single" satisfies MCPServerJSONError);
2771 const [name, value] = entries[0];
2772 if (!name.trim() || !isRecord(value)) throw new Error("single" satisfies MCPServerJSONError);
2773 assertSupportedKeys(value, [
2774 "type", "transport", "command", "args", "url", "env", "headers", "auto_start",
2775 "call_timeout_seconds", "tool_timeout_seconds", "trusted_read_only_tools",
2776 "default_tools_approval_mode", "tools", "approvals_reviewer",
2777 ]);
2778 if (fixedName && name !== fixedName) throw new Error("name" satisfies MCPServerJSONError);
2779 if (value.type != null && typeof value.type !== "string") throw new Error("invalid" satisfies MCPServerJSONError);
2780 if (value.transport != null && typeof value.transport !== "string") throw new Error("invalid" satisfies MCPServerJSONError);
2781 if (value.type != null && value.transport != null) throw new Error("unsupported" satisfies MCPServerJSONError);
2782 const transportValue = typeof value.type === "string" ? value.type : value.transport;
2783 const transport = normalizeTransportValue(typeof transportValue === "string" ? transportValue : (typeof value.url === "string" ? "http" : "stdio"));
2784 if (transport !== "stdio" && transport !== "http" && transport !== "sse") throw new Error("invalid" satisfies MCPServerJSONError);
2785 if (transport === "stdio" && (value.url != null || value.headers != null)) throw new Error("unsupported" satisfies MCPServerJSONError);
2786 if (transport !== "stdio" && (value.command != null || value.args != null)) throw new Error("unsupported" satisfies MCPServerJSONError);
2787 const command = typeof value.command === "string" ? value.command.trim() : "";
2788 if (value.args != null && (!Array.isArray(value.args) || !value.args.every((arg) => typeof arg === "string"))) throw new Error("invalid" satisfies MCPServerJSONError);
2789 const args = value.args ? value.args as string[] : [];
2790 const url = typeof value.url === "string" ? value.url.trim() : "";
2791 if (!options?.allowIncomplete && ((transport === "stdio" && !command) || (transport !== "stdio" && !url))) {
2792 throw new Error("required" satisfies MCPServerJSONError);
2793 }
2794 let env: Record<string, string> | null;
2795 let headers: Record<string, string> | null;
2796 try {
2797 env = stringRecord(value.env);
2798 headers = stringRecord(value.headers);
2799 } catch {
2800 throw new Error("invalid" satisfies MCPServerJSONError);
2801 }
2802 if (value.auto_start != null && typeof value.auto_start !== "boolean") throw new Error("invalid" satisfies MCPServerJSONError);
2803 const autoStart = value.auto_start as boolean | undefined;
2804 const callTimeoutSeconds = nonNegativeInteger(value.call_timeout_seconds);
2805 const toolTimeoutSeconds = nonNegativeIntegerRecord(value.tool_timeout_seconds);
2806 const input: MCPServerInput = {
2807 name: fixedName || name,
2808 transport,
2809 command: transport === "stdio" ? command : "",
2810 args: transport === "stdio" ? args : [],
2811 url: transport === "stdio" ? "" : url,
2812 env,
2813 headers: transport === "stdio" ? null : headers,
2814 autoStart: autoStart ?? null,
2815 callTimeoutSeconds: callTimeoutSeconds ?? null,
2816 toolTimeoutSeconds: toolTimeoutSeconds ?? null,
2817 };
2818 return {
2819 input,
2820 draft: {
2821 name: input.name,
2822 transport,
2823 command: [command, ...args].filter(Boolean).join(" "),
2824 structuredCommand: transport === "stdio" ? {
2825 display: [command, ...args].filter(Boolean).join(" "),
2826 command,
2827 args: [...args],
2828 } : undefined,
2829 url,
2830 env: env ? Object.entries(env).map(([key, item]) => `${key}=${item}`).join("\n") : "",
2831 headers: headers ? Object.entries(headers).map(([key, item]) => `${key}=${item}`).join("\n") : "",
2832 autoStart,
2833 callTimeoutSeconds,
2834 toolTimeoutSeconds,
2835 },
2836 };
2837 }
2838
2839 function mcpServerJSONErrorLabel(error: unknown, t: ReturnType<typeof useT>): string {
2840 const code = error instanceof Error ? error.message as MCPServerJSONError : "invalid";
2841 if (code === "single") return t("caps.jsonSingleServer");
2842 if (code === "name") return t("caps.jsonNameMismatch");
2843 if (code === "required") return t("caps.jsonRequired");
2844 if (code === "unsupported") return t("caps.jsonUnsupported");
2845 return t("caps.jsonInvalid");
2846 }
2847
2848 function MCPServerSettingsEditor({
2849 server,
2850 busy,
2851 onCancel,
2852 onSubmit,
2853 }: {
2854 server?: ServerView;
2855 busy: boolean;
2856 onCancel: () => void;
2857 onSubmit: (input: MCPServerInput) => void;
2858 }) {
2859 const t = useT();
2860 type EditorMode = "quick" | "form" | "json";
2861 const [mode, setMode] = useState<EditorMode>(server ? "form" : "quick");
2862 const [definition, setDefinition] = useState("");
2863 const [quickError, setQuickError] = useState("");
2864 const [draft, setDraft] = useState<MCPServerEditorDraft>(() => mcpServerEditorDraft(server));
2865 const [json, setJSON] = useState(() => mcpServerDraftJSON(mcpServerEditorDraft(server)));
2866 const [jsonError, setJSONError] = useState("");
2867 const [advancedOpen, setAdvancedOpen] = useState(false);
2868 const isStdio = draft.transport === "stdio";
2869 const ready = Boolean(draft.name.trim() && (isStdio ? draft.command.trim() : draft.url.trim()));
2870
2871 const updateDraft = (patch: Partial<MCPServerEditorDraft>) => setDraft((current) => ({ ...current, ...patch }));
2872 const switchMode = (next: EditorMode) => {
2873 if (next === mode) return;
2874 if (next === "quick") {
2875 setQuickError("");
2876 setMode("quick");
2877 return;
2878 }
2879 if (mode === "quick") {
2880 if (definition.trim()) {
2881 try {
2882 const nextDraft = mcpServerInputDraft(parseMCPQuickDefinition(definition));
2883 setDraft(nextDraft);
2884 if (next === "json") setJSON(mcpServerDraftJSON(nextDraft));
2885 setQuickError("");
2886 } catch (error) {
2887 setQuickError(mcpServerJSONErrorLabel(error, t));
2888 return;
2889 }
2890 }
2891 setMode(next);
2892 return;
2893 }
2894 if (next === "json") {
2895 setJSON(mcpServerDraftJSON(draft));
2896 setJSONError("");
2897 setMode("json");
2898 return;
2899 }
2900 if (json === mcpServerDraftJSON(draft)) {
2901 setJSONError("");
2902 setMode("form");
2903 return;
2904 }
2905 try {
2906 const parsed = parseMCPServerJSON(json, server?.name, { allowIncomplete: true });
2907 setDraft(parsed.draft);
2908 setJSONError("");
2909 setMode("form");
2910 } catch (error) {
2911 setJSONError(mcpServerJSONErrorLabel(error, t));
2912 }
2913 };
2914 const finalize = (input: MCPServerInput) => (server ? withExplicitMCPClears(input) : input);
2915 const submit = () => {
2916 if (mode === "quick") {
2917 try {
2918 setQuickError("");
2919 onSubmit(parseMCPQuickDefinition(definition));
2920 } catch (error) {
2921 setQuickError(mcpServerJSONErrorLabel(error, t));
2922 }
2923 return;
2924 }
2925 if (mode === "form") {
2926 onSubmit(finalize(mcpServerDraftInput(draft)));
2927 return;
2928 }
2929 try {
2930 const parsed = parseMCPServerJSON(json, server?.name);
2931 setJSONError("");
2932 onSubmit(finalize(parsed.input));
2933 } catch (error) {
2934 setJSONError(mcpServerJSONErrorLabel(error, t));
2935 }
2936 };
2937
2938 return (
2939 <div className="cap-mcp-editor">
2940 <SettingsOptions className="cap-mcp-editor__mode set-seg" role="tablist" aria-label={t("caps.editorMode")}>
2941 {!server && (
2942 <button className={`set-seg__btn${mode === "quick" ? " set-seg__btn--on" : ""}`} type="button" role="tab" aria-selected={mode === "quick"} onClick={() => switchMode("quick")}>
2943 {t("caps.quickMode")}
2944 </button>
2945 )}
2946 <button className={`set-seg__btn${mode === "form" ? " set-seg__btn--on" : ""}`} type="button" role="tab" aria-selected={mode === "form"} onClick={() => switchMode("form")}>
2947 {t("caps.formMode")}
2948 </button>
2949 <button className={`set-seg__btn${mode === "json" ? " set-seg__btn--on" : ""}`} type="button" role="tab" aria-selected={mode === "json"} onClick={() => switchMode("json")}>
2950 {t("caps.jsonMode")}
2951 </button>
2952 </SettingsOptions>
2953 {mode === "quick" ? (
2954 <div className="cap-mcp-quick">
2955 <label className="cap-mcp-field">
2956 <span>{t("caps.installDefinition")}</span>
2957 <textarea
2958 className="mem-textarea cap-mcp-quick__input"
2959 value={definition}
2960 disabled={busy}
2961 onChange={(event) => { setDefinition(event.target.value); setQuickError(""); }}
2962 placeholder={t("caps.installDefinitionPlaceholder")}
2963 spellCheck={false}
2964 />
2965 </label>
2966 <div className="cap-mcp-quick__hint">{t("caps.installDefinitionHint")}</div>
2967 <div className="cap-mcp-quick__benefits" aria-label={t("caps.quickBenefitsLabel")}>
2968 <span>{t("caps.quickDetectTransport")}</span>
2969 <span>{t("caps.quickVerifyConnection")}</span>
2970 <span>{t("caps.quickEnableTools")}</span>
2971 </div>
2972 {quickError && <div className="banner banner--error" role="alert">{quickError}</div>}
2973 </div>
2974 ) : mode === "form" ? (
2975 <div className="cap-mcp-form-grid">
2976 <label className="cap-mcp-field cap-mcp-field--name">
2977 <span>{t("caps.name")}</span>
2978 <input className="mem-input" value={draft.name} disabled={busy || Boolean(server)} onChange={(event) => updateDraft({ name: event.target.value })} placeholder={t("caps.namePlaceholder")} />
2979 </label>
2980 <label className="cap-mcp-field cap-mcp-field--transport">
2981 <span>{t("caps.transport")}</span>
2982 <SettingsSelect className="mem-select" value={draft.transport} disabled={busy} onValueChange={(value) => updateDraft({ transport: normalizeTransportValue(value) })}>
2983 <option value="stdio">stdio</option>
2984 <option value="http">http</option>
2985 <option value="sse">sse</option>
2986 </SettingsSelect>
2987 </label>
2988 {isStdio ? (
2989 <label className="cap-mcp-field cap-mcp-field--wide">
2990 <span>{t("caps.command")}</span>
2991 <input className="mem-input" value={draft.command} disabled={busy} onChange={(event) => updateDraft({ command: event.target.value })} placeholder={t("caps.commandPlaceholder")} />
2992 </label>
2993 ) : (
2994 <label className="cap-mcp-field cap-mcp-field--wide">
2995 <span>{t("caps.url")}</span>
2996 <input className="mem-input" value={draft.url} disabled={busy} onChange={(event) => updateDraft({ url: event.target.value })} placeholder={t("caps.urlPlaceholder")} />
2997 </label>
2998 )}
2999 <div className="cap-mcp-advanced cap-mcp-field--wide">
3000 <button className="cap-mcp-advanced__toggle" type="button" aria-expanded={advancedOpen} onClick={() => setAdvancedOpen((open) => !open)}>
3001 {advancedOpen ? <ChevronDown aria-hidden size={14} /> : <ChevronRight aria-hidden size={14} />}
3002 {advancedOpen ? t("caps.hideAdvancedOptions") : t("caps.advancedOptions")}
3003 </button>
3004 {advancedOpen && (
3005 <div className="cap-mcp-advanced__body">
3006 {!isStdio && (
3007 <label className="cap-mcp-field">
3008 <span>{t("caps.headersLabel")}</span>
3009 <textarea className="mem-textarea" value={draft.headers} disabled={busy} onChange={(event) => updateDraft({ headers: event.target.value })} placeholder={t("caps.headersPlaceholder")} spellCheck={false} />
3010 {server?.headerKeys && server.headerKeys.length > 0 && <small>{t("caps.headersPreserveHint")}</small>}
3011 </label>
3012 )}
3013 <label className="cap-mcp-field">
3014 <span>{t("caps.envLabel")}</span>
3015 <textarea className="mem-textarea" value={draft.env} disabled={busy} onChange={(event) => updateDraft({ env: event.target.value })} placeholder={t("caps.envPlaceholder")} spellCheck={false} />
3016 {server?.envKeys && server.envKeys.length > 0 && <small>{t("caps.envPreserveHint")}</small>}
3017 </label>
3018 </div>
3019 )}
3020 </div>
3021 </div>
3022 ) : (
3023 <div className="cap-mcp-json-editor">
3024 <label className="cap-mcp-field">
3025 <span>{t("caps.jsonConfig")}</span>
3026 <textarea className="mem-textarea cap-mcp-json-editor__input" value={json} disabled={busy} onInput={(event) => { setJSON(event.currentTarget.value); setJSONError(""); }} spellCheck={false} />
3027 </label>
3028 <div className="cap-mcp-json-editor__hint">{t("caps.jsonPasteHint")}</div>
3029 {jsonError && <div className="banner banner--error" role="alert">{jsonError}</div>}
3030 </div>
3031 )}
3032 <div className="cap-mcp-editor__actions">
3033 <button className="btn btn--small" disabled={busy} type="button" onClick={onCancel}>{t("common.cancel")}</button>
3034 <button className="btn btn--primary btn--small" disabled={busy || (mode === "quick" ? !definition.trim() : mode === "form" && !ready)} type="button" onClick={submit}>
3035 {server ? t("caps.saveConfig") : t("caps.addAndConnect")}
3036 </button>
3037 </div>
3038 </div>
3039 );
3040 }
3041
3042 // MCPServersSettingsPage is a self-contained MCP servers management page
3043 // embedded inside the settings centre.
3044 export function MCPServersSettingsPage() {
3045 const t = useT();
3046 const [snapshotKey, setSnapshotKey] = useState("");
3047 const [servers, setServers] = useState<ServerView[] | null>(null);
3048 const [busy, setBusy] = useState(false);
3049 const [err, setErr] = useState<string | null>(null);
3050 const [query, setQuery] = useState("");
3051 const [screen, setScreen] = useState<MCPSettingsScreen>({ kind: "list" });
3052 const [marketplace, setMarketplace] = useState<MCPMarketplaceView | null>(null);
3053 const [marketplaceQuery, setMarketplaceQuery] = useState("");
3054
3055 const reload = useCallback(async () => {
3056 const [meta, tabs] = await Promise.all([
3057 app.Meta().catch(() => null),
3058 app.ListTabs().catch(() => []),
3059 ]);
3060 const key = settingsSnapshotKey(meta, tabs);
3061 setSnapshotKey(key);
3062 const cached = key ? mcpSettingsSnapshot : null;
3063 if (cached?.key === key) {
3064 setServers(cached.value);
3065 } else {
3066 setServers(null);
3067 }
3068 const next = normalizeServerViews(await app.MCPServers().catch(() => []));
3069 mcpSettingsSnapshot = { key, value: next };
3070 setServers(next);
3071 }, []);
3072 useEffect(() => { void reload(); }, [reload]);
3073 useEffect(() => {
3074 if (!servers?.some((s) => s.status === "initializing" || s.status === "deferred")) return;
3075 const id = window.setInterval(() => void reload(), 2500);
3076 return () => window.clearInterval(id);
3077 }, [reload, servers]);
3078
3079 const mutate = async (fn: () => Promise<unknown>) => {
3080 setBusy(true);
3081 setErr(null);
3082 try {
3083 await fn();
3084 await reload();
3085 return true;
3086 } catch (e) {
3087 setErr(activeWorkBusyNoticeText(e, t) ?? String((e as Error)?.message ?? e));
3088 await reload();
3089 return false;
3090 } finally {
3091 setBusy(false);
3092 }
3093 };
3094 const browseMarketplace = async (search = marketplaceQuery) => {
3095 setBusy(true);
3096 setErr(null);
3097 try {
3098 const result = await app.MCPMarketplace(search);
3099 setMarketplace({ ...result, servers: asArray(result.servers) });
3100 return true;
3101 } catch (error) {
3102 setErr(String((error as Error)?.message ?? error));
3103 return false;
3104 } finally {
3105 setBusy(false);
3106 }
3107 };
3108 const openMarketplace = () => {
3109 setScreen({ kind: "marketplace" });
3110 if (marketplace === null) void browseMarketplace("");
3111 };
3112 const installMarketplaceEntry = async (entry: MCPMarketplaceEntry) => {
3113 const current = await app.MCPMarketplaceResolve(entry.name);
3114 return installMCPServer(mcpMarketplaceServerInput(current, servers ?? []));
3115 };
3116 const filteredServers = useMemo(() => {
3117 const sorted = sortServersForDisplay(servers ?? []);
3118 const normalizedQuery = query.trim().toLowerCase();
3119 return normalizedQuery ? sorted.filter((server) => mcpSettingsSearchText(server, serverCommand(server)).includes(normalizedQuery)) : sorted;
3120 }, [query, servers]);
3121 const projectServers = useMemo(() => filteredServers.filter((server) => server.source === "project"), [filteredServers]);
3122 const managedServers = useMemo(
3123 () => filteredServers.filter((server) => server.source === "plugin" || Boolean(server.managedByPlugin)),
3124 [filteredServers],
3125 );
3126 const installedServers = useMemo(
3127 () => filteredServers.filter((server) => server.source !== "project" && server.source !== "plugin" && !server.managedByPlugin),
3128 [filteredServers],
3129 );
3130 const selectedServer = screen.kind === "detail" || screen.kind === "edit"
3131 ? servers?.find((server) => server.name === screen.name)
3132 : undefined;
3133 useEffect(() => {
3134 if (servers && (screen.kind === "detail" || screen.kind === "edit") && !servers.some((server) => server.name === screen.name)) {
3135 setScreen({ kind: "list" });
3136 }
3137 }, [screen, servers]);
3138
3139 const summary = useMemo(() => {
3140 if (!servers) return "";
3141 return mcpServerSummary(servers, t);
3142 }, [servers, t]);
3143
3144 const loading = servers === null;
3145 const actionBusy = busy || !snapshotKey || loading;
3146
3147 return (
3148 <section className="cap-mcp-settings">
3149 {err && <div className="banner banner--error" role="alert">{err}</div>}
3150 {screen.kind === "list" && (
3151 <>
3152 <div className="cap-mcp-list-toolbar settings-toolbar">
3153 {servers && servers.length > 0 ? <div className="drawer__summary">{summary}</div> : <span />}
3154 <div className="cap-mcp-list-toolbar__actions">
3155 <Tooltip label={t("caps.refresh")}>
3156 <button className="cap-mcp-icon-btn" type="button" aria-label={t("caps.refresh")} disabled={actionBusy} onClick={() => void reload()}>
3157 <RefreshCw aria-hidden size={15} />
3158 </button>
3159 </Tooltip>
3160 <button className="btn btn--small" disabled={actionBusy} type="button" onClick={openMarketplace}>
3161 <Search aria-hidden size={14} />
3162 {t("caps.browseRegistry")}
3163 </button>
3164 <button className="btn btn--primary btn--small cap-mcp-add-btn" disabled={actionBusy} type="button" onClick={() => setScreen({ kind: "add" })}>
3165 <Plus aria-hidden size={14} />
3166 {t("caps.addServer")}
3167 </button>
3168 </div>
3169 </div>
3170 <label className="cap-mcp-search">
3171 <Search aria-hidden size={15} />
3172 <input type="search" value={query} onInput={(event) => setQuery(event.currentTarget.value)} placeholder={t("caps.searchServers")} />
3173 </label>
3174 {loading && <div className="mem-empty">{t("caps.loading")}</div>}
3175 {!loading && servers.length === 0 && <div className="mem-empty">{t("caps.noServers")}</div>}
3176 {!loading && servers.length > 0 && filteredServers.length === 0 && <div className="mem-empty">{t("caps.noServerMatches")}</div>}
3177 <MCPSettingsServerGroup
3178 title={t("caps.projectServers")}
3179 hint={t("caps.projectServersHint")}
3180 servers={projectServers}
3181 busy={actionBusy}
3182 onOpen={(name) => setScreen({ kind: "detail", name })}
3183 onRetry={(name) => void mutate(() => connectMCPServer(name, servers ?? []))}
3184 onToggle={(name, enabled) => void mutate(() => app.SetMCPServerEnabled(name, enabled))}
3185 onRemove={(name) => void mutate(() => app.RemoveMCPServer(name))}
3186 />
3187 <MCPSettingsServerGroup
3188 title={t("caps.installedServers")}
3189 hint={t("caps.installedServersHint")}
3190 servers={installedServers}
3191 busy={actionBusy}
3192 onOpen={(name) => setScreen({ kind: "detail", name })}
3193 onRetry={(name) => void mutate(() => connectMCPServer(name, servers ?? []))}
3194 onToggle={(name, enabled) => void mutate(() => app.SetMCPServerEnabled(name, enabled))}
3195 onRemove={(name) => void mutate(() => app.RemoveMCPServer(name))}
3196 />
3197 <MCPSettingsServerGroup
3198 title={t("caps.pluginServers")}
3199 hint={t("caps.pluginServersHint")}
3200 servers={managedServers}
3201 busy={actionBusy}
3202 onOpen={(name) => setScreen({ kind: "detail", name })}
3203 onRetry={(name) => void mutate(() => connectMCPServer(name, servers ?? []))}
3204 onToggle={(name, enabled) => void mutate(() => app.SetMCPServerEnabled(name, enabled))}
3205 onRemove={(name) => void mutate(() => app.RemoveMCPServer(name))}
3206 />
3207 </>
3208 )}
3209 {screen.kind === "marketplace" && (
3210 <div className="cap-mcp-subpage">
3211 <MCPSettingsSubpageHeader title={t("caps.registryTitle")} description={t("caps.registryHint")} onBack={() => setScreen({ kind: "list" })} />
3212 <form className="cap-mcp-search cap-mcp-search--action" onSubmit={(event) => { event.preventDefault(); void browseMarketplace(); }}>
3213 <Search aria-hidden size={15} />
3214 <input type="search" value={marketplaceQuery} onInput={(event) => setMarketplaceQuery(event.currentTarget.value)} placeholder={t("caps.searchRegistry")} />
3215 <button className="btn btn--small" disabled={busy} type="submit">{t("caps.search")}</button>
3216 </form>
3217 {marketplace?.warning && <div className="banner" role="status">{t("caps.registryCached")} {marketplace.warning}</div>}
3218 {busy && marketplace === null && <div className="mem-empty">{t("caps.loading")}</div>}
3219 {!busy && marketplace && marketplace.servers.length === 0 && <div className="mem-empty">{t("caps.noRegistryMatches")}</div>}
3220 {marketplace && marketplace.servers.length > 0 && (
3221 <div className="cap-mcp-list">
3222 {marketplace.servers.map((entry) => (
3223 <div className="cap-mcp-list-row" key={entry.name}>
3224 <div className="cap-mcp-list-row__main">
3225 <span className="cap-mcp-list-row__icon" aria-hidden><ServerIcon size={16} strokeWidth={1.8} /></span>
3226 <span className="cap-mcp-list-row__copy">
3227 <span className="cap-mcp-list-row__head">
3228 <span className="cap-mcp-list-row__name">{entry.title || entry.name}</span>
3229 {entry.version && <span className="cap-mcp-list-row__transport">{entry.version}</span>}
3230 {entry.transport && <span className="cap-mcp-list-row__transport">{entry.transport}</span>}
3231 </span>
3232 <span className="cap-mcp-list-row__target">{entry.name}</span>
3233 <span className="cap-mcp-list-row__summary">{entry.description || entry.unavailableReason}</span>
3234 {!entry.installable && entry.unavailableReason && <span className="cap-mcp-list-row__owner">{entry.unavailableReason}</span>}
3235 </span>
3236 </div>
3237 <div className="cap-mcp-list-row__actions">
3238 {entry.installable ? (
3239 <button className="btn btn--primary btn--small" disabled={actionBusy || marketplace.cached} type="button" onClick={() => void mutate(() => installMarketplaceEntry(entry)).then((ok) => { if (ok) setScreen({ kind: "list" }); })}>
3240 {t("caps.install")}
3241 </button>
3242 ) : <span className="cap-mcp-list-row__owner">{t("caps.manualSetup")}</span>}
3243 </div>
3244 </div>
3245 ))}
3246 </div>
3247 )}
3248 </div>
3249 )}
3250 {screen.kind === "add" && (
3251 <div className="cap-mcp-subpage">
3252 <MCPSettingsSubpageHeader title={t("caps.addServerTitle")} description={t("caps.addServerHint")} onBack={() => setScreen({ kind: "list" })} />
3253 <MCPServerSettingsEditor
3254 busy={busy}
3255 onCancel={() => setScreen({ kind: "list" })}
3256 onSubmit={(input) => void mutate(() => installMCPServer(input)).then((ok) => { if (ok) setScreen({ kind: "list" }); })}
3257 />
3258 </div>
3259 )}
3260 {screen.kind === "edit" && selectedServer && (
3261 <div className="cap-mcp-subpage">
3262 <MCPSettingsSubpageHeader title={t("caps.editServerTitle", { name: selectedServer.name })} description={t("caps.editServerHint")} onBack={() => setScreen({ kind: "detail", name: selectedServer.name })} />
3263 <MCPServerSettingsEditor
3264 server={selectedServer}
3265 busy={busy}
3266 onCancel={() => setScreen({ kind: "detail", name: selectedServer.name })}
3267 onSubmit={(input) => void mutate(() => app.UpdateMCPServer(selectedServer.name, input)).then((ok) => { if (ok) setScreen({ kind: "detail", name: selectedServer.name }); })}
3268 />
3269 </div>
3270 )}
3271 {screen.kind === "detail" && selectedServer && (
3272 <div className="cap-mcp-subpage">
3273 <MCPSettingsSubpageHeader title={selectedServer.name} description={t("caps.serverDetailsHint")} onBack={() => setScreen({ kind: "list" })} />
3274 {selectedServer.error && (
3275 <div className="cap-mcp-detail-error">
3276 <div className="banner banner--error">{summarizeServerError(selectedServer.error)}</div>
3277 <details>
3278 <summary>{t("caps.rawLog")}</summary>
3279 <pre>{selectedServer.error}</pre>
3280 </details>
3281 </div>
3282 )}
3283 <ServerDetails
3284 s={selectedServer}
3285 tools={selectedServer.toolList ?? []}
3286 busy={actionBusy}
3287 onConfirm={() => void mutate(() => app.RemoveMCPServer(selectedServer.name)).then((ok) => { if (ok) setScreen({ kind: "list" }); })}
3288 onConnectNow={() => void mutate(() => connectMCPServer(selectedServer.name, servers ?? []))}
3289 onReconnect={() => void mutate(() => app.ReconnectMCPServer(selectedServer.name))}
3290 onConfirmClearAuth={() => void mutate(() => app.ClearMCPServerAuthentication(selectedServer.name))}
3291 toolsExpanded
3292 editing={false}
3293 onEdit={() => setScreen({ kind: "edit", name: selectedServer.name })}
3294 onCancelEdit={() => undefined}
3295 onUpdate={() => undefined}
3296 onToggleTools={() => undefined}
3297 standalone
3298 showToolsToggle={false}
3299 />
3300 </div>
3301 )}
3302 </section>
3303 );
3304 }
3305
3306 // SkillsSettingsPage is a self-contained skills management page embedded inside
3307 // the settings centre.
3308 export function SkillsSettingsPage({ activeWorkspaceKey = "" }: { activeWorkspaceKey?: string }) {
3309 const t = useT();
3310 const [snapshotKey, setSnapshotKey] = useState("");
3311 const [view, setView] = useState<SkillsSettingsView | null>(null);
3312 const [busy, setBusy] = useState(false);
3313 const [err, setErr] = useState<string | null>(null);
3314 const [skillQuery, setSkillQuery] = useState("");
3315 const [expandedSkills, setExpandedSkills] = useState<Set<string>>(() => new Set());
3316 const reloadSequence = useRef(0);
3317
3318 const reload = useCallback(async () => {
3319 const sequence = ++reloadSequence.current;
3320 const [meta, tabs] = await Promise.all([
3321 app.Meta().catch(() => null),
3322 app.ListTabs().catch(() => []),
3323 ]);
3324 if (sequence !== reloadSequence.current) return;
3325 const key = settingsSnapshotKey(meta, tabs);
3326 setSnapshotKey(key);
3327 const cached = key ? skillsSettingsSnapshot : null;
3328 if (cached?.key === key) {
3329 setView(cached.value);
3330 } else {
3331 setView(null);
3332 }
3333 const next = normalizeSkillsSettingsView(await app.SkillsSettings().catch(() => ({ skills: [], skillRoots: [] })));
3334 if (sequence !== reloadSequence.current) return;
3335 skillsSettingsSnapshot = { key, value: next };
3336 setView(next);
3337 }, [activeWorkspaceKey]);
3338 useEffect(() => {
3339 setView(null);
3340 void reload();
3341 }, [reload]);
3342
3343 const mutate = async (fn: () => Promise<unknown>) => {
3344 setBusy(true);
3345 setErr(null);
3346 try {
3347 await fn();
3348 await reload();
3349 return true;
3350 } catch (e) {
3351 setErr(activeWorkBusyNoticeText(e, t) ?? String((e as Error)?.message ?? e));
3352 await reload();
3353 return false;
3354 } finally {
3355 setBusy(false);
3356 }
3357 };
3358
3359 const filteredSkills = useMemo(() => {
3360 if (!view) return [];
3361 const q = skillQuery.trim().toLowerCase();
3362 if (!q) return view.skills;
3363 return view.skills.filter((sk) => {
3364 const text = [sk.name, "/" + sk.name, sk.invocation, sk.plugin, sk.description, sk.scope, sk.sourceDir, sk.runAs].join(" ").toLowerCase();
3365 return text.includes(q);
3366 });
3367 }, [view, skillQuery]);
3368
3369 const skillSummary = useMemo(() => {
3370 if (!view) return "";
3371 return skillListSummary(view.skills, filteredSkills, skillQuery.trim().length > 0, t);
3372 }, [filteredSkills, skillQuery, t, view]);
3373
3374 const toggleSkill = useCallback((name: string) => {
3375 setExpandedSkills((prev) => { const next = new Set(prev); if (next.has(name)) next.delete(name); else next.add(name); return next; });
3376 }, []);
3377
3378 if (!view) return <div className="empty">{t("caps.loading")}</div>;
3379 const actionBusy = busy || !snapshotKey;
3380
3381 return (
3382 <section className="mem-section">
3383 {err && <div className="banner banner--error">{err}</div>}
3384 <div className="cap-search settings-toolbar">
3385 <input
3386 className="mem-input"
3387 type="search"
3388 placeholder={t("caps.searchSkills")}
3389 value={skillQuery}
3390 onChange={(e) => setSkillQuery(e.target.value)}
3391 />
3392 </div>
3393 <label className="provider-capability-row cap-skill-policy">
3394 <span className="provider-capability-row__copy">
3395 <span className="provider-capability-row__title">{t("caps.skillImplicitInvocation")}</span>
3396 <span className="cap-skill-policy__hint">{t("caps.skillImplicitInvocationHint")}</span>
3397 </span>
3398 <input
3399 className="provider-capability-row__switch"
3400 type="checkbox"
3401 role="switch"
3402 checked={view.allowImplicitInvocation}
3403 disabled={actionBusy}
3404 onChange={(e) => void mutate(() => app.SetSkillImplicitInvocation(e.target.checked))}
3405 />
3406 </label>
3407 <SkillSources
3408 roots={view.skillRoots ?? []}
3409 busy={actionBusy}
3410 onAdd={() => mutate(async () => {
3411 const path = await app.PickSkillFolder();
3412 if (path) await app.AddSkillPath(path);
3413 })}
3414 onRefresh={() => mutate(() => app.RefreshSkills())}
3415 onToggle={(path, enabled) => mutate(() => app.SetSkillPathEnabled(path, enabled))}
3416 />
3417 <div className="cap-skills-head settings-toolbar">
3418 <div className="cap-skills-head__copy">
3419 <div className="cap-skills-head__title">{t("caps.skills")}</div>
3420 <div className="cap-skills-head__summary">{skillSummary}</div>
3421 </div>
3422 </div>
3423 {view.skills.length === 0 ? (
3424 <div className="mem-empty">{t("caps.noSkills")}</div>
3425 ) : filteredSkills.length === 0 ? (
3426 <div className="mem-empty">{t("caps.noSkillMatches")}</div>
3427 ) : (
3428 <div className="cap-skills">
3429 {filteredSkills.map((sk) => (
3430 <SkillRow
3431 key={sk.name}
3432 skill={sk}
3433 busy={actionBusy}
3434 expanded={expandedSkills.has(sk.name)}
3435 onToggle={() => toggleSkill(sk.name)}
3436 onToggleEnabled={(enabled) => void mutate(() => app.SetSkillEnabled(sk.name, enabled))}
3437 />
3438 ))}
3439 </div>
3440 )}
3441 </section>
3442 );
3443 }
3444
3444 lines Plain Text