@deepseek-ai/dsh-client-ui-commands 0.1.7-rc.2 → 0.2.0-rc.2

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/README.i18n.yaml CHANGED
@@ -18,8 +18,8 @@
18
18
  en: 01adce72791579fa
19
19
  zh: 70eb55a1529038e7
20
20
  /deepseek-ai-dsh-client-ui-commands/use-this-package/kinds-and-decorations:
21
- en: 86841942f6989fbb
22
- zh: 901557d3a5d0528e
21
+ en: 57679a20574d8b3f
22
+ zh: 98d5e5a7781e6de1
23
23
  /deepseek-ai-dsh-client-ui-commands/use-this-package/built-in-row-faces:
24
24
  en: e96156be95ea87e3
25
25
  zh: f5350db26483ec9f
@@ -27,8 +27,8 @@
27
27
  en: 4feecdbec0c3f39e
28
28
  zh: 618944ecade8a3a2
29
29
  /deepseek-ai-dsh-client-ui-commands/understand-the-implementation:
30
- en: 0fd2b19e41605440
31
- zh: 52d5c653b877a1f0
30
+ en: 3aeeba6c91c3ef19
31
+ zh: d3afb3df6d15e17b
32
32
  /deepseek-ai-dsh-client-ui-commands/further-exploration:
33
33
  en: 409c04f534d23de7
34
34
  zh: 165022d7a0a554eb
package/README.md CHANGED
@@ -31,6 +31,12 @@ Mount this plugin alongside `ui-input-trigger` and `ui-conversation`; the `/` so
31
31
 
32
32
  A contribution is a client-owned command; a host-name collision fails loudly. Its UI is a popupSelect spec or an action: a callback a bare invocation runs after the trigger token is consumed, without submitting a message. Business packages own their actions and availability; the composer registers File through this same API. A decoration adds a bare-invocation popup or action to an existing host command while preserving its catalog row, argument claim, and lifecycle logging; it never fires without a matching host row. Menu queries fuzzy-match ordered, case-insensitive subsequences of command names and titles, with prefixes first and no section headings.
33
33
 
34
+ A popupSelect spec may supply `searchLabels()` to resolve its placeholder, empty-catalog text, and no-match text for each opening. Omitted labels retain the shell's generic copy; labels from one popup never carry into the next.
35
+
36
+ Options may carry `group: { name, label }`: `name` identifies the group and `label` supplies its localized sticky heading. Equal names share one group; groups retain first-occurrence order in the loaded options, including when a query hides earlier rows. Ungrouped options form one headingless block at their first occurrence. Groups without matching rows are hidden.
37
+
38
+ The optional `searchMode` defaults to `'substring'`, matching `label` and `detail` case-insensitively without ranking. `'fuzzy-label'` matches only labels as ordered, case-insensitive subsequences and ranks within each group: prefixes first, then alignment score, then original row order. Both modes ignore leading and trailing query spaces; a blank query keeps every row in group order. Rendering, keyboard navigation, and selection use the same filtered order. `/model` opts into `'fuzzy-label'`; other commands retain substring matching unless they opt in.
39
+
34
40
  ### Built-in row faces
35
41
 
36
42
  First-party command definitions carry stable `definitionId` values. The client selects their localized titles, descriptions, icons, and input spellings by identity; changing a Host description cannot change that selection. Same-name overrides without the matching identity keep their own copy and receive no first-party aliases. Chinese and English spellings resolve through the same effective Session catalog in every locale, preserving the typed spelling in the draft and submitting the registered Host name. Contributions supply their own `label`, `description`, and `icon`, read on every candidate pass. Empty-query section order follows names, with unlisted rows closing Commands.
@@ -44,7 +50,7 @@ When the composer submits with images or generic files, only a host command decl
44
50
  <a id="understand-the-implementation"></a>
45
51
  ## Understand the implementation
46
52
 
47
- Menus use the shared `MenuSurface` material, including the macOS backing for background blur; custom content follows the [menu rules](../../../docs/web-styling.md#component-rules).
53
+ Popup selection panels fill the composer overlay's width, matching the slash menu. Popup search fields keep transparent backgrounds and borders in both palettes. Grouped options use [ui-primitives](../ui-primitives/README.md#understand-the-implementation)' shared `MenuGroup` headings and `observeStickyMenuGroups`; an ordinary effect owns the asynchronous observation and rebuilds it when rendered groups change. Menus use the shared `MenuSurface` material, including the macOS backing for background blur; custom content follows the [menu rules](../../../docs/web-styling.md#component-rules).
48
54
 
49
55
  <details>
50
56
  <summary>Implementation internals — click to expand</summary>
package/README.zh.md CHANGED
@@ -31,6 +31,12 @@ kind: "package-reference"
31
31
 
32
32
  贡献项是客户端自有命令,与宿主命令同名会明确报错。它的 UI 是 popupSelect 规格或动作:裸调用消费触发 token 后运行回调,不提交消息。业务包负责自己的动作及可用性,输入框通过同一 API 注册「文件」。装饰为已有宿主命令添加裸调用弹窗或动作,并保留其目录行、参数认领与生命周期记录;没有匹配的宿主行时不触发。菜单查询按顺序、不区分大小写地模糊匹配命令名与标题的子序列,前缀优先,不显示小节标题。
33
33
 
34
+ popupSelect 规格可提供 `searchLabels()`,在每次打开时解析占位文字、空目录提示和无匹配提示。未提供时保留外壳的通用文案;一个弹窗的文案不会沿用到下一个弹窗。
35
+
36
+ 选项可携带 `group: { name, label }`:`name` 标识分组,`label` 提供本地化吸顶标题。同名选项归入同一组;分组保持在已加载选项中首次出现的顺序,即使查询隐藏了较早的行也不变。未分组选项在首次出现的位置组成一个无标题区块。没有匹配行的分组会隐藏。
37
+
38
+ 可选的 `searchMode` 默认为 `'substring'`,不区分大小写地匹配 `label` 与 `detail` 的子串,不进行相关性排序。`'fuzzy-label'` 仅匹配标签中按顺序、不区分大小写的子序列,并在组内排序:前缀优先,其次按匹配得分,最后按原始行顺序。两种模式均忽略查询首尾空格;空查询按分组顺序保留所有行。渲染、键盘导航与选择使用相同的筛选顺序。`/model` 启用 `'fuzzy-label'`;其他命令未显式启用时仍使用子串匹配。
39
+
34
40
  ### 内置行的展示面
35
41
 
36
42
  内置命令定义携带稳定的 `definitionId`。客户端按标识选择本地化标题、说明、图标和输入写法,修改宿主说明不会改变选择结果。没有匹配标识的同名覆盖保留自己的文案,也不获得内置别名。在任何界面语言下,中英文写法都通过同一个会话有效目录解析,草稿保留手输写法,提交使用宿主注册名。贡献项提供自己的 `label`、`description` 和 `icon`,每次生成候选项时读取。空查询按名称确定小节顺序,未列出的行排在「指令」末尾。
@@ -44,7 +50,7 @@ composer 携带图片或通用文件提交时,只有声明了 `input.attachmen
44
50
  <a id="understand-the-implementation"></a>
45
51
  ## 理解实现
46
52
 
47
- 菜单采用共享 `MenuSurface` 材质,包括用于背景模糊的 macOS 底层;自定义内容遵循[菜单规则](../../../docs/web-styling.zh.md#component-rules)。
53
+ 弹窗选择面板撑满 composer 浮层宽度,与斜杠菜单一致。弹窗搜索框在浅/深色主题下均保持背景和边框透明。分组选项使用 [ui-primitives](../ui-primitives/README.zh.md#understand-the-implementation) 共享的 `MenuGroup` 标题与 `observeStickyMenuGroups`;普通 effect 负责异步观察,并在渲染分组变化时清理和重建观察器。菜单采用共享 `MenuSurface` 材质,包括用于背景模糊的 macOS 底层;自定义内容遵循[菜单规则](../../../docs/web-styling.zh.md#component-rules)。
48
54
 
49
55
  <details>
50
56
  <summary>实现细节——点击展开</summary>
package/lib/client.js CHANGED
@@ -334,6 +334,27 @@ window.__ModuleLoader__.load({
334
334
  return signal.reason instanceof Error ? signal.reason : /* @__PURE__ */ new Error("command directory wait aborted");
335
335
  }
336
336
  //#endregion
337
+ //#region lib/types/client/option-groups.js
338
+ /**
339
+ * Group rows by their caller-owned group name, without sorting groups or rows.
340
+ * Ungrouped rows occupy one block at their first occurrence.
341
+ * @param options - Options in display order.
342
+ * @returns groups in first-occurrence order, with original option objects.
343
+ */
344
+ function groupOptions(options) {
345
+ const groups = /* @__PURE__ */ new Map();
346
+ for (const option of options) {
347
+ const name = option.group?.name;
348
+ const existing = groups.get(name);
349
+ if (existing !== void 0) existing.rows.push(option);
350
+ else groups.set(name, {
351
+ group: option.group,
352
+ rows: [option]
353
+ });
354
+ }
355
+ return [...groups.values()];
356
+ }
357
+ //#endregion
337
358
  //#region lib/types/client/popup.js
338
359
  /**
339
360
  * Headless popupSelect shell state: one controller per client
@@ -352,6 +373,8 @@ window.__ModuleLoader__.load({
352
373
  status: "pending",
353
374
  options: [],
354
375
  search: "",
376
+ searchLabels: null,
377
+ searchMode: "substring",
355
378
  active: 0,
356
379
  submitting: false,
357
380
  confirming: null,
@@ -359,16 +382,23 @@ window.__ModuleLoader__.load({
359
382
  error: null
360
383
  };
361
384
  /**
362
- * Filter option rows against the shell's local search text (case-insensitive
363
- * substring over label and detail; blank search keeps every row).
385
+ * Filter rows using substring matching, or rank labels fuzzily within each group.
386
+ * Blank search keeps every row; fuzzy matching preserves group order.
364
387
  * @param options - the loaded rows.
365
388
  * @param search - the shell's search text.
366
- * @returns the rows the shell shows and highlights over.
389
+ * @param mode - the command's policy; defaults to substring over label and detail.
390
+ * @returns the original option objects in the order shown and used for selection.
367
391
  */
368
- function filterOptions(options, search) {
392
+ function filterOptions(options, search, mode = "substring") {
369
393
  const query = search.trim().toLowerCase();
370
- if (query === "") return options;
371
- return options.filter((o) => o.label.toLowerCase().includes(query) || (o.detail?.toLowerCase().includes(query) ?? false));
394
+ const groups = groupOptions(options);
395
+ const ordered = groups.some((group) => group.group !== void 0) ? groups.flatMap((group) => group.rows) : options;
396
+ if (query === "") return ordered;
397
+ if (mode === "fuzzy-label") return groups.flatMap((group) => (0, _deepseek_ai_dsh_client_ui_primitives.rankByName)(group.rows.map((option) => ({
398
+ name: option.label,
399
+ option
400
+ })), query).map((row) => row.option));
401
+ return ordered.filter((o) => o.label.toLowerCase().includes(query) || (o.detail?.toLowerCase().includes(query) ?? false));
372
402
  }
373
403
  /**
374
404
  * Highlight index for a freshly loaded row list: the row marked as the current
@@ -377,10 +407,11 @@ window.__ModuleLoader__.load({
377
407
  * made without looking confirms that value instead of the topmost row.
378
408
  * @param options - the loaded rows.
379
409
  * @param search - the shell's live filter text (non-empty after a retry).
410
+ * @param mode - the command's search policy.
380
411
  * @returns index into the filtered rows.
381
412
  */
382
- function currentIndex(options, search) {
383
- const at = filterOptions(options, search).findIndex((option) => option.active === true);
413
+ function currentIndex(options, search, mode) {
414
+ const at = filterOptions(options, search, mode).findIndex((option) => option.active === true);
384
415
  return at === -1 ? 0 : at;
385
416
  }
386
417
  /** The shell's error-strip line for a settlement failure. */
@@ -414,6 +445,7 @@ window.__ModuleLoader__.load({
414
445
  * @param segment - open-time token segment snapshot for post-select consumption.
415
446
  */
416
447
  open(command, spec, context, segment) {
448
+ const searchLabels = spec.searchLabels?.() ?? null;
417
449
  this.binding?.abort.abort();
418
450
  const binding = {
419
451
  command,
@@ -426,7 +458,9 @@ window.__ModuleLoader__.load({
426
458
  this.state.set({
427
459
  ...CLOSED,
428
460
  open: true,
429
- command
461
+ command,
462
+ searchLabels,
463
+ searchMode: spec.searchMode ?? "substring"
430
464
  });
431
465
  this.load(binding);
432
466
  }
@@ -439,7 +473,7 @@ window.__ModuleLoader__.load({
439
473
  ...current,
440
474
  status: "ready",
441
475
  options,
442
- active: currentIndex(options, current.search),
476
+ active: currentIndex(options, current.search, current.searchMode),
443
477
  error: null
444
478
  });
445
479
  }, (error) => {
@@ -489,7 +523,7 @@ window.__ModuleLoader__.load({
489
523
  move(dir) {
490
524
  const s = this.state.getSnapshot();
491
525
  if (!s.open || s.status !== "ready" || s.submitting || s.confirming !== null) return;
492
- const rows = filterOptions(s.options, s.search);
526
+ const rows = filterOptions(s.options, s.search, s.searchMode);
493
527
  if (rows.length === 0) return;
494
528
  const active = (s.active + dir + rows.length) % rows.length;
495
529
  this.state.set({
@@ -505,7 +539,7 @@ window.__ModuleLoader__.load({
505
539
  highlight(index) {
506
540
  const s = this.state.getSnapshot();
507
541
  if (!s.open || s.status !== "ready" || s.submitting || s.confirming !== null) return;
508
- if (index < 0 || index >= filterOptions(s.options, s.search).length || index === s.active) return;
542
+ if (index < 0 || index >= filterOptions(s.options, s.search, s.searchMode).length || index === s.active) return;
509
543
  this.state.set({
510
544
  ...s,
511
545
  active: index
@@ -525,7 +559,7 @@ window.__ModuleLoader__.load({
525
559
  const binding = this.binding;
526
560
  const s = this.state.getSnapshot();
527
561
  if (binding === null || !s.open || s.status !== "ready" || s.submitting || s.confirming !== null) return;
528
- const option = filterOptions(s.options, s.search)[index];
562
+ const option = filterOptions(s.options, s.search, s.searchMode)[index];
529
563
  if (option === void 0) return;
530
564
  if (option.confirmation !== void 0) {
531
565
  this.state.set({
@@ -1129,7 +1163,7 @@ window.__ModuleLoader__.load({
1129
1163
  }
1130
1164
  //#endregion
1131
1165
  //#region \0dsh-css:/home/runner/work/deepseek-harness/deepseek-harness/packages/client/ui-commands/src/client/PopupSelectView.module.css.mjs
1132
- const css = ".mufS8W_card{z-index:100;--dsh-scrollbar-thumb:var(--dsw-alias-scrollbar-bg-l2);--dsh-scrollbar-thumb-hover:var(--dsw-alias-scrollbar-hover-l2);--dsw-elevation-stroke-color:var(--dsw-alias-border-l1);min-width:min(220px,100%);max-width:100%;max-height:320px;box-shadow:var(--dsw-elevation-prominent);border:0;outline:none;flex-direction:column;padding:3px;display:flex;position:absolute;bottom:calc(100% + 4px);left:0;overflow:hidden}.mufS8W_viewport{flex-direction:column;min-height:0;display:flex;overflow-y:auto}.mufS8W_row{border-radius:var(--dsw-radius-md);cursor:pointer;color:var(--dsw-alias-label-primary);align-items:center;gap:6px;padding:5px 7px;font-size:12px;display:flex}.mufS8W_rowActive{background:var(--dsw-alias-interactive-bg-hover)}.mufS8W_label{flex:0 auto;align-items:baseline;gap:4px;min-width:0;display:flex}.mufS8W_labelText{white-space:nowrap;text-overflow:ellipsis;min-width:0;overflow:hidden}.mufS8W_badge{color:var(--dsw-alias-label-tertiary);letter-spacing:.2px;flex:none;align-self:flex-start;margin-top:-1px;font-size:8px;font-weight:600;line-height:10px}.mufS8W_detail{min-width:0;color:var(--dsw-alias-label-tertiary);white-space:nowrap;text-overflow:ellipsis;flex:1;font-size:11px;overflow:hidden}.mufS8W_check{color:var(--dsw-alias-label-primary);flex:none;margin-left:auto;display:inline-flex}.mufS8W_check svg{width:14px;height:14px}.mufS8W_status{color:var(--dsw-alias-label-tertiary);padding:7px 8px;font-size:12px}.mufS8W_search{border:.5px solid var(--dsw-alias-border-inverted);border-radius:var(--dsw-radius-md);color:var(--dsw-alias-label-primary);background:0 0;outline:none;margin:2px 2px 3px;padding:5px 7px;font-size:12px}.mufS8W_error{color:var(--dsw-alias-state-error-primary);align-items:center;gap:6px;padding:5px 7px;font-size:11px;display:flex}.mufS8W_errorText{text-overflow:ellipsis;flex:1;overflow:hidden}.mufS8W_retry{border:.5px solid var(--dsw-alias-border-inverted);border-radius:var(--dsw-radius-sm);color:var(--dsw-alias-label-primary);cursor:pointer;background:0 0;padding:2px 7px;font-size:11px}";
1166
+ const css = ".mufS8W_card{box-sizing:border-box;z-index:100;--dsh-scrollbar-thumb:var(--dsw-alias-scrollbar-bg-l2);--dsh-scrollbar-thumb-hover:var(--dsw-alias-scrollbar-hover-l2);--dsw-elevation-stroke-color:var(--dsw-alias-border-l1);min-width:min(220px,100%);max-width:100%;max-height:320px;box-shadow:var(--dsw-elevation-prominent);border:0;outline:none;flex-direction:column;padding:3px;display:flex;position:absolute;bottom:calc(100% + 4px);left:0;right:0;overflow:hidden}.mufS8W_viewport{flex-direction:column;min-height:0;display:flex;overflow-y:auto}.mufS8W_row{border-radius:var(--dsw-radius-md);cursor:pointer;color:var(--dsw-alias-label-primary);align-items:center;gap:6px;padding:5px 7px;font-size:12px;display:flex}.mufS8W_rowActive{background:var(--dsw-alias-interactive-bg-hover)}.mufS8W_label{flex:0 auto;align-items:baseline;gap:4px;min-width:0;display:flex}.mufS8W_labelText{white-space:nowrap;text-overflow:ellipsis;min-width:0;overflow:hidden}.mufS8W_badge{color:var(--dsw-alias-label-tertiary);letter-spacing:.2px;flex:none;align-self:flex-start;margin-top:-1px;font-size:8px;font-weight:600;line-height:10px}.mufS8W_detail{min-width:0;color:var(--dsw-alias-label-tertiary);white-space:nowrap;text-overflow:ellipsis;flex:1;font-size:11px;overflow:hidden}.mufS8W_check{color:var(--dsw-alias-label-primary);flex:none;margin-left:auto;display:inline-flex}.mufS8W_check svg{width:14px;height:14px}.mufS8W_status{color:var(--dsw-alias-label-tertiary);padding:7px 8px;font-size:12px}.mufS8W_search{border-radius:var(--dsw-radius-md);color:var(--dsw-alias-label-primary);background:0 0;border:.5px solid #0000;outline:none;margin:2px 2px 3px;padding:5px 7px;font-size:12px}.mufS8W_error{color:var(--dsw-alias-state-error-primary);align-items:center;gap:6px;padding:5px 7px;font-size:11px;display:flex}.mufS8W_errorText{text-overflow:ellipsis;flex:1;overflow:hidden}.mufS8W_retry{border:.5px solid var(--dsw-alias-border-inverted);border-radius:var(--dsw-radius-sm);color:var(--dsw-alias-label-primary);cursor:pointer;background:0 0;padding:2px 7px;font-size:11px}";
1133
1167
  const tagId = "@deepseek-ai/dsh-client-ui-commands/PopupSelectView.module.css";
1134
1168
  if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
1135
1169
  const tag = document.createElement("style");
@@ -1179,12 +1213,33 @@ window.__ModuleLoader__.load({
1179
1213
  const state = (0, react.useSyncExternalStore)((fn) => popup.state.subscribe(fn), () => popup.state.getSnapshot());
1180
1214
  const cardRef = (0, react.useRef)(null);
1181
1215
  const searchRef = (0, react.useRef)(null);
1216
+ const viewportRef = (0, react.useRef)(null);
1217
+ const rows = (0, react.useMemo)(() => filterOptions(state.options, state.search, state.searchMode), [
1218
+ state.options,
1219
+ state.search,
1220
+ state.searchMode
1221
+ ]);
1222
+ const groups = (0, react.useMemo)(() => groupOptions(rows), [rows]);
1223
+ (0, react.useEffect)(() => {
1224
+ const viewport = viewportRef.current;
1225
+ if (viewport === null) return;
1226
+ return (0, _deepseek_ai_dsh_client_ui_primitives.observeStickyMenuGroups)(viewport);
1227
+ }, [
1228
+ state.open,
1229
+ state.status,
1230
+ state.confirming,
1231
+ groups
1232
+ ]);
1182
1233
  const maxHeight = (0, _deepseek_ai_dsh_client_ui_primitives.useAnchoredMaxHeight)(cardRef, MAX_HEIGHT, state);
1183
1234
  const active = state.open ? state.active : null;
1184
1235
  (0, react.useEffect)(() => {
1185
1236
  if (active === null) return;
1186
1237
  cardRef.current?.querySelector("[aria-selected=\"true\"]")?.scrollIntoView({ block: "nearest" });
1187
- }, [active]);
1238
+ }, [
1239
+ active,
1240
+ rows,
1241
+ state.confirming
1242
+ ]);
1188
1243
  (0, react.useEffect)(() => {
1189
1244
  if (!state.open || state.confirming !== null) return;
1190
1245
  const onPointerDown = (ev) => {
@@ -1204,8 +1259,8 @@ window.__ModuleLoader__.load({
1204
1259
  if (state.open && state.confirming === null) searchRef.current?.focus();
1205
1260
  }, [state.open, state.confirming]);
1206
1261
  if (!state.open) return null;
1207
- const rows = filterOptions(state.options, state.search);
1208
1262
  const confirmation = state.confirming?.confirmation;
1263
+ const emptyLabel = state.searchLabels === null ? t("status.empty") : state.options.length === 0 ? state.searchLabels.empty : state.searchLabels.noResults;
1209
1264
  const onKeyDown = (ev) => {
1210
1265
  switch (ev.key) {
1211
1266
  case "ArrowDown":
@@ -1233,6 +1288,39 @@ window.__ModuleLoader__.load({
1233
1288
  default:
1234
1289
  }
1235
1290
  };
1291
+ const renderOption = (option, index) => (0, react_jsx_runtime.jsxs)("div", {
1292
+ role: "option",
1293
+ "aria-selected": index === state.active,
1294
+ "aria-label": option.badge === void 0 ? void 0 : `${option.label} ${option.badge}`,
1295
+ className: clsx(PopupSelectView_module_css_default.row, index === state.active && PopupSelectView_module_css_default.rowActive),
1296
+ onClick: () => {
1297
+ popup.select(index);
1298
+ },
1299
+ onMouseEnter: () => {
1300
+ popup.highlight(index);
1301
+ },
1302
+ children: [
1303
+ (0, react_jsx_runtime.jsxs)("span", {
1304
+ className: PopupSelectView_module_css_default.label,
1305
+ children: [(0, react_jsx_runtime.jsx)("span", {
1306
+ className: PopupSelectView_module_css_default.labelText,
1307
+ children: option.label
1308
+ }), option.badge !== void 0 && (0, react_jsx_runtime.jsx)("sup", {
1309
+ className: PopupSelectView_module_css_default.badge,
1310
+ children: option.badge
1311
+ })]
1312
+ }),
1313
+ option.detail !== void 0 && (0, react_jsx_runtime.jsx)("span", {
1314
+ className: PopupSelectView_module_css_default.detail,
1315
+ children: option.detail
1316
+ }),
1317
+ option.active === true && (0, react_jsx_runtime.jsx)("span", {
1318
+ className: PopupSelectView_module_css_default.check,
1319
+ children: (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconCheckOutlineRegular, {})
1320
+ })
1321
+ ]
1322
+ }, option.id);
1323
+ let optionIndex = 0;
1236
1324
  return (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [state.confirming === null && (0, react_jsx_runtime.jsxs)(_deepseek_ai_dsh_client_ui_primitives.MenuSurface, {
1237
1325
  ref: cardRef,
1238
1326
  className: PopupSelectView_module_css_default.card,
@@ -1244,7 +1332,7 @@ window.__ModuleLoader__.load({
1244
1332
  ref: searchRef,
1245
1333
  className: PopupSelectView_module_css_default.search,
1246
1334
  type: "text",
1247
- placeholder: t("search.placeholder"),
1335
+ placeholder: state.searchLabels?.placeholder ?? t("search.placeholder"),
1248
1336
  "aria-label": t("search.aria"),
1249
1337
  value: state.search,
1250
1338
  readOnly: state.submitting,
@@ -1277,44 +1365,17 @@ window.__ModuleLoader__.load({
1277
1365
  }),
1278
1366
  state.status === "ready" && rows.length === 0 && (0, react_jsx_runtime.jsx)("div", {
1279
1367
  className: PopupSelectView_module_css_default.status,
1280
- children: t("status.empty")
1368
+ children: emptyLabel
1281
1369
  }),
1282
1370
  state.status === "ready" && (0, react_jsx_runtime.jsx)("div", {
1371
+ ref: viewportRef,
1283
1372
  role: "listbox",
1284
1373
  "aria-label": t("listbox.aria", { command: String(state.command) }),
1285
1374
  className: PopupSelectView_module_css_default.viewport,
1286
- children: rows.map((option, index) => (0, react_jsx_runtime.jsxs)("div", {
1287
- role: "option",
1288
- "aria-selected": index === state.active,
1289
- "aria-label": option.badge === void 0 ? void 0 : `${option.label} ${option.badge}`,
1290
- className: clsx(PopupSelectView_module_css_default.row, index === state.active && PopupSelectView_module_css_default.rowActive),
1291
- onClick: () => {
1292
- popup.select(index);
1293
- },
1294
- onMouseEnter: () => {
1295
- popup.highlight(index);
1296
- },
1297
- children: [
1298
- (0, react_jsx_runtime.jsxs)("span", {
1299
- className: PopupSelectView_module_css_default.label,
1300
- children: [(0, react_jsx_runtime.jsx)("span", {
1301
- className: PopupSelectView_module_css_default.labelText,
1302
- children: option.label
1303
- }), option.badge !== void 0 && (0, react_jsx_runtime.jsx)("sup", {
1304
- className: PopupSelectView_module_css_default.badge,
1305
- children: option.badge
1306
- })]
1307
- }),
1308
- option.detail !== void 0 && (0, react_jsx_runtime.jsx)("span", {
1309
- className: PopupSelectView_module_css_default.detail,
1310
- children: option.detail
1311
- }),
1312
- option.active === true && (0, react_jsx_runtime.jsx)("span", {
1313
- className: PopupSelectView_module_css_default.check,
1314
- children: (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconCheckOutlineRegular, {})
1315
- })
1316
- ]
1317
- }, option.id))
1375
+ children: groups.map(({ group, rows: groupRows }) => group === void 0 ? (0, react_jsx_runtime.jsx)(react.Fragment, { children: groupRows.map((option) => renderOption(option, optionIndex++)) }, "ungrouped") : (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.MenuGroup, {
1376
+ label: group.label,
1377
+ children: groupRows.map((option) => renderOption(option, optionIndex++))
1378
+ }, `group:${group.name}`))
1318
1379
  })
1319
1380
  ]
1320
1381
  }), confirmation !== void 0 && (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.RiskConfirmation, {
@@ -15,6 +15,13 @@ export interface SelectConfirmation {
15
15
  readonly cancelLabel: string;
16
16
  readonly confirmLabel: string;
17
17
  }
18
+ /** Caller-owned group name and localized heading for popup options. */
19
+ export interface SelectOptionGroup {
20
+ readonly name: string;
21
+ readonly label: string;
22
+ }
23
+ /** Search behavior selected by the popup owner. */
24
+ export type PopupSearchMode = 'substring' | 'fuzzy-label';
18
25
  /** One option row of a popupSelect shell. */
19
26
  export interface SelectOption {
20
27
  readonly id: string;
@@ -22,6 +29,8 @@ export interface SelectOption {
22
29
  /** Optional short marker rendered as a superscript beside the label. */
23
30
  readonly badge?: string;
24
31
  readonly detail?: string;
32
+ /** Equal group names share a sticky heading; groups retain first-occurrence order. */
33
+ readonly group?: SelectOptionGroup;
25
34
  /**
26
35
  * The row the shell's highlight parks on when the panel opens, so an accept
27
36
  * gesture made without looking confirms the value in use. A business package
@@ -32,6 +41,14 @@ export interface SelectOption {
32
41
  /** Optional in-page risk gate owned by the shared popup shell. */
33
42
  readonly confirmation?: SelectConfirmation;
34
43
  }
44
+ /** Command-owned search copy, resolved when its popup opens. */
45
+ export interface PopupSearchLabels {
46
+ readonly placeholder: string;
47
+ /** Empty catalog, before filtering. */
48
+ readonly empty: string;
49
+ /** Nonempty catalog with no matching rows. */
50
+ readonly noResults: string;
51
+ }
35
52
  /**
36
53
  * Business registration for the popupSelect command kind. Data is
37
54
  * self-served: options/onSelect use the business package's own protocol.
@@ -40,6 +57,13 @@ export interface SelectOption {
40
57
  */
41
58
  export interface PopupSelectSpec {
42
59
  readonly kind: 'popupSelect';
60
+ /** Defaults to substring search; fuzzy-label ranks names within each group. */
61
+ readonly searchMode?: PopupSearchMode;
62
+ /**
63
+ * Supply localized search copy for this opening; omitted uses the shell's generic copy.
64
+ * @returns search placeholder and empty-state labels.
65
+ */
66
+ searchLabels?(): PopupSearchLabels;
43
67
  options(session: ClientSessionContext, signal: AbortSignal): Promise<readonly SelectOption[]>;
44
68
  onSelect(option: SelectOption, session: ClientSessionContext): void | Promise<void>;
45
69
  }
@@ -14,7 +14,7 @@ export type { CommandDescriptor, DirectoryStatus } from './directory.ts';
14
14
  export { filterOptions, PopupSelectController } from './popup.ts';
15
15
  export type { PopupSelectDeps, PopupSpec, PopupState, TokenSegment } from './popup.ts';
16
16
  export type { PopupSelectInjected, PopupSelectViewProps } from './PopupSelectView.tsx';
17
- export type { ActionSpec, CommandContribution, CommandDecoration, CommandUiContract, CommandUiSpec, PopupSelectSpec, SelectConfirmation, SelectOption, } from './contract.ts';
17
+ export type { ActionSpec, CommandContribution, CommandDecoration, CommandUiContract, CommandUiSpec, PopupSearchLabels, PopupSelectSpec, SelectConfirmation, SelectOption, SelectOptionGroup, PopupSearchMode, } from './contract.ts';
18
18
  export type { CommandKey } from './locales.ts';
19
19
  declare module '@deepseek-ai/cordis' {
20
20
  interface Context {
@@ -0,0 +1,15 @@
1
+ /** Stable grouping shared by popup filtering and rendering. */
2
+ import type { SelectOption, SelectOptionGroup } from './contract.ts';
3
+ interface OptionGroup {
4
+ readonly group: SelectOptionGroup | undefined;
5
+ readonly rows: readonly SelectOption[];
6
+ }
7
+ /**
8
+ * Group rows by their caller-owned group name, without sorting groups or rows.
9
+ * Ungrouped rows occupy one block at their first occurrence.
10
+ * @param options - Options in display order.
11
+ * @returns groups in first-occurrence order, with original option objects.
12
+ */
13
+ export declare function groupOptions(options: readonly SelectOption[]): OptionGroup[];
14
+ export {};
15
+ //# sourceMappingURL=option-groups.d.ts.map
@@ -1,6 +1,6 @@
1
1
  import type { SnapshotStore } from '@deepseek-ai/dsh-client-store';
2
2
  import type { TokenSpan } from '@deepseek-ai/dsh-client-ui-input-trigger/client';
3
- import type { SelectOption } from './contract.ts';
3
+ import type { PopupSearchLabels, PopupSearchMode, PopupSelectSpec, SelectOption } from './contract.ts';
4
4
  /**
5
5
  * The command token segment snapshotted at shell-open time, replayed to the
6
6
  * injected {@link PopupSelectDeps.consume} callback after a successful
@@ -21,7 +21,7 @@ export type TokenSegment = {
21
21
  * session wiring passes its session projection; the controller only carries
22
22
  * it from open() to the callbacks).
23
23
  */
24
- export interface PopupSpec<TCtx> {
24
+ export interface PopupSpec<TCtx> extends Pick<PopupSelectSpec, 'searchLabels' | 'searchMode'> {
25
25
  /** Load the option rows once per open (retry after failure reuses the same signal). */
26
26
  options(context: TCtx, signal: AbortSignal): Promise<readonly SelectOption[]>;
27
27
  /** Settle the picked option against the open-time context. */
@@ -51,6 +51,10 @@ export interface PopupState {
51
51
  readonly options: readonly SelectOption[];
52
52
  /** Local filter text over the loaded options. */
53
53
  readonly search: string;
54
+ /** Command-owned copy for this opening; null uses generic shell labels. */
55
+ readonly searchLabels: PopupSearchLabels | null;
56
+ /** Search policy resolved from the command spec at opening. */
57
+ readonly searchMode: PopupSearchMode;
54
58
  /**
55
59
  * Highlight index into the filtered row list: 0 until options land; afterwards
56
60
  * the row the loaded list marks as the current value
@@ -68,13 +72,14 @@ export interface PopupState {
68
72
  readonly error: string | null;
69
73
  }
70
74
  /**
71
- * Filter option rows against the shell's local search text (case-insensitive
72
- * substring over label and detail; blank search keeps every row).
75
+ * Filter rows using substring matching, or rank labels fuzzily within each group.
76
+ * Blank search keeps every row; fuzzy matching preserves group order.
73
77
  * @param options - the loaded rows.
74
78
  * @param search - the shell's search text.
75
- * @returns the rows the shell shows and highlights over.
79
+ * @param mode - the command's policy; defaults to substring over label and detail.
80
+ * @returns the original option objects in the order shown and used for selection.
76
81
  */
77
- export declare function filterOptions(options: readonly SelectOption[], search: string): readonly SelectOption[];
82
+ export declare function filterOptions(options: readonly SelectOption[], search: string, mode?: PopupSearchMode): readonly SelectOption[];
78
83
  /**
79
84
  * Headless controller of one session's popupSelect shell. Late settlements
80
85
  * lose their write rights through binding identity: dismiss/dispose/reopen
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@deepseek-ai/dsh-client-ui-commands",
3
3
  "description": "Client command surface: global directory cache, '/' source, three command UI kinds, popupSelect registry",
4
- "version": "0.1.7-rc.2",
4
+ "version": "0.2.0-rc.2",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -44,22 +44,22 @@
44
44
  "@types/react": "~18.3.1",
45
45
  "react": "^18.2.0",
46
46
  "clsx": "^2.0.0",
47
- "@deepseek-ai/dsh-api-remotes": "0.1.7-rc.2",
48
- "@deepseek-ai/dsh-client-connection": "0.1.7-rc.2",
49
- "@deepseek-ai/dsh-client-locale": "0.1.7-rc.2",
50
- "@deepseek-ai/dsh-client-ui-conversation": "0.1.7-rc.2",
51
- "@deepseek-ai/dsh-client-ui-primitives": "0.1.7-rc.2",
52
- "@deepseek-ai/dsh-client-test-runtime": "0.1.7-rc.2",
53
- "@deepseek-ai/dsh-client-ui-input-trigger": "0.1.7-rc.2",
54
- "@deepseek-ai/dsh-client-ui-slots": "0.1.7-rc.2",
55
- "@deepseek-ai/dsh-commands": "0.1.7-rc.2",
47
+ "@deepseek-ai/dsh-api-remotes": "0.2.0-rc.2",
48
+ "@deepseek-ai/dsh-client-connection": "0.2.0-rc.2",
49
+ "@deepseek-ai/dsh-client-locale": "0.2.0-rc.2",
50
+ "@deepseek-ai/dsh-client-test-runtime": "0.2.0-rc.2",
51
+ "@deepseek-ai/dsh-client-ui-primitives": "0.2.0-rc.2",
52
+ "@deepseek-ai/dsh-client-ui-input-trigger": "0.2.0-rc.2",
53
+ "@deepseek-ai/dsh-client-ui-conversation": "0.2.0-rc.2",
54
+ "@deepseek-ai/dsh-commands": "0.2.0-rc.2",
56
55
  "@deepseek-ai/cordis": "~4.0.4",
57
- "@deepseek-ai/dsh-api-session-controller": "0.1.7-rc.2",
58
- "@deepseek-ai/dsh-client-store": "0.1.7-rc.2",
59
- "@deepseek-ai/dsh-session": "0.1.7-rc.2",
60
- "@deepseek-ai/dsh-util-values": "0.1.7-rc.2",
61
- "@deepseek-ai/dsh-client-ui-renderer": "0.1.7-rc.2",
62
- "@deepseek-ai/dsh-client-ui-session": "0.1.7-rc.2"
56
+ "@deepseek-ai/dsh-api-session-controller": "0.2.0-rc.2",
57
+ "@deepseek-ai/dsh-client-store": "0.2.0-rc.2",
58
+ "@deepseek-ai/dsh-session": "0.2.0-rc.2",
59
+ "@deepseek-ai/dsh-util-values": "0.2.0-rc.2",
60
+ "@deepseek-ai/dsh-client-ui-renderer": "0.2.0-rc.2",
61
+ "@deepseek-ai/dsh-client-ui-session": "0.2.0-rc.2",
62
+ "@deepseek-ai/dsh-client-ui-slots": "0.2.0-rc.2"
63
63
  },
64
64
  "files": [
65
65
  "lib/index.js",