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