@deepseek-ai/dsh-client-ui-commands 0.2.0-rc.1 → 0.2.1-alpha.1

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
@@ -42,5 +42,5 @@
42
42
  en: cfce26d6a603cb7d
43
43
  zh: 8332a8d1ad44abcf
44
44
  /deepseek-ai-dsh-client-ui-commands/known-limitations-and-deferred-work/dev-note:
45
- en: 2f5e19244ac6c305
46
- zh: e43b52ae5b4af798
45
+ en: e67f633dc6fe7773
46
+ zh: 3bb0c20c93f16f98
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>
@@ -95,5 +101,3 @@ These limits define the current command surface. They are current package constr
95
101
  None.
96
102
 
97
103
  </details>
98
-
99
- **Runtime invariant:** No companion is published. This browser-side source uses the wire command directory; it emits no Cordis events and owns no cross-plugin mutable state. Its dispatch and cache behavior are asserted by this package's specs.
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>
@@ -95,5 +101,3 @@ composer 携带图片或通用文件提交时,只有声明了 `input.attachmen
95
101
  无。
96
102
 
97
103
  </details>
98
-
99
- **运行时不变式:** 不发布伴生入口。这是基于 wire 命令目录的浏览器侧 source,不发出 Cordis 事件,也不持有跨插件可变状态;dispatch 与 cache 行为由包测试覆盖。
package/lib/client.js CHANGED
@@ -9,8 +9,642 @@ window.__ModuleLoader__.load({
9
9
  let _deepseek_ai_dsh_client_store = require("@deepseek-ai/dsh-client-store");
10
10
  let react_jsx_runtime = require("react/jsx-runtime");
11
11
  let react = require("react");
12
+ //#region ../../util/values/src/partial-json.ts
13
+ /**
14
+ * Lazily scanned view of one JSON object's top-level fields, built from text
15
+ * that may still be streaming or from an already parsed object. Nothing is
16
+ * scanned until a reader asks; the view remembers every question it answered
17
+ * and reports changed answers when the owner refreshes for publication.
18
+ * Used for model tool-call arguments: a row reads the fields it
19
+ * cares about at whatever granularity it displays, at every stage of the call.
20
+ * @module @deepseek-ai/dsh-util-values/src/partial-json
21
+ */
22
+ const SIMPLE_ESCAPES = {
23
+ "\"": "\"",
24
+ "\\": "\\",
25
+ "/": "/",
26
+ b: "\b",
27
+ f: "\f",
28
+ n: "\n",
29
+ r: "\r",
30
+ t: " "
31
+ };
32
+ const CONTENT_ESCAPE = /[\\\u0000-\u001f]/u;
33
+ function isWhitespace(c) {
34
+ return c === " " || c === "\n" || c === "\r" || c === " ";
35
+ }
36
+ function isHex(c) {
37
+ return c >= "0" && c <= "9" || c >= "a" && c <= "f" || c >= "A" && c <= "F";
38
+ }
39
+ (class PartialArguments {
40
+ /** The view of a call with no arguments available. */
41
+ static EMPTY = PartialArguments.fromObject({});
42
+ /**
43
+ * View finished argument text without scanning it until a reader asks.
44
+ * @param text - the complete argument JSON text.
45
+ * @returns a sealed view.
46
+ */
47
+ static fromText(text) {
48
+ const view = new PartialArguments();
49
+ view.append(text);
50
+ view.sealed = true;
51
+ return view;
52
+ }
53
+ /**
54
+ * View an already parsed argument payload, such as a PTC dispatch object.
55
+ * @param value - the parsed argument value.
56
+ * @returns a sealed view; a non-object payload has no fields.
57
+ */
58
+ static fromObject(value) {
59
+ const view = new PartialArguments();
60
+ view.object = typeof value === "object" && value !== null && !Array.isArray(value) ? value : {};
61
+ view.sealed = true;
62
+ return view;
63
+ }
64
+ /**
65
+ * The source: text so far or a parsed object, plus whether it can still grow.
66
+ * These are the only enumerable fields, so two views over the same source
67
+ * compare equal structurally however far each has been read.
68
+ */
69
+ chunks = [];
70
+ object;
71
+ sealed = false;
72
+ #ends = [];
73
+ #size = 0;
74
+ #consumed = 0;
75
+ #mode = "root";
76
+ #escape = false;
77
+ #keyStart = 0;
78
+ #keyEscaped = false;
79
+ #key = "";
80
+ #current = null;
81
+ #nestedEnds = [];
82
+ #nestedInString = false;
83
+ #invalidAt;
84
+ #invalidValue = false;
85
+ #entries = /* @__PURE__ */ new Map();
86
+ #order = [];
87
+ #reads = /* @__PURE__ */ new Map();
88
+ /** Whether this view rejects further appends; does not scan text or register reads. */
89
+ get isSealed() {
90
+ return this.sealed;
91
+ }
92
+ /** Whether indexing or a content read found invalid JSON; unread value contents are not validated. */
93
+ get invalid() {
94
+ this.scan();
95
+ return this.#mode === "invalid" || this.#invalidValue;
96
+ }
97
+ /**
98
+ * Retain streamed argument text without scanning or comparing observed answers.
99
+ * @param fragment - the text following every fragment appended before.
100
+ */
101
+ append(fragment) {
102
+ if (this.sealed) throw new Error("PartialArguments: cannot append to a sealed view");
103
+ if (fragment.length === 0) return;
104
+ this.chunks.push(fragment);
105
+ this.#size += fragment.length;
106
+ this.#ends.push(this.#size);
107
+ }
108
+ /**
109
+ * Reconcile a streamed prefix with authoritative complete text without joining the fragments.
110
+ * @param text - the final argument text, which replaces missing or conflicting deltas.
111
+ * @returns this view sealed with its caches retained when every character matches; otherwise a new sealed view.
112
+ */
113
+ settle(text) {
114
+ if (this.object !== void 0 || text.length !== this.#size) return PartialArguments.fromText(text);
115
+ let offset = 0;
116
+ for (const chunk of this.chunks) {
117
+ if (!text.startsWith(chunk, offset)) return PartialArguments.fromText(text);
118
+ offset += chunk.length;
119
+ }
120
+ this.chunks = text.length === 0 ? [] : [text];
121
+ this.#ends = text.length === 0 ? [] : [text.length];
122
+ this.sealed = true;
123
+ return this;
124
+ }
125
+ /**
126
+ * Compare observed answers and advance their publication baseline. Unread views remain unscanned.
127
+ * @returns whether any observed answer changed since its first read or the preceding refresh.
128
+ */
129
+ refresh() {
130
+ if (this.#reads.size === 0) return false;
131
+ this.scan();
132
+ let changed = false;
133
+ let completions = false;
134
+ for (const read of this.#reads.values()) {
135
+ if (read.completion) {
136
+ completions = true;
137
+ continue;
138
+ }
139
+ changed = this.refreshRead(read) || changed;
140
+ }
141
+ if (completions) {
142
+ for (const read of this.#reads.values()) if (read.completion) changed = this.refreshRead(read) || changed;
143
+ }
144
+ if (this.sealed) this.#reads.clear();
145
+ return changed;
146
+ }
147
+ refreshRead(read) {
148
+ const now = read.answer();
149
+ if (Object.is(now, read.last)) return false;
150
+ read.last = now;
151
+ return true;
152
+ }
153
+ /**
154
+ * Check whether no further fields can arrive.
155
+ * @returns whether the outer object closed, indexing failed, or the view is sealed; unread values are not validated.
156
+ */
157
+ closed() {
158
+ return this.remember("closed", "", () => this.closedNow());
159
+ }
160
+ /**
161
+ * List discovered fields in first-appearance order.
162
+ * @returns top-level keys seen so far, in first-appearance order.
163
+ */
164
+ keys() {
165
+ return this.remember("keys", "", () => this.keysNow(), (keys) => keys.length);
166
+ }
167
+ /**
168
+ * Check whether a top-level field has appeared.
169
+ * @param key - argument name.
170
+ * @returns whether the field has appeared (a string opened or another value began).
171
+ */
172
+ has(key) {
173
+ return this.remember("has", key, () => this.hasNow(key));
174
+ }
175
+ /**
176
+ * Check whether a field's closing delimiter has arrived, without validating its contents.
177
+ * @param key - argument name.
178
+ * @returns whether its delimiter arrived and no content reader has reported an error for this value.
179
+ */
180
+ complete(key) {
181
+ return this.remember("complete", key, () => this.completeNow(key));
182
+ }
183
+ /**
184
+ * Read string length without materializing its text.
185
+ * @param key - argument name.
186
+ * @param options - change granularity for a streaming string.
187
+ * @returns decoded UTF-16 length of the string field so far; undefined when absent or not a string.
188
+ */
189
+ stringLength(key, options) {
190
+ const step = Math.max(1, Math.floor(options?.step ?? 1));
191
+ const offset = options?.offset ?? 0;
192
+ return this.remember(`length:${step}:${offset}`, key, () => this.lengthNow(key), (length) => length === void 0 ? void 0 : Math.ceil((length + offset) / step));
193
+ }
194
+ /**
195
+ * Check a string against a decoded UTF-16 length limit without materializing it.
196
+ * @param key - argument name.
197
+ * @param maxLength - decoded UTF-16 limit, floored to at least zero.
198
+ * @returns whether the string is longer than the limit; false when absent or not a string.
199
+ */
200
+ stringExceeds(key, maxLength) {
201
+ const limit = Math.max(0, Math.floor(maxLength));
202
+ return this.remember(`exceeds:${limit}`, key, () => (this.lengthNow(key, limit + 1) ?? 0) > limit);
203
+ }
204
+ /**
205
+ * Read a decoded string, including a streaming prefix.
206
+ * @param key - argument name.
207
+ * @returns the string field's decoded text so far; undefined when absent or not a string.
208
+ */
209
+ text(key) {
210
+ return this.remember("text", key, () => this.textNow(key));
211
+ }
212
+ /**
213
+ * Read at most the first decoded UTF-16 units of a string.
214
+ * @param key - argument name.
215
+ * @param maxLength - maximum decoded UTF-16 length, floored to at least one.
216
+ * @returns the bounded string prefix; undefined when absent or not a string.
217
+ */
218
+ textPrefix(key, maxLength) {
219
+ const limit = Math.max(1, Math.floor(maxLength));
220
+ return this.remember(`prefix:${limit}`, key, () => this.textPrefixNow(key, limit));
221
+ }
222
+ /**
223
+ * Read a completed non-string argument.
224
+ * @param key - argument name.
225
+ * @returns the parsed non-string value once it closed; undefined while open, absent, or a string.
226
+ */
227
+ value(key) {
228
+ return this.remember("value", key, () => this.valueNow(key));
229
+ }
230
+ /** Answer a question and, on a streaming view, remember it for change detection. */
231
+ remember(kind, key, read, comparison) {
232
+ this.scan();
233
+ const result = read();
234
+ if (!this.sealed) {
235
+ const id = `${kind}/${key}`;
236
+ if (!this.#reads.has(id)) this.#reads.set(id, {
237
+ completion: kind === "complete",
238
+ answer: comparison === void 0 ? read : () => comparison(read()),
239
+ last: comparison === void 0 ? result : comparison(result)
240
+ });
241
+ }
242
+ return result;
243
+ }
244
+ closedNow() {
245
+ return this.sealed || this.#mode === "closed" || this.#mode === "invalid";
246
+ }
247
+ keysNow() {
248
+ return this.object === void 0 ? this.#order : Object.keys(this.object);
249
+ }
250
+ hasNow(key) {
251
+ return this.object === void 0 ? this.#entries.has(key) : Object.hasOwn(this.object, key);
252
+ }
253
+ completeNow(key) {
254
+ if (this.object !== void 0) return Object.hasOwn(this.object, key);
255
+ const entry = this.#entries.get(key);
256
+ return entry !== void 0 && entry.end >= 0 && (entry.kind === "string" ? entry.invalidAt === void 0 : !entry.invalid);
257
+ }
258
+ lengthNow(key, limit = Number.POSITIVE_INFINITY) {
259
+ if (this.object !== void 0) {
260
+ const field = Object.hasOwn(this.object, key) ? this.object[key] : void 0;
261
+ return typeof field === "string" ? field.length : void 0;
262
+ }
263
+ const entry = this.#entries.get(key);
264
+ if (entry?.kind !== "string") return void 0;
265
+ if (entry.text !== void 0 && entry.text.at === entry.end) return entry.text.length;
266
+ const read = entry.length ??= {
267
+ at: entry.start,
268
+ length: 0,
269
+ text: ""
270
+ };
271
+ this.readString(entry, read, limit, false);
272
+ return read.length;
273
+ }
274
+ textNow(key) {
275
+ if (this.object !== void 0) {
276
+ const field = Object.hasOwn(this.object, key) ? this.object[key] : void 0;
277
+ return typeof field === "string" ? field : void 0;
278
+ }
279
+ const entry = this.#entries.get(key);
280
+ if (entry?.kind !== "string") return void 0;
281
+ if (entry.text === void 0 && entry.end >= 0 && entry.needsDecoding && entry.invalidAt === void 0) {
282
+ let text;
283
+ try {
284
+ text = JSON.parse(`"${this.slice(entry.start, entry.end)}"`);
285
+ } catch (_error) {}
286
+ if (text !== void 0) entry.text = {
287
+ at: entry.end,
288
+ length: text.length,
289
+ text
290
+ };
291
+ }
292
+ const read = entry.text ??= {
293
+ at: entry.start,
294
+ length: 0,
295
+ text: ""
296
+ };
297
+ this.readString(entry, read, Number.POSITIVE_INFINITY, true);
298
+ return read.text;
299
+ }
300
+ textPrefixNow(key, maxLength) {
301
+ if (this.object !== void 0) {
302
+ const field = Object.hasOwn(this.object, key) ? this.object[key] : void 0;
303
+ return typeof field === "string" ? field.slice(0, maxLength) : void 0;
304
+ }
305
+ const entry = this.#entries.get(key);
306
+ if (entry?.kind !== "string") return void 0;
307
+ const prefixes = entry.prefixes ??= /* @__PURE__ */ new Map();
308
+ let read = prefixes.get(maxLength);
309
+ if (read === void 0) {
310
+ read = {
311
+ at: entry.start,
312
+ length: 0,
313
+ text: ""
314
+ };
315
+ prefixes.set(maxLength, read);
316
+ }
317
+ this.readString(entry, read, maxLength, true);
318
+ return read.text;
319
+ }
320
+ valueNow(key) {
321
+ if (this.object !== void 0) {
322
+ if (!Object.hasOwn(this.object, key)) return void 0;
323
+ const field = this.object[key];
324
+ return typeof field === "string" ? void 0 : field;
325
+ }
326
+ const entry = this.#entries.get(key);
327
+ if (entry?.kind !== "value" || entry.end < 0 || entry.invalid) return void 0;
328
+ if (entry.parsed === void 0) try {
329
+ entry.parsed = JSON.parse(this.slice(entry.start, entry.end));
330
+ } catch (_error) {
331
+ entry.invalid = true;
332
+ this.#invalidValue = true;
333
+ }
334
+ return entry.parsed;
335
+ }
336
+ chunkAt(at) {
337
+ let low = 0;
338
+ let high = this.#ends.length;
339
+ while (low < high) {
340
+ const mid = low + high >>> 1;
341
+ if (this.#ends[mid] <= at) low = mid + 1;
342
+ else high = mid;
343
+ }
344
+ return low;
345
+ }
346
+ /** Materialize only a requested range, never the cumulative source. */
347
+ slice(start, end) {
348
+ if (start >= end) return "";
349
+ const first = this.chunkAt(start);
350
+ const last = this.chunkAt(end - 1);
351
+ const base = first === 0 ? 0 : this.#ends[first - 1];
352
+ if (first === last) return this.chunks[first].slice(start - base, end - base);
353
+ const parts = [this.chunks[first].slice(start - base)];
354
+ for (let i = first + 1; i < last; i++) parts.push(this.chunks[i]);
355
+ parts.push(this.chunks[last].slice(0, end - this.#ends[last - 1]));
356
+ return parts.join("");
357
+ }
358
+ readString(entry, read, limit, materialize) {
359
+ const end = Math.min(entry.end < 0 ? this.#consumed : entry.end, entry.invalidAt ?? Number.POSITIVE_INFINITY, this.#invalidAt ?? Number.POSITIVE_INFINITY);
360
+ if (!entry.needsDecoding) {
361
+ const length = Math.min(end - read.at, limit - read.length);
362
+ if (length <= 0) return;
363
+ if (materialize) read.text += this.slice(read.at, read.at + length);
364
+ read.at += length;
365
+ read.length += length;
366
+ return;
367
+ }
368
+ let chunkIndex = this.chunkAt(read.at);
369
+ while (read.at < end && read.length < limit) {
370
+ const base = chunkIndex === 0 ? 0 : this.#ends[chunkIndex - 1];
371
+ const chunk = this.chunks[chunkIndex];
372
+ const remaining = chunk.slice(read.at - base, Math.min(chunk.length, end - base));
373
+ const boundary = remaining.search(CONTENT_ESCAPE);
374
+ const length = Math.min(boundary < 0 ? remaining.length : boundary, limit - read.length);
375
+ if (length > 0) {
376
+ if (materialize) read.text += remaining.slice(0, length);
377
+ read.at += length;
378
+ read.length += length;
379
+ if (read.at === base + chunk.length) chunkIndex++;
380
+ continue;
381
+ }
382
+ const type = remaining.length > 1 ? remaining[1] : read.at + 1 < end ? this.chunks[chunkIndex + 1][0] : void 0;
383
+ let decoded;
384
+ let width = 2;
385
+ if (remaining[0] === "\\" && type === void 0 && entry.end < 0) return;
386
+ if (remaining[0] === "\\" && type === "u") {
387
+ const hex = this.slice(read.at + 2, Math.min(end, read.at + 6));
388
+ let valid = true;
389
+ for (let i = 0; i < hex.length; i++) if (!isHex(hex[i])) valid = false;
390
+ if (valid) {
391
+ if (hex.length < 4 && entry.end < 0) return;
392
+ if (hex.length === 4) decoded = String.fromCharCode(Number.parseInt(hex, 16));
393
+ }
394
+ width = 6;
395
+ } else if (remaining[0] === "\\" && type !== void 0) decoded = SIMPLE_ESCAPES[type];
396
+ if (decoded === void 0) {
397
+ entry.invalidAt = read.at;
398
+ this.#invalidValue = true;
399
+ return;
400
+ }
401
+ if (materialize) read.text += decoded;
402
+ read.length++;
403
+ read.at += width;
404
+ while (chunkIndex < this.chunks.length && read.at >= this.#ends[chunkIndex]) chunkIndex++;
405
+ }
406
+ }
407
+ /** Locate new field ranges without decoding or parsing their contents. */
408
+ scan() {
409
+ if (this.object !== void 0 || this.#consumed === this.#size) return;
410
+ for (let i = this.chunkAt(this.#consumed); i < this.chunks.length && this.#invalidAt === void 0; i++) {
411
+ const pending = this.chunks[i];
412
+ const base = i === 0 ? 0 : this.#ends[i - 1];
413
+ for (let index = this.#consumed - base; index < pending.length && this.#mode !== "invalid"; index++) {
414
+ if (this.#mode === "string" || this.#mode === "nested" && this.#nestedInString) {
415
+ const end = this.stringBoundary(pending, index);
416
+ this.#consumed += end - index;
417
+ index = end;
418
+ if (index === pending.length) break;
419
+ }
420
+ this.step(pending[index], this.#consumed);
421
+ this.#consumed++;
422
+ }
423
+ }
424
+ }
425
+ /** Only raw quotes and their preceding backslash runs can terminate a string. */
426
+ stringBoundary(fragment, start) {
427
+ let at = start;
428
+ while (true) {
429
+ const quote = fragment.indexOf("\"", at);
430
+ const end = quote < 0 ? fragment.length : quote;
431
+ if (this.#mode === "string") {
432
+ const entry = this.#current;
433
+ if (!entry.needsDecoding && CONTENT_ESCAPE.test(fragment.slice(at, end))) entry.needsDecoding = true;
434
+ }
435
+ let slashStart = end;
436
+ while (slashStart > at && fragment[slashStart - 1] === "\\") slashStart--;
437
+ const escaped = (end - slashStart) % 2 === 1 !== (slashStart === at && this.#escape);
438
+ this.#escape = quote < 0 && escaped;
439
+ if (quote < 0 || !escaped) return end;
440
+ at = quote + 1;
441
+ }
442
+ }
443
+ step(c, at) {
444
+ switch (this.#mode) {
445
+ case "root":
446
+ if (isWhitespace(c)) return;
447
+ if (c === "{") {
448
+ this.#mode = "key-or-end";
449
+ return;
450
+ }
451
+ this.fail();
452
+ return;
453
+ case "key-or-end":
454
+ if (isWhitespace(c)) return;
455
+ if (c === "}") {
456
+ this.#mode = "closed";
457
+ return;
458
+ }
459
+ if (c === "\"") {
460
+ this.beginKey(at);
461
+ return;
462
+ }
463
+ this.fail();
464
+ return;
465
+ case "key-only":
466
+ if (isWhitespace(c)) return;
467
+ if (c === "\"") {
468
+ this.beginKey(at);
469
+ return;
470
+ }
471
+ this.fail();
472
+ return;
473
+ case "key":
474
+ this.stepKey(c, at);
475
+ return;
476
+ case "colon":
477
+ if (isWhitespace(c)) return;
478
+ if (c === ":") {
479
+ this.#mode = "value";
480
+ return;
481
+ }
482
+ this.fail();
483
+ return;
484
+ case "value":
485
+ this.beginValue(c, at);
486
+ return;
487
+ case "string": {
488
+ const entry = this.#current;
489
+ entry.end = at;
490
+ this.#current = null;
491
+ this.#mode = "comma-or-end";
492
+ return;
493
+ }
494
+ case "scalar":
495
+ this.stepScalar(c, at);
496
+ return;
497
+ case "nested":
498
+ this.stepNested(c, at);
499
+ return;
500
+ case "comma-or-end":
501
+ if (isWhitespace(c)) return;
502
+ if (c === ",") {
503
+ this.#mode = "key-only";
504
+ return;
505
+ }
506
+ if (c === "}") {
507
+ this.#mode = "closed";
508
+ return;
509
+ }
510
+ this.fail();
511
+ return;
512
+ case "closed":
513
+ if (isWhitespace(c)) return;
514
+ this.fail();
515
+ return;
516
+ /* v8 ignore next 2 -- scan() stops stepping once the view is invalid. */
517
+ case "invalid": return;
518
+ /* v8 ignore next 2 -- Every scanner mode has a handler above. */
519
+ default: assertNever(this.#mode);
520
+ }
521
+ }
522
+ fail() {
523
+ this.#invalidAt = this.#consumed;
524
+ this.#mode = "invalid";
525
+ this.#current = null;
526
+ }
527
+ beginKey(at) {
528
+ this.#mode = "key";
529
+ this.#keyStart = at + 1;
530
+ this.#keyEscaped = false;
531
+ this.#escape = false;
532
+ }
533
+ stepKey(c, at) {
534
+ if (c < " ") {
535
+ this.fail();
536
+ return;
537
+ }
538
+ if (this.#escape) {
539
+ this.#escape = false;
540
+ return;
541
+ }
542
+ if (c === "\\") {
543
+ this.#escape = true;
544
+ this.#keyEscaped = true;
545
+ return;
546
+ }
547
+ if (c !== "\"") return;
548
+ const raw = this.slice(this.#keyStart, at);
549
+ if (this.#keyEscaped) try {
550
+ this.#key = JSON.parse(`"${raw}"`);
551
+ } catch (_error) {
552
+ this.fail();
553
+ return;
554
+ }
555
+ else this.#key = raw;
556
+ this.#mode = "colon";
557
+ }
558
+ open(entry) {
559
+ if (!this.#entries.has(this.#key)) this.#order.push(this.#key);
560
+ this.#entries.set(this.#key, entry);
561
+ this.#current = entry;
562
+ }
563
+ beginValue(c, at) {
564
+ if (isWhitespace(c)) return;
565
+ if (c === "\"") {
566
+ this.open({
567
+ kind: "string",
568
+ start: at + 1,
569
+ end: -1,
570
+ needsDecoding: false,
571
+ invalidAt: void 0,
572
+ length: void 0,
573
+ text: void 0,
574
+ prefixes: void 0
575
+ });
576
+ this.#escape = false;
577
+ this.#mode = "string";
578
+ return;
579
+ }
580
+ if (c === "}" || c === "," || c === ":" || c === "]") {
581
+ this.fail();
582
+ return;
583
+ }
584
+ this.open({
585
+ kind: "value",
586
+ start: at,
587
+ end: -1,
588
+ parsed: void 0,
589
+ invalid: false
590
+ });
591
+ if (c === "{" || c === "[") {
592
+ this.#mode = "nested";
593
+ this.#nestedEnds = [c === "{" ? "}" : "]"];
594
+ this.#nestedInString = false;
595
+ this.#escape = false;
596
+ return;
597
+ }
598
+ this.#mode = "scalar";
599
+ }
600
+ stepScalar(c, at) {
601
+ if (c !== "," && c !== "}" && !isWhitespace(c)) return;
602
+ this.closeValue(at);
603
+ this.#mode = c === "," ? "key-only" : c === "}" ? "closed" : "comma-or-end";
604
+ }
605
+ stepNested(c, at) {
606
+ if (this.#nestedInString) {
607
+ this.#nestedInString = false;
608
+ return;
609
+ }
610
+ if (c === "\"") {
611
+ this.#nestedInString = true;
612
+ return;
613
+ }
614
+ if (c === "{" || c === "[") {
615
+ this.#nestedEnds.push(c === "{" ? "}" : "]");
616
+ return;
617
+ }
618
+ if (c === "}" || c === "]") {
619
+ if (this.#nestedEnds.pop() !== c) {
620
+ this.fail();
621
+ return;
622
+ }
623
+ if (this.#nestedEnds.length === 0) {
624
+ this.closeValue(at + 1);
625
+ this.#mode = "comma-or-end";
626
+ }
627
+ }
628
+ }
629
+ closeValue(end) {
630
+ const entry = this.#current;
631
+ entry.end = end;
632
+ this.#current = null;
633
+ }
634
+ });
635
+ //#endregion
12
636
  //#region ../../util/values/src/index.ts
13
637
  /**
638
+ * Mark an unreachable closed-union branch.
639
+ * @param value - impossible value; an unhandled typed variant fails at the call site.
640
+ * @param context - optional switch-site label included in the failure message.
641
+ * @returns never; a runtime value that escaped its type always throws.
642
+ */
643
+ function assertNever(value, context) {
644
+ const rendered = JSON.stringify(value) ?? String(value);
645
+ throw new Error(`unreachable variant${context ? ` in ${context}` : ""}: ${rendered}`);
646
+ }
647
+ /**
14
648
  * Weak-key lookup with a strongly retained iterable set of associated values.
15
649
  *
16
650
  * Each value must belong to only one key. The container performs no automatic
@@ -334,6 +968,27 @@ window.__ModuleLoader__.load({
334
968
  return signal.reason instanceof Error ? signal.reason : /* @__PURE__ */ new Error("command directory wait aborted");
335
969
  }
336
970
  //#endregion
971
+ //#region lib/types/client/option-groups.js
972
+ /**
973
+ * Group rows by their caller-owned group name, without sorting groups or rows.
974
+ * Ungrouped rows occupy one block at their first occurrence.
975
+ * @param options - Options in display order.
976
+ * @returns groups in first-occurrence order, with original option objects.
977
+ */
978
+ function groupOptions(options) {
979
+ const groups = /* @__PURE__ */ new Map();
980
+ for (const option of options) {
981
+ const name = option.group?.name;
982
+ const existing = groups.get(name);
983
+ if (existing !== void 0) existing.rows.push(option);
984
+ else groups.set(name, {
985
+ group: option.group,
986
+ rows: [option]
987
+ });
988
+ }
989
+ return [...groups.values()];
990
+ }
991
+ //#endregion
337
992
  //#region lib/types/client/popup.js
338
993
  /**
339
994
  * Headless popupSelect shell state: one controller per client
@@ -352,6 +1007,8 @@ window.__ModuleLoader__.load({
352
1007
  status: "pending",
353
1008
  options: [],
354
1009
  search: "",
1010
+ searchLabels: null,
1011
+ searchMode: "substring",
355
1012
  active: 0,
356
1013
  submitting: false,
357
1014
  confirming: null,
@@ -359,16 +1016,23 @@ window.__ModuleLoader__.load({
359
1016
  error: null
360
1017
  };
361
1018
  /**
362
- * Filter option rows against the shell's local search text (case-insensitive
363
- * substring over label and detail; blank search keeps every row).
1019
+ * Filter rows using substring matching, or rank labels fuzzily within each group.
1020
+ * Blank search keeps every row; fuzzy matching preserves group order.
364
1021
  * @param options - the loaded rows.
365
1022
  * @param search - the shell's search text.
366
- * @returns the rows the shell shows and highlights over.
1023
+ * @param mode - the command's policy; defaults to substring over label and detail.
1024
+ * @returns the original option objects in the order shown and used for selection.
367
1025
  */
368
- function filterOptions(options, search) {
1026
+ function filterOptions(options, search, mode = "substring") {
369
1027
  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));
1028
+ const groups = groupOptions(options);
1029
+ const ordered = groups.some((group) => group.group !== void 0) ? groups.flatMap((group) => group.rows) : options;
1030
+ if (query === "") return ordered;
1031
+ if (mode === "fuzzy-label") return groups.flatMap((group) => (0, _deepseek_ai_dsh_client_ui_primitives.rankByName)(group.rows.map((option) => ({
1032
+ name: option.label,
1033
+ option
1034
+ })), query).map((row) => row.option));
1035
+ return ordered.filter((o) => o.label.toLowerCase().includes(query) || (o.detail?.toLowerCase().includes(query) ?? false));
372
1036
  }
373
1037
  /**
374
1038
  * Highlight index for a freshly loaded row list: the row marked as the current
@@ -377,10 +1041,11 @@ window.__ModuleLoader__.load({
377
1041
  * made without looking confirms that value instead of the topmost row.
378
1042
  * @param options - the loaded rows.
379
1043
  * @param search - the shell's live filter text (non-empty after a retry).
1044
+ * @param mode - the command's search policy.
380
1045
  * @returns index into the filtered rows.
381
1046
  */
382
- function currentIndex(options, search) {
383
- const at = filterOptions(options, search).findIndex((option) => option.active === true);
1047
+ function currentIndex(options, search, mode) {
1048
+ const at = filterOptions(options, search, mode).findIndex((option) => option.active === true);
384
1049
  return at === -1 ? 0 : at;
385
1050
  }
386
1051
  /** The shell's error-strip line for a settlement failure. */
@@ -414,6 +1079,7 @@ window.__ModuleLoader__.load({
414
1079
  * @param segment - open-time token segment snapshot for post-select consumption.
415
1080
  */
416
1081
  open(command, spec, context, segment) {
1082
+ const searchLabels = spec.searchLabels?.() ?? null;
417
1083
  this.binding?.abort.abort();
418
1084
  const binding = {
419
1085
  command,
@@ -426,7 +1092,9 @@ window.__ModuleLoader__.load({
426
1092
  this.state.set({
427
1093
  ...CLOSED,
428
1094
  open: true,
429
- command
1095
+ command,
1096
+ searchLabels,
1097
+ searchMode: spec.searchMode ?? "substring"
430
1098
  });
431
1099
  this.load(binding);
432
1100
  }
@@ -439,7 +1107,7 @@ window.__ModuleLoader__.load({
439
1107
  ...current,
440
1108
  status: "ready",
441
1109
  options,
442
- active: currentIndex(options, current.search),
1110
+ active: currentIndex(options, current.search, current.searchMode),
443
1111
  error: null
444
1112
  });
445
1113
  }, (error) => {
@@ -489,7 +1157,7 @@ window.__ModuleLoader__.load({
489
1157
  move(dir) {
490
1158
  const s = this.state.getSnapshot();
491
1159
  if (!s.open || s.status !== "ready" || s.submitting || s.confirming !== null) return;
492
- const rows = filterOptions(s.options, s.search);
1160
+ const rows = filterOptions(s.options, s.search, s.searchMode);
493
1161
  if (rows.length === 0) return;
494
1162
  const active = (s.active + dir + rows.length) % rows.length;
495
1163
  this.state.set({
@@ -505,7 +1173,7 @@ window.__ModuleLoader__.load({
505
1173
  highlight(index) {
506
1174
  const s = this.state.getSnapshot();
507
1175
  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;
1176
+ if (index < 0 || index >= filterOptions(s.options, s.search, s.searchMode).length || index === s.active) return;
509
1177
  this.state.set({
510
1178
  ...s,
511
1179
  active: index
@@ -525,7 +1193,7 @@ window.__ModuleLoader__.load({
525
1193
  const binding = this.binding;
526
1194
  const s = this.state.getSnapshot();
527
1195
  if (binding === null || !s.open || s.status !== "ready" || s.submitting || s.confirming !== null) return;
528
- const option = filterOptions(s.options, s.search)[index];
1196
+ const option = filterOptions(s.options, s.search, s.searchMode)[index];
529
1197
  if (option === void 0) return;
530
1198
  if (option.confirmation !== void 0) {
531
1199
  this.state.set({
@@ -1129,7 +1797,7 @@ window.__ModuleLoader__.load({
1129
1797
  }
1130
1798
  //#endregion
1131
1799
  //#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}";
1800
+ 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
1801
  const tagId = "@deepseek-ai/dsh-client-ui-commands/PopupSelectView.module.css";
1134
1802
  if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
1135
1803
  const tag = document.createElement("style");
@@ -1179,12 +1847,33 @@ window.__ModuleLoader__.load({
1179
1847
  const state = (0, react.useSyncExternalStore)((fn) => popup.state.subscribe(fn), () => popup.state.getSnapshot());
1180
1848
  const cardRef = (0, react.useRef)(null);
1181
1849
  const searchRef = (0, react.useRef)(null);
1850
+ const viewportRef = (0, react.useRef)(null);
1851
+ const rows = (0, react.useMemo)(() => filterOptions(state.options, state.search, state.searchMode), [
1852
+ state.options,
1853
+ state.search,
1854
+ state.searchMode
1855
+ ]);
1856
+ const groups = (0, react.useMemo)(() => groupOptions(rows), [rows]);
1857
+ (0, react.useEffect)(() => {
1858
+ const viewport = viewportRef.current;
1859
+ if (viewport === null) return;
1860
+ return (0, _deepseek_ai_dsh_client_ui_primitives.observeStickyMenuGroups)(viewport);
1861
+ }, [
1862
+ state.open,
1863
+ state.status,
1864
+ state.confirming,
1865
+ groups
1866
+ ]);
1182
1867
  const maxHeight = (0, _deepseek_ai_dsh_client_ui_primitives.useAnchoredMaxHeight)(cardRef, MAX_HEIGHT, state);
1183
1868
  const active = state.open ? state.active : null;
1184
1869
  (0, react.useEffect)(() => {
1185
1870
  if (active === null) return;
1186
1871
  cardRef.current?.querySelector("[aria-selected=\"true\"]")?.scrollIntoView({ block: "nearest" });
1187
- }, [active]);
1872
+ }, [
1873
+ active,
1874
+ rows,
1875
+ state.confirming
1876
+ ]);
1188
1877
  (0, react.useEffect)(() => {
1189
1878
  if (!state.open || state.confirming !== null) return;
1190
1879
  const onPointerDown = (ev) => {
@@ -1204,8 +1893,8 @@ window.__ModuleLoader__.load({
1204
1893
  if (state.open && state.confirming === null) searchRef.current?.focus();
1205
1894
  }, [state.open, state.confirming]);
1206
1895
  if (!state.open) return null;
1207
- const rows = filterOptions(state.options, state.search);
1208
1896
  const confirmation = state.confirming?.confirmation;
1897
+ const emptyLabel = state.searchLabels === null ? t("status.empty") : state.options.length === 0 ? state.searchLabels.empty : state.searchLabels.noResults;
1209
1898
  const onKeyDown = (ev) => {
1210
1899
  switch (ev.key) {
1211
1900
  case "ArrowDown":
@@ -1233,6 +1922,39 @@ window.__ModuleLoader__.load({
1233
1922
  default:
1234
1923
  }
1235
1924
  };
1925
+ const renderOption = (option, index) => (0, react_jsx_runtime.jsxs)("div", {
1926
+ role: "option",
1927
+ "aria-selected": index === state.active,
1928
+ "aria-label": option.badge === void 0 ? void 0 : `${option.label} ${option.badge}`,
1929
+ className: clsx(PopupSelectView_module_css_default.row, index === state.active && PopupSelectView_module_css_default.rowActive),
1930
+ onClick: () => {
1931
+ popup.select(index);
1932
+ },
1933
+ onMouseEnter: () => {
1934
+ popup.highlight(index);
1935
+ },
1936
+ children: [
1937
+ (0, react_jsx_runtime.jsxs)("span", {
1938
+ className: PopupSelectView_module_css_default.label,
1939
+ children: [(0, react_jsx_runtime.jsx)("span", {
1940
+ className: PopupSelectView_module_css_default.labelText,
1941
+ children: option.label
1942
+ }), option.badge !== void 0 && (0, react_jsx_runtime.jsx)("sup", {
1943
+ className: PopupSelectView_module_css_default.badge,
1944
+ children: option.badge
1945
+ })]
1946
+ }),
1947
+ option.detail !== void 0 && (0, react_jsx_runtime.jsx)("span", {
1948
+ className: PopupSelectView_module_css_default.detail,
1949
+ children: option.detail
1950
+ }),
1951
+ option.active === true && (0, react_jsx_runtime.jsx)("span", {
1952
+ className: PopupSelectView_module_css_default.check,
1953
+ children: (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconCheckOutlineRegular, {})
1954
+ })
1955
+ ]
1956
+ }, option.id);
1957
+ let optionIndex = 0;
1236
1958
  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
1959
  ref: cardRef,
1238
1960
  className: PopupSelectView_module_css_default.card,
@@ -1244,7 +1966,7 @@ window.__ModuleLoader__.load({
1244
1966
  ref: searchRef,
1245
1967
  className: PopupSelectView_module_css_default.search,
1246
1968
  type: "text",
1247
- placeholder: t("search.placeholder"),
1969
+ placeholder: state.searchLabels?.placeholder ?? t("search.placeholder"),
1248
1970
  "aria-label": t("search.aria"),
1249
1971
  value: state.search,
1250
1972
  readOnly: state.submitting,
@@ -1277,44 +1999,17 @@ window.__ModuleLoader__.load({
1277
1999
  }),
1278
2000
  state.status === "ready" && rows.length === 0 && (0, react_jsx_runtime.jsx)("div", {
1279
2001
  className: PopupSelectView_module_css_default.status,
1280
- children: t("status.empty")
2002
+ children: emptyLabel
1281
2003
  }),
1282
2004
  state.status === "ready" && (0, react_jsx_runtime.jsx)("div", {
2005
+ ref: viewportRef,
1283
2006
  role: "listbox",
1284
2007
  "aria-label": t("listbox.aria", { command: String(state.command) }),
1285
2008
  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))
2009
+ 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, {
2010
+ label: group.label,
2011
+ children: groupRows.map((option) => renderOption(option, optionIndex++))
2012
+ }, `group:${group.name}`))
1318
2013
  })
1319
2014
  ]
1320
2015
  }), 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.2.0-rc.1",
4
+ "version": "0.2.1-alpha.1",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -38,28 +38,28 @@
38
38
  },
39
39
  "license": "MIT",
40
40
  "peerDependencies": {
41
- "@deepseek-ai/cordis": "~4.0.4"
41
+ "@deepseek-ai/cordis": "~4.0.5-alpha.1"
42
42
  },
43
43
  "devDependencies": {
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.2.0-rc.1",
48
- "@deepseek-ai/dsh-client-connection": "0.2.0-rc.1",
49
- "@deepseek-ai/dsh-client-locale": "0.2.0-rc.1",
50
- "@deepseek-ai/dsh-client-test-runtime": "0.2.0-rc.1",
51
- "@deepseek-ai/dsh-client-ui-conversation": "0.2.0-rc.1",
52
- "@deepseek-ai/dsh-client-ui-primitives": "0.2.0-rc.1",
53
- "@deepseek-ai/dsh-client-ui-slots": "0.2.0-rc.1",
54
- "@deepseek-ai/dsh-api-session-controller": "0.2.0-rc.1",
55
- "@deepseek-ai/dsh-commands": "0.2.0-rc.1",
56
- "@deepseek-ai/dsh-session": "0.2.0-rc.1",
57
- "@deepseek-ai/dsh-util-values": "0.2.0-rc.1",
58
- "@deepseek-ai/dsh-client-ui-renderer": "0.2.0-rc.1",
59
- "@deepseek-ai/dsh-client-ui-session": "0.2.0-rc.1",
60
- "@deepseek-ai/dsh-client-store": "0.2.0-rc.1",
61
- "@deepseek-ai/cordis": "~4.0.4",
62
- "@deepseek-ai/dsh-client-ui-input-trigger": "0.2.0-rc.1"
47
+ "@deepseek-ai/dsh-api-remotes": "0.2.1-alpha.1",
48
+ "@deepseek-ai/dsh-client-locale": "0.2.1-alpha.1",
49
+ "@deepseek-ai/dsh-client-test-runtime": "0.2.1-alpha.1",
50
+ "@deepseek-ai/dsh-client-ui-conversation": "0.2.1-alpha.1",
51
+ "@deepseek-ai/dsh-client-ui-primitives": "0.2.1-alpha.1",
52
+ "@deepseek-ai/dsh-client-ui-input-trigger": "0.2.1-alpha.1",
53
+ "@deepseek-ai/dsh-client-connection": "0.2.1-alpha.1",
54
+ "@deepseek-ai/dsh-client-ui-slots": "0.2.1-alpha.1",
55
+ "@deepseek-ai/cordis": "~4.0.5-alpha.1",
56
+ "@deepseek-ai/dsh-api-session-controller": "0.2.1-alpha.1",
57
+ "@deepseek-ai/dsh-client-store": "0.2.1-alpha.1",
58
+ "@deepseek-ai/dsh-session": "0.2.1-alpha.1",
59
+ "@deepseek-ai/dsh-util-values": "0.2.1-alpha.1",
60
+ "@deepseek-ai/dsh-client-ui-session": "0.2.1-alpha.1",
61
+ "@deepseek-ai/dsh-client-ui-renderer": "0.2.1-alpha.1",
62
+ "@deepseek-ai/dsh-commands": "0.2.1-alpha.1"
63
63
  },
64
64
  "files": [
65
65
  "lib/index.js",