@daweifu/capability-menu 0.1.3 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -5240,6 +5240,7 @@ const capabilityRow$schema = object({
5240
5240
  name: string().readonly(),
5241
5241
  server: string().optional().readonly(),
5242
5242
  source: string().optional().readonly(),
5243
+ preset: string().optional().readonly(),
5243
5244
  class: union([
5244
5245
  literal("resident"),
5245
5246
  literal("on-demand"),
@@ -5273,7 +5274,8 @@ const toolDetail$schema = object({
5273
5274
  provider: string().readonly(),
5274
5275
  serverName: string().optional().readonly(),
5275
5276
  path: string().optional().readonly(),
5276
- source: string().optional().readonly()
5277
+ source: string().optional().readonly(),
5278
+ preset: string().optional().readonly()
5277
5279
  }).readonly(),
5278
5280
  tags: array(string()).readonly(),
5279
5281
  stats: object({
@@ -5762,6 +5764,24 @@ const TYPERT_REMOTE = {
5762
5764
  line: 264,
5763
5765
  column: 3
5764
5766
  }
5767
+ },
5768
+ {
5769
+ id: "@daweifu/capability-menu#capabilityPolicy/catalogVersion",
5770
+ service: "capabilityPolicy",
5771
+ namespace: "capabilityPolicy",
5772
+ method: "catalogVersion",
5773
+ invocation: { kind: "direct" },
5774
+ parameters: [],
5775
+ result: {
5776
+ mode: "strict",
5777
+ typeSymbol: "number",
5778
+ schema: number()
5779
+ },
5780
+ sourceLocation: {
5781
+ file: "src/server/remote.ts",
5782
+ line: 317,
5783
+ column: 3
5784
+ }
5765
5785
  }
5766
5786
  ]
5767
5787
  };
@@ -6361,6 +6381,219 @@ const PROJECT_SKILL_SOURCES = /* @__PURE__ */ new Set(["project-dsh", "project-a
6361
6381
  */
6362
6382
  const ADOPTABLE_SKILL_SOURCES = /* @__PURE__ */ new Set(["user-agents", "custom"]);
6363
6383
  //#endregion
6384
+ //#region src/client/skillGroups.ts
6385
+ /**
6386
+ * What the capability list decides about *which* rows it shows, kept out of
6387
+ * `CapabilitySection.tsx` as pure functions.
6388
+ *
6389
+ * The split is deliberate: the grouping rules are where this plugin got its one
6390
+ * real mis-grouping (preset skills filed under 全局技能), and they are also the
6391
+ * only part of the client a test can reach — this repo has no DOM harness, so
6392
+ * anything that lives inside the component can only be checked by hand.
6393
+ */
6394
+ /** Group tools by server name; skills stay flat (no server), both name-sorted. */
6395
+ function groupRows(rows) {
6396
+ const byServer = /* @__PURE__ */ new Map();
6397
+ const skills = [];
6398
+ for (const row of rows) {
6399
+ if (row.kind === "skill") {
6400
+ skills.push(row);
6401
+ continue;
6402
+ }
6403
+ const server = row.server ?? "built-in";
6404
+ const list = byServer.get(server);
6405
+ if (list === void 0) byServer.set(server, [row]);
6406
+ else list.push(row);
6407
+ }
6408
+ return {
6409
+ servers: [...byServer.entries()].map(([server, tools]) => ({
6410
+ server,
6411
+ tools: [...tools].sort((a, b) => a.name.localeCompare(b.name))
6412
+ })).sort((a, b) => a.server.localeCompare(b.server)),
6413
+ skills: [...skills].sort((a, b) => a.name.localeCompare(b.name))
6414
+ };
6415
+ }
6416
+ /**
6417
+ * Compile the box. A `/…/` wrapper is optional — it is stripped when present, so
6418
+ * both `cordis|ptc` and `/cordis|ptc/` are read as patterns — and it keeps the
6419
+ * common case of pasting a slash-delimited pattern from working.
6420
+ */
6421
+ function compileNameFilter(input) {
6422
+ const raw = input.trim();
6423
+ const needle = raw.toLowerCase();
6424
+ if (raw === "") return {
6425
+ needle,
6426
+ regex: false,
6427
+ test: () => true
6428
+ };
6429
+ const pattern = raw.length > 1 && raw.startsWith("/") && raw.endsWith("/") ? raw.slice(1, -1) : raw;
6430
+ try {
6431
+ const compiled = new RegExp(pattern, "i");
6432
+ return {
6433
+ needle,
6434
+ regex: true,
6435
+ test: (values) => values.some((v) => v !== void 0 && compiled.test(v))
6436
+ };
6437
+ } catch {
6438
+ return {
6439
+ needle,
6440
+ regex: false,
6441
+ test: (values) => values.some((v) => v !== void 0 && v.toLowerCase().includes(needle))
6442
+ };
6443
+ }
6444
+ }
6445
+ /**
6446
+ * Everything a row is matched against: its own name, plus the dimensions that
6447
+ * name the group it sits in — the MCP server (or the label the built-in group is
6448
+ * drawn with), the skill's source root, and the preset that ships it.
6449
+ *
6450
+ * Group headings are labels rather than row fields, which is exactly why typing
6451
+ * 系统 used to find nothing: the server id is `built-in`, and 系统内置 is only how
6452
+ * that id is painted. Both spellings are searchable now.
6453
+ */
6454
+ function searchableText(row, labels = {}) {
6455
+ const values = [row.name];
6456
+ if (row.server !== void 0) {
6457
+ values.push(row.server);
6458
+ if (row.server === "built-in") values.push(labels.builtIn);
6459
+ }
6460
+ if (row.source !== void 0) values.push(row.source, labels.source?.(row.source));
6461
+ values.push(row.preset);
6462
+ return values.filter((v) => v !== void 0 && v !== "");
6463
+ }
6464
+ /**
6465
+ * Narrow a list by the filter. Applied *before* grouping so a server header's
6466
+ * own counts describe the rows actually listed under it.
6467
+ */
6468
+ function filterRows(rows, filter, labels = {}) {
6469
+ if (filter.needle === "") return [...rows];
6470
+ return rows.filter((row) => filter.test(searchableText(row, labels)));
6471
+ }
6472
+ /**
6473
+ * Split skill rows across the three sub-tabs.
6474
+ *
6475
+ * Preset skills are split off by their `preset` field and never by `source`: a
6476
+ * preset's own `skill-filesystem` labels its skills `custom`, exactly like a
6477
+ * user-configured `customSkillDirs` entry, so a source-based rule drags both
6478
+ * into 全局技能 — filing a preset asset as global is precisely the mis-grouping
6479
+ * this split exists to fix. Rows keep the order they arrive in, so callers that
6480
+ * pass name-sorted rows get name-sorted groups.
6481
+ */
6482
+ function splitSkillGroups(skills) {
6483
+ const presetSkills = [];
6484
+ const projectSkills = [];
6485
+ const globalSkills = [];
6486
+ const presetGroups = [];
6487
+ for (const skill of skills) {
6488
+ const preset = skill.preset;
6489
+ if (preset !== void 0) {
6490
+ presetSkills.push(skill);
6491
+ const group = presetGroups.find(([id]) => id === preset);
6492
+ if (group === void 0) presetGroups.push([preset, [skill]]);
6493
+ else group[1].push(skill);
6494
+ continue;
6495
+ }
6496
+ if (PROJECT_SKILL_SOURCES.has(skill.source ?? "")) projectSkills.push(skill);
6497
+ else globalSkills.push(skill);
6498
+ }
6499
+ presetGroups.sort(([a], [b]) => a.localeCompare(b));
6500
+ return {
6501
+ presetSkills,
6502
+ presetGroups,
6503
+ projectSkills,
6504
+ globalSkills
6505
+ };
6506
+ }
6507
+ /**
6508
+ * The sub-tab the Skills panel actually shows: the requested one, unless it is
6509
+ * 预设技能 while no preset ships skills. That tab does not exist then, and a
6510
+ * selection left on it must not survive — deployed presets are re-scanned on
6511
+ * every refresh, so the tab can disappear under the operator.
6512
+ */
6513
+ function resolveSkillTab(selected, groups) {
6514
+ return selected === "preset" && groups.presetSkills.length === 0 ? "global" : selected;
6515
+ }
6516
+ /** Rows in one tier. Counts the *displayed* rows, not the whole catalog. */
6517
+ function countByClass(rows, cls) {
6518
+ return rows.reduce((n, row) => row.class === cls ? n + 1 : n, 0);
6519
+ }
6520
+ //#endregion
6521
+ //#region src/client/revalidate.ts
6522
+ /**
6523
+ * When to re-read the catalog, and how often to look — the decisions behind the
6524
+ * management page keeping itself fresh, kept as pure functions.
6525
+ *
6526
+ * Wiring lives in `CapabilitySection.tsx` (timers, the `visibilitychange`
6527
+ * listener, the two subscriptions); everything that could be got wrong lives
6528
+ * here, where a test can reach it. This repo has no DOM harness.
6529
+ */
6530
+ /**
6531
+ * How often a visible, idle page asks the host for its catalog version.
6532
+ *
6533
+ * This is the resolution of the one case nothing on the wire announces — a file
6534
+ * dropped into an already-registered skill directory — so it doubles as "how
6535
+ * long will I stare at a stale list after an agent changes something next to
6536
+ * me". 5s keeps that from being annoying; the cost is one tiny request per tick,
6537
+ * and only while this settings page is on screen.
6538
+ */
6539
+ const POLL_INTERVAL_MS = 5e3;
6540
+ /** Ceiling for the failure backoff, so a long outage does not spin. */
6541
+ const POLL_BACKOFF_MAX_MS = 6e4;
6542
+ /**
6543
+ * How long one version sample may take before it is treated as a failure.
6544
+ *
6545
+ * Without this, a request that neither resolves nor rejects would end the poll
6546
+ * for good: the next tick is only scheduled after the current one settles, and
6547
+ * nothing else restarts the effect while the page stays visible and idle.
6548
+ */
6549
+ const POLL_SAMPLE_TIMEOUT_MS = 15e3;
6550
+ /**
6551
+ * Delay before the next version poll.
6552
+ *
6553
+ * `failures` is the run of consecutive failures ending at this decision: 0
6554
+ * means the last sample succeeded and the plain interval applies, otherwise the
6555
+ * delay doubles until it hits the ceiling. This is what turns "the carrier
6556
+ * died" from a hot loop into a slow retry that heals itself when it comes back.
6557
+ */
6558
+ function pollDelayMs(failures) {
6559
+ if (!Number.isFinite(failures) || failures <= 0) return POLL_INTERVAL_MS;
6560
+ return Math.min(POLL_INTERVAL_MS * 2 ** failures, POLL_BACKOFF_MAX_MS);
6561
+ }
6562
+ /**
6563
+ * Whether a version sample means the catalog moved under the page.
6564
+ *
6565
+ * Deliberately `!==` rather than `>`: the counter is in-process, so it restarts
6566
+ * from zero when the plugin reloads, and a plain "greater than" test would sit
6567
+ * there believing a fresh host was the same host. `undefined` (nothing sampled
6568
+ * yet) only records — the page has just read the snapshot, and re-reading it
6569
+ * because it has no baseline would be busywork.
6570
+ */
6571
+ function versionChanged(seen, current) {
6572
+ return seen !== void 0 && seen !== current;
6573
+ }
6574
+ /**
6575
+ * The value of `work`, or a rejection if it has not settled within `ms`.
6576
+ *
6577
+ * Used so a carrier that accepts a request and then goes quiet degrades into
6578
+ * the same retry path as a carrier that refuses one.
6579
+ */
6580
+ function withDeadline(work, ms) {
6581
+ return new Promise((resolve, reject) => {
6582
+ const timer = setTimeout(() => reject(/* @__PURE__ */ new Error(`timed out after ${ms}ms`)), ms);
6583
+ work.then((value) => {
6584
+ clearTimeout(timer);
6585
+ resolve(value);
6586
+ }, (error) => {
6587
+ clearTimeout(timer);
6588
+ reject(error);
6589
+ });
6590
+ });
6591
+ }
6592
+ /** Whether a signal may act now, or has to be parked until the page is idle. */
6593
+ function canRevalidate(gate) {
6594
+ return gate.visible && gate.ready && !gate.busy && !gate.rebuilding;
6595
+ }
6596
+ //#endregion
6364
6597
  //#region src/client/CapabilitySection.tsx
6365
6598
  /**
6366
6599
  * ⚠️ VERIFIED AGAINST REAL rc.8 CLIENT API.
@@ -6412,6 +6645,8 @@ const CSS = `
6412
6645
  .mc-summary{display:flex;gap:12px;flex-wrap:wrap;justify-content:flex-end;align-items:center;padding-bottom:8px}
6413
6646
  .mc-catalog-btn{border:1px solid var(--dsw-alias-border-l2);border-radius:6px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-secondary);font:inherit;font-size:12px;line-height:20px;padding:0 10px;cursor:pointer;white-space:nowrap}
6414
6647
  .mc-catalog-btn:hover{border-color:var(--dsw-alias-border-l3);background:var(--dsw-alias-interactive-bg-hover)}
6648
+ /* 忙碌态靠变淡表示,而不是换文案:换文案会改按钮宽度,把这行挤到第二行。 */
6649
+ .mc-catalog-btn:disabled{cursor:default;opacity:.6}
6415
6650
  .mc-catalog-tabs{padding:8px 16px 0}
6416
6651
  .mc-catalog-path{padding:8px 16px 0;margin:0;font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary);word-break:break-all}
6417
6652
  .mc-chip{display:inline-flex;align-items:center;gap:6px;font-size:12px;line-height:20px;white-space:nowrap}
@@ -6435,6 +6670,15 @@ body[data-ds-dark-theme] .mc-chip--resident{color:#96b6d1}
6435
6670
  body[data-ds-dark-theme] .mc-chip--on-demand{color:#d4b26b}
6436
6671
  body[data-ds-dark-theme] .mc-chip--disabled{color:#b8abad}
6437
6672
  .mc-tabs{border-bottom:1px solid var(--dsw-alias-border-l2);display:flex;align-items:flex-end;justify-content:space-between;gap:22px}
6673
+ /* 名字过滤框:两个 tab 共用一条,宽度占满,与下方列表左对齐。
6674
+ 它属于上面的主 tab 栏,不属于下面的列表,所以两侧都把 .mc-section 的 12px
6675
+ 栏距收掉一部分:下划线—6px—输入框—8px—全局/项目 子页签文字(后者里的 4px
6676
+ 是子页签自己的 padding-top)。改之前是 12+12=24px 和 12+4=16px,读起来像
6677
+ 两个不相干的块。 */
6678
+ .mc-filter{display:flex;padding-top:0;margin-top:-6px;margin-bottom:-8px}
6679
+ .mc-filter input{box-sizing:border-box;width:100%;min-width:0;padding:6px 10px;border:1px solid var(--dsw-alias-border-l2);border-radius:6px;background:var(--dsw-alias-bg-layer-1);color:inherit;font:inherit;font-size:13px;line-height:18px}
6680
+ .mc-filter input:hover{border-color:var(--dsw-alias-border-l3)}
6681
+ .mc-filter input:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:1px;border-color:transparent}
6438
6682
  /* Skills sub-tab bar (全局技能/项目技能): same underline chrome, no right-side summary. */
6439
6683
  .mc-subtabs{border-bottom:1px solid var(--dsw-alias-border-l2);display:flex;align-items:flex-end;gap:22px}
6440
6684
  .mc-tab-group{display:flex;align-items:flex-end;gap:22px}
@@ -6442,13 +6686,17 @@ body[data-ds-dark-theme] .mc-chip--disabled{color:#b8abad}
6442
6686
  .mc-tab:hover,.mc-tab[data-active=true]{color:var(--dsw-alias-label-primary)}
6443
6687
  .mc-tab[data-active=true]:after,.mc-tab:focus-visible:after{background:var(--dsw-alias-label-primary);content:"";border-radius:2px 2px 0 0;height:2px;position:absolute;bottom:-1px;left:0;right:0}
6444
6688
  .mc-tab:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:2px;color:var(--dsw-alias-label-primary);border-radius:2px}
6445
- .mc-panel{min-width:0;padding-top:12px}
6446
- /* Skills panel: its first row is the 全局技能/项目技能 sub-tab bar, which sits
6447
- directly under the main tab bar. It is a second level of the same control, so
6448
- the two bars have to read as one stack: no panel padding at all, and the
6449
- sub-tabs keep a token top padding instead of the 7px they share with the
6450
- primary tabs. Together the label sits ~13px under the main label rather than
6451
- ~20px, with the main bar's rule between them. */
6689
+ /* 面板的第一行必须和另一个 tab 的第一行从过滤框下沿出发走同样远,否则切 tab 时
6690
+ 首行会跳一下。这里留 4px,加上 .mc-section 的 4px 栏距(过滤框那侧被 -8px 收
6691
+ 掉后剩 4px)= 8px,正好是 Skills 面板子页签文字的位置(4px 栏距 + 子页签自己
6692
+ 的 4px padding-top)。原来这里是 12px,Tools 的首个分组因此落在过滤框下 16px
6693
+ 处,比「全局技能」那行低一半。 */
6694
+ .mc-panel{min-width:0;padding-top:4px}
6695
+ /* Skills panel: its first row is the 全局技能/项目技能 sub-tab bar, and that bar
6696
+ belongs to the header stack above it (rule → name filter → sub-tabs) rather
6697
+ than to the list below. So the panel adds no padding of its own, and the
6698
+ sub-tab labels keep a 4px top padding instead of the 7px they share with the
6699
+ primary tabs. */
6452
6700
  .mc-panel--tight{padding-top:0}
6453
6701
  .mc-panel--tight .mc-subtabs .mc-tab{padding-top:4px}
6454
6702
  .mc-panel-inner{display:flex;flex-direction:column;gap:14px}
@@ -6508,6 +6756,9 @@ body[data-ds-dark-theme] .mc-count--disabled{color:#b8abad}
6508
6756
  .mc-confirm-path{font-family:var(--dsw-font-markdown-code-block-font-family);word-break:break-all}
6509
6757
  .mc-confirm-actions{display:flex;justify-content:flex-end;gap:8px}
6510
6758
  .mc-skill-body{border-top:1px solid var(--dsw-alias-border-l1);padding:8px 12px 12px}
6759
+ /* 预设技能组:预设 id 是页签之下的分组小标题,不是新一级页签。 */
6760
+ .mc-skill-group{display:flex;flex-direction:column;gap:8px;min-width:0}
6761
+ .mc-skill-group-title{font-size:12px;line-height:18px;font-weight:600;color:var(--dsw-alias-label-secondary);padding:0 2px}
6511
6762
  .mc-tree{display:flex;flex-direction:column;gap:2px;font-size:13px;line-height:20px}
6512
6763
  .mc-tree-row{display:flex;align-items:center;gap:8px;padding:3px 4px;border-radius:6px;cursor:pointer;min-width:0}
6513
6764
  .mc-tree-row:hover{background:var(--dsw-alias-interactive-bg-hover)}
@@ -6555,32 +6806,22 @@ function sourceLabel(source, t) {
6555
6806
  const key = SOURCE_LABEL_KEYS[source];
6556
6807
  return key === void 0 ? source : t(key);
6557
6808
  }
6558
- function groupRows(rows) {
6559
- const byServer = /* @__PURE__ */ new Map();
6560
- const skills = [];
6561
- for (const row of rows) {
6562
- if (row.kind === "skill") {
6563
- skills.push(row);
6564
- continue;
6565
- }
6566
- const server = row.server ?? "built-in";
6567
- const list = byServer.get(server);
6568
- if (list === void 0) byServer.set(server, [row]);
6569
- else list.push(row);
6570
- }
6571
- return {
6572
- servers: [...byServer.entries()].map(([server, tools]) => ({
6573
- server,
6574
- tools: [...tools].sort((a, b) => a.name.localeCompare(b.name))
6575
- })).sort((a, b) => a.server.localeCompare(b.server)),
6576
- skills: [...skills].sort((a, b) => a.name.localeCompare(b.name))
6577
- };
6578
- }
6579
- function countByClass(rows, cls) {
6580
- return rows.reduce((n, row) => row.class === cls ? n + 1 : n, 0);
6809
+ /**
6810
+ * Whether the document is visible. A hidden settings tab does no polling and
6811
+ * takes no re-reads; coming back is itself a revalidation trigger.
6812
+ */
6813
+ function useDocumentVisible() {
6814
+ const [visible, setVisible] = (0, react.useState)(true);
6815
+ (0, react.useEffect)(() => {
6816
+ const update = () => setVisible(document.visibilityState !== "hidden");
6817
+ update();
6818
+ document.addEventListener("visibilitychange", update);
6819
+ return () => document.removeEventListener("visibilitychange", update);
6820
+ }, []);
6821
+ return visible;
6581
6822
  }
6582
6823
  function CapabilitySection(props) {
6583
- const { remote, t, mountError } = props;
6824
+ const { remote, t, mountError, subscribeSignals } = props;
6584
6825
  const [state, setState] = (0, react.useState)(() => {
6585
6826
  const cached = cachedSnapshot();
6586
6827
  return cached === void 0 ? { status: "loading" } : {
@@ -6649,6 +6890,84 @@ function CapabilitySection(props) {
6649
6890
  return next;
6650
6891
  });
6651
6892
  }, []);
6893
+ const visible = useDocumentVisible();
6894
+ const [pendingSignal, setPendingSignal] = (0, react.useState)(false);
6895
+ const seenVersion = (0, react.useRef)(void 0);
6896
+ const pollFailures = (0, react.useRef)(0);
6897
+ const gate = (0, react.useRef)({
6898
+ visible: true,
6899
+ ready: false,
6900
+ busy: false,
6901
+ rebuilding: false
6902
+ });
6903
+ (0, react.useEffect)(() => {
6904
+ gate.current = {
6905
+ visible,
6906
+ ready: state.status === "ready",
6907
+ busy,
6908
+ rebuilding: refreshing
6909
+ };
6910
+ });
6911
+ /**
6912
+ * A signal that the host catalog may have moved. Runs now when the page is
6913
+ * idle; otherwise parks until it is, so a signal that lands mid-click is
6914
+ * never dropped (and never repaints the row under the cursor).
6915
+ */
6916
+ const onExternalSignal = (0, react.useCallback)(() => {
6917
+ if (canRevalidate(gate.current)) refreshCatalog();
6918
+ else setPendingSignal(true);
6919
+ }, [refreshCatalog]);
6920
+ (0, react.useEffect)(() => {
6921
+ return subscribeSignals?.(onExternalSignal);
6922
+ }, [subscribeSignals, onExternalSignal]);
6923
+ (0, react.useEffect)(() => {
6924
+ if (!pendingSignal) return;
6925
+ if (!canRevalidate({
6926
+ visible,
6927
+ ready: state.status === "ready",
6928
+ busy,
6929
+ rebuilding: refreshing
6930
+ })) return;
6931
+ setPendingSignal(false);
6932
+ refreshCatalog();
6933
+ }, [
6934
+ pendingSignal,
6935
+ visible,
6936
+ state.status,
6937
+ busy,
6938
+ refreshing,
6939
+ refreshCatalog
6940
+ ]);
6941
+ (0, react.useEffect)(() => {
6942
+ if (!visible || state.status !== "ready" || busy || refreshing) return;
6943
+ let stopped = false;
6944
+ let timer;
6945
+ const tick = async () => {
6946
+ try {
6947
+ const version = unwrap(await withDeadline(remote.catalogVersion(), POLL_SAMPLE_TIMEOUT_MS), "capabilityPolicy.catalogVersion");
6948
+ pollFailures.current = 0;
6949
+ if (versionChanged(seenVersion.current, version)) {
6950
+ seenVersion.current = version;
6951
+ await reload();
6952
+ }
6953
+ } catch {
6954
+ pollFailures.current += 1;
6955
+ }
6956
+ if (!stopped) timer = setTimeout(() => void tick(), pollDelayMs(pollFailures.current));
6957
+ };
6958
+ timer = setTimeout(() => void tick(), 0);
6959
+ return () => {
6960
+ stopped = true;
6961
+ if (timer !== void 0) clearTimeout(timer);
6962
+ };
6963
+ }, [
6964
+ visible,
6965
+ state.status,
6966
+ busy,
6967
+ refreshing,
6968
+ remote,
6969
+ reload
6970
+ ]);
6652
6971
  /** Move one or more capability ids to the next class in the click cycle. */
6653
6972
  const cycleClass = (0, react.useCallback)(async (ids, kind) => {
6654
6973
  if (ids.length === 0 || busy) return;
@@ -6744,19 +7063,31 @@ function CapabilitySection(props) {
6744
7063
  onToggleServer: toggleServer,
6745
7064
  onCycle: cycleClass,
6746
7065
  onRefresh: () => void refreshCatalog(),
6747
- refreshing,
6748
7066
  onNotice: setNotice
6749
7067
  })
6750
7068
  ]
6751
7069
  });
6752
7070
  }
6753
7071
  function ReadyBody(props) {
6754
- const { remote, snapshot, openServers, busy, activeTab, t, onTabChange, onToggleServer, onCycle, onRefresh, refreshing, onNotice } = props;
6755
- const { servers, skills } = groupRows(snapshot.rows);
6756
- const projectSkills = skills.filter((skill) => PROJECT_SKILL_SOURCES.has(skill.source ?? ""));
6757
- const globalSkills = skills.filter((skill) => !PROJECT_SKILL_SOURCES.has(skill.source ?? ""));
7072
+ const { remote, snapshot, openServers, busy, activeTab, t, onTabChange, onToggleServer, onCycle, onRefresh, onNotice } = props;
7073
+ /** Name filter, shared by both tabs: it narrows whichever list is shown. */
7074
+ const [filter, setFilter] = (0, react.useState)("");
7075
+ const nameFilter = compileNameFilter(filter);
7076
+ const needle = nameFilter.needle;
7077
+ const shownRows = filterRows(snapshot.rows, nameFilter, {
7078
+ builtIn: t("builtInGroup"),
7079
+ source: (source) => sourceLabel(source, t)
7080
+ });
7081
+ const { servers, skills } = groupRows(shownRows);
7082
+ const { presetSkills, presetGroups, projectSkills, globalSkills } = splitSkillGroups(skills);
6758
7083
  const [skillTab, setSkillTab] = (0, react.useState)("global");
6759
- const statRows = activeTab === "tools" ? snapshot.rows.filter((r) => r.kind === "tool") : skillTab === "project" ? projectSkills : globalSkills;
7084
+ const activeSkillTab = resolveSkillTab(skillTab, {
7085
+ presetSkills,
7086
+ presetGroups,
7087
+ projectSkills,
7088
+ globalSkills
7089
+ });
7090
+ const statRows = activeTab === "tools" ? shownRows.filter((r) => r.kind === "tool") : activeSkillTab === "project" ? projectSkills : activeSkillTab === "preset" ? presetSkills : globalSkills;
6760
7091
  const summary = CLASS_KEYS.map((cls) => ({
6761
7092
  cls,
6762
7093
  count: countByClass(statRows, cls)
@@ -6936,35 +7267,36 @@ function ReadyBody(props) {
6936
7267
  })]
6937
7268
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
6938
7269
  className: "mc-summary",
6939
- children: [
6940
- summary.map(({ cls, count }) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
6941
- className: `mc-chip mc-chip--${cls}`,
6942
- children: [
6943
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
6944
- className: `mc-dot mc-dot--${cls}`,
6945
- "aria-hidden": "true"
6946
- }),
6947
- t(CLASS_SHORT_KEYS[cls]),
6948
- " · ",
6949
- count
6950
- ]
6951
- }, cls)),
6952
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
6953
- type: "button",
6954
- className: "mc-catalog-btn",
6955
- onClick: () => onRefresh(),
6956
- disabled: refreshing,
6957
- children: refreshing ? t("refreshing") : t("refresh")
6958
- }),
6959
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
6960
- type: "button",
6961
- className: "mc-catalog-btn",
6962
- onClick: () => setRegisterOpen(true),
6963
- children: t("registerCapability")
6964
- })
6965
- ]
7270
+ children: [summary.map(({ cls, count }) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
7271
+ className: `mc-chip mc-chip--${cls}`,
7272
+ children: [
7273
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
7274
+ className: `mc-dot mc-dot--${cls}`,
7275
+ "aria-hidden": "true"
7276
+ }),
7277
+ t(CLASS_SHORT_KEYS[cls]),
7278
+ " · ",
7279
+ count
7280
+ ]
7281
+ }, cls)), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
7282
+ type: "button",
7283
+ className: "mc-catalog-btn",
7284
+ onClick: () => setRegisterOpen(true),
7285
+ children: t("registerCapability")
7286
+ })]
6966
7287
  })]
6967
7288
  }),
7289
+ snapshot.rows.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
7290
+ className: "mc-filter",
7291
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
7292
+ type: "search",
7293
+ value: filter,
7294
+ placeholder: t("filterByName"),
7295
+ "aria-label": t("filterByName"),
7296
+ title: t("filterHint"),
7297
+ onChange: (e) => setFilter(e.target.value)
7298
+ })
7299
+ }),
6968
7300
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
6969
7301
  role: "tabpanel",
6970
7302
  hidden: activeTab !== "tools",
@@ -6973,7 +7305,7 @@ function ReadyBody(props) {
6973
7305
  className: "mc-panel-inner",
6974
7306
  children: servers.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
6975
7307
  className: "mc-empty",
6976
- children: t("emptyTools")
7308
+ children: needle === "" ? t("emptyTools") : t("filterNoMatch")
6977
7309
  }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(react_jsx_runtime.Fragment, { children: servers.map(({ server, tools }) => {
6978
7310
  const open = openServers.has(server);
6979
7311
  const counts = CLASS_KEYS.map((cls) => ({
@@ -7095,32 +7427,44 @@ function ReadyBody(props) {
7095
7427
  className: "mc-panel-inner",
7096
7428
  children: skills.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
7097
7429
  className: "mc-empty",
7098
- children: t("emptySkills")
7430
+ children: needle === "" ? t("emptySkills") : t("filterNoMatch")
7099
7431
  }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
7100
7432
  className: "mc-subtabs",
7101
7433
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
7102
7434
  className: "mc-tab-group",
7103
7435
  role: "tablist",
7104
7436
  "aria-label": t("skillsGroup"),
7105
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
7106
- type: "button",
7107
- role: "tab",
7108
- className: "mc-tab",
7109
- "aria-selected": skillTab === "global",
7110
- "data-active": skillTab === "global" ? "true" : void 0,
7111
- onClick: () => setSkillTab("global"),
7112
- children: t("globalSkills")
7113
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
7114
- type: "button",
7115
- role: "tab",
7116
- className: "mc-tab",
7117
- "aria-selected": skillTab === "project",
7118
- "data-active": skillTab === "project" ? "true" : void 0,
7119
- onClick: () => setSkillTab("project"),
7120
- children: t("projectSkills")
7121
- })]
7437
+ children: [
7438
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
7439
+ type: "button",
7440
+ role: "tab",
7441
+ className: "mc-tab",
7442
+ "aria-selected": activeSkillTab === "global",
7443
+ "data-active": activeSkillTab === "global" ? "true" : void 0,
7444
+ onClick: () => setSkillTab("global"),
7445
+ children: t("globalSkills")
7446
+ }),
7447
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
7448
+ type: "button",
7449
+ role: "tab",
7450
+ className: "mc-tab",
7451
+ "aria-selected": activeSkillTab === "project",
7452
+ "data-active": activeSkillTab === "project" ? "true" : void 0,
7453
+ onClick: () => setSkillTab("project"),
7454
+ children: t("projectSkills")
7455
+ }),
7456
+ presetSkills.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
7457
+ type: "button",
7458
+ role: "tab",
7459
+ className: "mc-tab",
7460
+ "aria-selected": activeSkillTab === "preset",
7461
+ "data-active": activeSkillTab === "preset" ? "true" : void 0,
7462
+ onClick: () => setSkillTab("preset"),
7463
+ children: t("presetSkills")
7464
+ })
7465
+ ]
7122
7466
  })
7123
- }), skillTab === "global" ? globalSkills.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SkillList, {
7467
+ }), activeSkillTab === "global" ? globalSkills.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SkillList, {
7124
7468
  skills: globalSkills,
7125
7469
  remote,
7126
7470
  busy,
@@ -7132,7 +7476,7 @@ function ReadyBody(props) {
7132
7476
  }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
7133
7477
  className: "mc-empty",
7134
7478
  children: t("emptyGlobalSkills")
7135
- }) : projectSkills.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SkillList, {
7479
+ }) : activeSkillTab === "project" ? projectSkills.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SkillList, {
7136
7480
  skills: projectSkills,
7137
7481
  remote,
7138
7482
  busy,
@@ -7144,7 +7488,22 @@ function ReadyBody(props) {
7144
7488
  }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
7145
7489
  className: "mc-empty",
7146
7490
  children: t("emptyProjectSkills")
7147
- })] })
7491
+ }) : presetGroups.map(([preset, group]) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
7492
+ className: "mc-skill-group",
7493
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
7494
+ className: "mc-skill-group-title",
7495
+ children: preset
7496
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SkillList, {
7497
+ skills: group,
7498
+ remote,
7499
+ busy,
7500
+ t,
7501
+ onCycle,
7502
+ editableSkills,
7503
+ onEditSkill: openSkillEdit,
7504
+ onAdoptSkill: adoptSkill
7505
+ })]
7506
+ }, preset))] })
7148
7507
  })
7149
7508
  }),
7150
7509
  toolDetail !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
@@ -7498,7 +7857,11 @@ function SkillList(props) {
7498
7857
  "aria-hidden": "true"
7499
7858
  }), t(CLASS_SHORT_KEYS[skill.class])]
7500
7859
  }),
7501
- !editableSkills.has(skill.id) && (ADOPTABLE_SKILL_SOURCES.has(skill.source ?? "") ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
7860
+ !editableSkills.has(skill.id) && (skill.preset !== void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
7861
+ className: "mc-source",
7862
+ title: t("skillFromPresetHint"),
7863
+ children: t("skillFromPreset", { preset: skill.preset })
7864
+ }) : ADOPTABLE_SKILL_SOURCES.has(skill.source ?? "") ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
7502
7865
  type: "button",
7503
7866
  className: "mc-count mc-count--action",
7504
7867
  disabled: busy,
@@ -7638,8 +8001,12 @@ async function apply(ctx) {
7638
8001
  builtInGroup: "系统内置",
7639
8002
  globalSkills: "全局技能",
7640
8003
  projectSkills: "项目技能",
8004
+ presetSkills: "预设技能",
7641
8005
  emptyGlobalSkills: "暂无全局技能",
7642
8006
  emptyProjectSkills: "暂无项目技能",
8007
+ filterByName: "按名字、分组或正则过滤…",
8008
+ filterHint: "忽略大小写。匹配工具/技能名、MCP server、技能来源标签与预设 id;也可以直接写正则,例如 cordis|ptc 或 /^mcp__dbx/。",
8009
+ filterNoMatch: "没有匹配的能力(清空过滤框可恢复完整列表)",
7643
8010
  emptyTools: "暂无工具",
7644
8011
  emptySkills: "暂无 Skill",
7645
8012
  toolCount: "{count} 个工具",
@@ -7651,8 +8018,6 @@ async function apply(ctx) {
7651
8018
  previewClose: "关闭",
7652
8019
  detailNotFound: "未找到该工具的详情",
7653
8020
  cycleOverridden: "分类未生效:{count} 个能力被更高优先级规则覆盖(如通配规则),可移除对应通配规则后重试",
7654
- refresh: "刷新",
7655
- refreshing: "刷新中…",
7656
8021
  refreshFailed: "刷新失败,请查看日志",
7657
8022
  retry: "重试",
7658
8023
  carrierFailureHint: "请求未到达服务端:浏览器与 dsh 之间的连接已断开(Failed to fetch 属传输层失败,服务端方法未执行)。若刚重启过 dsh web,请先硬刷新页面(Ctrl/Cmd+Shift+R)再重试。",
@@ -7687,6 +8052,8 @@ async function apply(ctx) {
7687
8052
  skillAdopted: "已纳入管理:{path}",
7688
8053
  skillSource: "来源:{source}",
7689
8054
  skillSourceHint: "该技能由 dsh 从其它根发现,不在本插件管理的技能目录里,因此没有「编辑」。",
8055
+ skillFromPreset: "来自预设 {preset}",
8056
+ skillFromPresetHint: "该技能随 agent preset 分发,只对挂载了该预设的会话可见;不能纳入管理(那会把它软链进全局技能根,对所有会话生效)。",
7690
8057
  skillUnmanaged: "未纳入管理",
7691
8058
  sourceCustom: "自定义技能目录",
7692
8059
  sourceBundled: "随 dsh 预置",
@@ -7732,8 +8099,12 @@ async function apply(ctx) {
7732
8099
  builtInGroup: "System built-in",
7733
8100
  globalSkills: "Global skills",
7734
8101
  projectSkills: "Project skills",
8102
+ presetSkills: "Preset skills",
7735
8103
  emptyGlobalSkills: "No global skills",
7736
8104
  emptyProjectSkills: "No project skills",
8105
+ filterByName: "Filter by name, group, or regex…",
8106
+ filterHint: "Case-insensitive. Matches tool/skill names, MCP servers, skill source labels and preset ids; a regular expression works too, e.g. cordis|ptc or /^mcp__dbx/.",
8107
+ filterNoMatch: "No capability matches (clear the filter to see the full list)",
7737
8108
  emptyTools: "No tools",
7738
8109
  emptySkills: "No skills",
7739
8110
  toolCount: "{count} tools",
@@ -7745,8 +8116,6 @@ async function apply(ctx) {
7745
8116
  previewClose: "Close",
7746
8117
  detailNotFound: "Tool detail not found",
7747
8118
  cycleOverridden: "Classification not applied: {count} capability(ies) overridden by a higher-priority rule (e.g. a wildcard). Remove the matching wildcard rule and retry.",
7748
- refresh: "Refresh",
7749
- refreshing: "Refreshing…",
7750
8119
  refreshFailed: "Refresh failed; see the log",
7751
8120
  retry: "Retry",
7752
8121
  carrierFailureHint: "The request never reached the server: the browser connection to dsh is broken (\"Failed to fetch\" is a transport failure, so no server method ran). If dsh web was just restarted, hard-refresh the page (Ctrl/Cmd+Shift+R) and retry.",
@@ -7781,6 +8150,8 @@ async function apply(ctx) {
7781
8150
  skillAdopted: "Adopted: {path}",
7782
8151
  skillSource: "Source: {source}",
7783
8152
  skillSourceHint: "dsh found this skill in another root, not in a skill directory this plugin manages, so there is no Edit button.",
8153
+ skillFromPreset: "From preset {preset}",
8154
+ skillFromPresetHint: "This skill ships with an agent preset and is visible only to sessions that mount it. It cannot be adopted (adopting would link it into the global skill root, making it apply to every session).",
7784
8155
  skillUnmanaged: "Not managed here",
7785
8156
  sourceCustom: "custom skill dirs",
7786
8157
  sourceBundled: "bundled with dsh",
@@ -7832,10 +8203,44 @@ async function apply(ctx) {
7832
8203
  return;
7833
8204
  }
7834
8205
  };
8206
+ /**
8207
+ * The two signals that say "the host catalog may have moved, and this page did
8208
+ * not ask": a settings document edit, and a carrier reconnect.
8209
+ *
8210
+ * `settings/document-updated` is one of the events dsh forwards to browsers
8211
+ * (so it arrives over the same validated channel as the remote calls) and it
8212
+ * fires for external edits too — composition files are edited outside the
8213
+ * browser, and nothing else on the wire announces that. `connection/reset` is
8214
+ * the client's own reconnect signal rather than a forwarded one, which is why
8215
+ * it is a plain local `ctx.on`; dsh's own settings sections listen to exactly
8216
+ * this pair, including the first connection, which closes the window where a
8217
+ * change lands between the initial read and the subscription.
8218
+ *
8219
+ * Neither subscription is fatal: the section falls back to its version poll.
8220
+ */
8221
+ const subscribeSignals = (listener) => {
8222
+ const disposers = [];
8223
+ const forwarded = ctx.remote;
8224
+ try {
8225
+ if (typeof forwarded.$on !== "function") throw new Error("ctx.remote.$on is unavailable");
8226
+ disposers.push(forwarded.$on("settings/document-updated", () => listener()));
8227
+ } catch (error) {
8228
+ console.error("[capability-menu] settings/document-updated subscription failed:", error);
8229
+ }
8230
+ try {
8231
+ disposers.push(ctx.on("connection/reset", () => listener()));
8232
+ } catch (error) {
8233
+ console.error("[capability-menu] connection/reset subscription failed:", error);
8234
+ }
8235
+ return () => {
8236
+ for (const dispose of disposers) dispose();
8237
+ };
8238
+ };
7835
8239
  const injected = () => {
7836
8240
  return {
7837
8241
  remote: remote(),
7838
8242
  t,
8243
+ subscribeSignals,
7839
8244
  ...mountError !== void 0 ? { mountError } : {}
7840
8245
  };
7841
8246
  };