@x1a0f3n9/dsh-client-ui-commands 0.1.5-rc.3

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 ADDED
@@ -0,0 +1,1295 @@
1
+ window.__ModuleLoader__.load({
2
+ id: "@x1a0f3n9/dsh-client-ui-commands",
3
+ factory: (require) => {
4
+ var module = { exports: {} };
5
+ var exports = module.exports;
6
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
+ let _deepseek_ai_cordis = require("@deepseek-ai/cordis");
8
+ let _x1a0f3n9_dsh_client_ui_primitives = require("@x1a0f3n9/dsh-client-ui-primitives");
9
+ let _x1a0f3n9_dsh_client_store = require("@x1a0f3n9/dsh-client-store");
10
+ let react_jsx_runtime = require("react/jsx-runtime");
11
+ let react = require("react");
12
+ //#region lib/types/client/locales.js
13
+ /**
14
+ * `command` namespace dictionaries: the composer menu's section headings,
15
+ * the client face (title, description, claim token) of the built-in Host
16
+ * commands whose catalog descriptors carry English text only, and the
17
+ * popupSelect shell's copy.
18
+ */
19
+ /** Simplified Chinese dictionary (the key-set source of truth). */
20
+ const zh = {
21
+ "section.add": "添加",
22
+ "section.commands": "指令",
23
+ "label.goal": "目标",
24
+ "label.plan": "计划",
25
+ "label.feedback": "反馈",
26
+ "label.compact": "压缩",
27
+ "label.permission": "权限",
28
+ "label.export": "下载日志",
29
+ "description.goal": "设置或查看长期任务目标",
30
+ "description.plan": "进入或退出计划模式",
31
+ "description.feedback": "发送关于当前会话的反馈",
32
+ "description.compact": "压缩以上对话内容",
33
+ "description.permission": "切换权限预设(沙箱模式与审批策略)",
34
+ "description.export": "将当前会话内容导出为 ZIP",
35
+ "token.goal": "目标",
36
+ "token.plan": "计划",
37
+ "token.feedback": "反馈",
38
+ "token.compact": "压缩",
39
+ "token.permission": "权限",
40
+ "token.export": "导出",
41
+ "search.placeholder": "搜索…",
42
+ "search.aria": "筛选选项",
43
+ "status.loading": "正在加载选项…",
44
+ "status.applying": "正在应用…",
45
+ "status.empty": "无选项",
46
+ "overlay.aria": "/{command} 选项",
47
+ "listbox.aria": "/{command} 匹配项",
48
+ "notice.attachmentsUnsupported": "/{command} 不接受附件,请先移除附件"
49
+ };
50
+ /** English dictionary, checked complete against the zh key set. */
51
+ const en = {
52
+ "section.add": "Add",
53
+ "section.commands": "Commands",
54
+ "label.goal": "Goal",
55
+ "label.plan": "Plan",
56
+ "label.feedback": "Feedback",
57
+ "label.compact": "Compact",
58
+ "label.permission": "Permission",
59
+ "label.export": "Export",
60
+ "description.goal": "Set or view the goal for a long-running task",
61
+ "description.plan": "Enter or leave plan mode",
62
+ "description.feedback": "Record feedback about this session",
63
+ "description.compact": "Compact older conversation history",
64
+ "description.permission": "Switch the permission preset (sandbox mode + approval policy)",
65
+ "description.export": "Download this Session log as a ZIP archive",
66
+ "token.goal": "goal",
67
+ "token.plan": "plan",
68
+ "token.feedback": "feedback",
69
+ "token.compact": "compact",
70
+ "token.permission": "permission",
71
+ "token.export": "export",
72
+ "search.placeholder": "Search…",
73
+ "search.aria": "Filter options",
74
+ "status.loading": "Loading options…",
75
+ "status.applying": "Applying…",
76
+ "status.empty": "No options",
77
+ "overlay.aria": "/{command} options",
78
+ "listbox.aria": "/{command} matches",
79
+ "notice.attachmentsUnsupported": "/{command} does not accept attachments; remove them first"
80
+ };
81
+ //#endregion
82
+ //#region lib/types/client/resolution.js
83
+ const BUILTINS = {
84
+ goal: "@x1a0f3n9/dsh-command-goal",
85
+ plan: "@x1a0f3n9/dsh-plan-mode",
86
+ feedback: "@x1a0f3n9/dsh-command-feedback",
87
+ compact: "@x1a0f3n9/dsh-command-compact",
88
+ permission: "@x1a0f3n9/dsh-permission-presets",
89
+ export: "@x1a0f3n9/dsh-session-log-export"
90
+ };
91
+ /**
92
+ * Identify a first-party definition without interpreting its display copy.
93
+ * @param descriptor - effective Host descriptor after scoped shadowing.
94
+ * @returns its first-party name, or undefined for another definition.
95
+ */
96
+ function builtinCommandName(descriptor) {
97
+ return Object.keys(BUILTINS).find((name) => descriptor.definitionId === BUILTINS[name]);
98
+ }
99
+ /**
100
+ * Select the input spelling for a menu-picked command.
101
+ * @param descriptor - effective Host descriptor.
102
+ * @param t - command-namespace translator.
103
+ * @returns localized spelling for a known definition, otherwise its registered name.
104
+ */
105
+ function claimToken(descriptor, t) {
106
+ const name = builtinCommandName(descriptor);
107
+ return name === void 0 ? descriptor.name : t(`token.${name}`);
108
+ }
109
+ const TOKEN_ALIASES = new Map(Object.keys(BUILTINS).flatMap((name) => [zh[`token.${name}`], en[`token.${name}`]].map((token) => [token, name])));
110
+ /**
111
+ * Resolve typed spelling against the current Session's effective definitions.
112
+ * @param token - typed name without its leading slash.
113
+ * @param descriptors - effective descriptors in the Session's ready catalog.
114
+ * @returns the matching descriptor; aliases never select an unrelated scoped override.
115
+ */
116
+ function resolveCommand(token, descriptors) {
117
+ const exact = descriptors.find((descriptor) => descriptor.name === token);
118
+ if (exact !== void 0) return exact;
119
+ const name = TOKEN_ALIASES.get(token);
120
+ if (name === void 0) return void 0;
121
+ return descriptors.find((descriptor) => descriptor.definitionId === BUILTINS[name]);
122
+ }
123
+ //#endregion
124
+ //#region lib/types/client/directory.js
125
+ /** One session key's cache cell. */
126
+ var Entry = class {
127
+ state = "cold";
128
+ commands = [];
129
+ /** Bumped at each pull start; only the latest pull may publish its outcome. */
130
+ epoch = 0;
131
+ lastError;
132
+ waiters = [];
133
+ };
134
+ /** The session-keyed directory cache. Plain class — the owning service wires events and RPC. */
135
+ var CommandDirectory = class {
136
+ fetchCommands;
137
+ entries = /* @__PURE__ */ new Map();
138
+ constructor(fetchCommands) {
139
+ this.fetchCommands = fetchCommands;
140
+ }
141
+ /**
142
+ * Current cache status for one session.
143
+ * @param sessionId - session key.
144
+ * @returns the entry status (cold when never touched).
145
+ */
146
+ status(sessionId) {
147
+ return this.entries.get(sessionId)?.state ?? "cold";
148
+ }
149
+ /**
150
+ * Synchronous command lookup over one Session's ready catalog; exact names precede localized aliases.
151
+ * @param sessionId - session key.
152
+ * @param name - typed command spelling without the leading slash.
153
+ * @returns the descriptor, or undefined when absent or the entry is not ready.
154
+ */
155
+ resolve(sessionId, name) {
156
+ const entry = this.entries.get(sessionId);
157
+ if (entry === void 0 || entry.state !== "ready") return void 0;
158
+ return resolveCommand(name, entry.commands);
159
+ }
160
+ /** Soft invalidation (commands-changed): background repull on every touched key; ready snapshots keep serving. */
161
+ invalidateAll() {
162
+ for (const key of this.entries.keys()) this.refresh(key);
163
+ }
164
+ /**
165
+ * Drop one Session's obsolete composition-specific snapshot and prewarm its replacement.
166
+ * @param sessionId - Session whose effective command composition changed.
167
+ */
168
+ resetSession(sessionId) {
169
+ const entry = this.entry(sessionId);
170
+ entry.state = "cold";
171
+ entry.commands = [];
172
+ entry.lastError = void 0;
173
+ this.refresh(sessionId);
174
+ }
175
+ /**
176
+ * Hard reset on reconnect: every entry drops its snapshot (the agent world
177
+ * may have changed shape across the generation) and prewarms.
178
+ */
179
+ resetConnected() {
180
+ for (const [key, entry] of this.entries) {
181
+ entry.state = "cold";
182
+ entry.commands = [];
183
+ this.refresh(key);
184
+ }
185
+ }
186
+ /**
187
+ * Fire-and-forget prewarm of one session (the command source's scope-birth
188
+ * warm hook lands here).
189
+ * @param sessionId - session key.
190
+ */
191
+ warm(sessionId) {
192
+ const entry = this.entry(sessionId);
193
+ if (entry.state === "cold" || entry.state === "failed") this.refresh(sessionId);
194
+ }
195
+ /**
196
+ * Start one pull for one session. Publishes ready/failed only while it is
197
+ * still the key's latest pull (epoch guard); a ready snapshot is not
198
+ * demoted while the pull flies.
199
+ * @param sessionId - session key.
200
+ * @returns settled when this pull's outcome is published or discarded.
201
+ */
202
+ async refresh(sessionId) {
203
+ const entry = this.entry(sessionId);
204
+ const epoch = ++entry.epoch;
205
+ if (entry.state !== "ready") entry.state = "pending";
206
+ try {
207
+ const commands = await this.fetchCommands(sessionId);
208
+ if (epoch !== entry.epoch) return;
209
+ entry.commands = commands;
210
+ entry.state = "ready";
211
+ entry.lastError = void 0;
212
+ } catch (error) {
213
+ if (epoch !== entry.epoch) return;
214
+ entry.commands = [];
215
+ entry.state = "failed";
216
+ entry.lastError = error;
217
+ } finally {
218
+ if (epoch === entry.epoch) notifyWaiters(entry);
219
+ }
220
+ }
221
+ /**
222
+ * Strong-wait until one session's catalog is servable (the enter-
223
+ * adjudication "directory must be reached" rule): ready returns at once;
224
+ * cold/failed launch a fresh pull; pending joins the flying one. Rejects
225
+ * when the awaited pull fails or the signal aborts.
226
+ * @param sessionId - session key.
227
+ * @param signal - attempt-scoped abort (the SubmitAttempt signal).
228
+ * @returns the hot command snapshot.
229
+ */
230
+ async ensureReady(sessionId, signal) {
231
+ const entry = this.entry(sessionId);
232
+ while (true) {
233
+ if (entry.state === "ready") return entry.commands;
234
+ if (entry.state !== "pending") this.refresh(sessionId);
235
+ await settled(entry, signal);
236
+ if (entry.state === "failed") throw new Error(`command directory warmup failed: ${entry.lastError instanceof Error ? entry.lastError.message : String(entry.lastError)}`);
237
+ }
238
+ }
239
+ entry(sessionId) {
240
+ let entry = this.entries.get(sessionId);
241
+ if (entry === void 0) {
242
+ entry = new Entry();
243
+ this.entries.set(sessionId, entry);
244
+ }
245
+ return entry;
246
+ }
247
+ };
248
+ /** One settlement tick for one entry: resolves at the next winning publish, rejects on abort. */
249
+ function settled(entry, signal) {
250
+ if (signal.aborted) return Promise.reject(abortReason(signal));
251
+ return new Promise((resolve, reject) => {
252
+ const waiter = () => {
253
+ signal.removeEventListener("abort", onAbort);
254
+ resolve();
255
+ };
256
+ const onAbort = () => {
257
+ entry.waiters = entry.waiters.filter((w) => w !== waiter);
258
+ reject(abortReason(signal));
259
+ };
260
+ signal.addEventListener("abort", onAbort, { once: true });
261
+ entry.waiters.push(waiter);
262
+ });
263
+ }
264
+ function notifyWaiters(entry) {
265
+ const woken = entry.waiters;
266
+ entry.waiters = [];
267
+ for (const wake of woken) wake();
268
+ }
269
+ /** Normalize an abort into an Error rejection. */
270
+ function abortReason(signal) {
271
+ return signal.reason instanceof Error ? signal.reason : /* @__PURE__ */ new Error("command directory wait aborted");
272
+ }
273
+ //#endregion
274
+ //#region lib/types/client/popup.js
275
+ /**
276
+ * Headless popupSelect shell state: one controller per client
277
+ * session, owned by CommandUiRuntime's per-session map and torn down by the
278
+ * session scope disposer. The shell is a transient layer (never in the input
279
+ * state machine): it loads options once, filters them locally against the
280
+ * shell's own search text, and settles a selection through the context
281
+ * captured at open time. Draft consumption and composer focus are injected
282
+ * callbacks — the session wiring dispatches the consume-token event (the
283
+ * Input side owns the span/bare-token CAS guard) and focuses the composer;
284
+ * the controller never touches the input machine.
285
+ */
286
+ const CLOSED = {
287
+ open: false,
288
+ command: null,
289
+ status: "pending",
290
+ options: [],
291
+ search: "",
292
+ active: 0,
293
+ submitting: false,
294
+ confirming: null,
295
+ acknowledged: false,
296
+ error: null
297
+ };
298
+ /**
299
+ * Filter option rows against the shell's local search text (case-insensitive
300
+ * substring over label and detail; blank search keeps every row).
301
+ * @param options - the loaded rows.
302
+ * @param search - the shell's search text.
303
+ * @returns the rows the shell shows and highlights over.
304
+ */
305
+ function filterOptions(options, search) {
306
+ const query = search.trim().toLowerCase();
307
+ if (query === "") return options;
308
+ return options.filter((o) => o.label.toLowerCase().includes(query) || (o.detail?.toLowerCase().includes(query) ?? false));
309
+ }
310
+ /** The shell's error-strip line for a settlement failure. */
311
+ function errorText(error) {
312
+ return error instanceof Error ? error.message : String(error);
313
+ }
314
+ /**
315
+ * Headless controller of one session's popupSelect shell. Late settlements
316
+ * lose their write rights through binding identity: dismiss/dispose/reopen
317
+ * swap the binding, so a settling options fetch or onSelect that no longer
318
+ * matches writes nothing and consumes nothing.
319
+ */
320
+ var PopupSelectController = class {
321
+ deps;
322
+ /** Shell state store (the overlay component subscribes here). */
323
+ state = (0, _x1a0f3n9_dsh_client_store.createSnapshotStore)(CLOSED);
324
+ binding = null;
325
+ /**
326
+ * @param deps - session-wiring callbacks (token consumption + composer focus).
327
+ */
328
+ constructor(deps) {
329
+ this.deps = deps;
330
+ }
331
+ /**
332
+ * Open the shell for one command: publish pending state and fetch options
333
+ * once through the business spec. A reopen supersedes the previous shell
334
+ * (its options fetch is aborted, its late settlements are dropped).
335
+ * @param command - command name the shell serves.
336
+ * @param spec - the registered popupSelect spec.
337
+ * @param context - open-time context snapshot, handed verbatim to options/onSelect.
338
+ * @param segment - open-time token segment snapshot for post-select consumption.
339
+ */
340
+ open(command, spec, context, segment) {
341
+ this.binding?.abort.abort();
342
+ const binding = {
343
+ command,
344
+ spec,
345
+ context,
346
+ segment,
347
+ abort: new AbortController()
348
+ };
349
+ this.binding = binding;
350
+ this.state.set({
351
+ ...CLOSED,
352
+ open: true,
353
+ command
354
+ });
355
+ this.load(binding);
356
+ }
357
+ /** Run the one options fetch of a binding; settlement rights die with the binding. */
358
+ load(binding) {
359
+ binding.spec.options(binding.context, binding.abort.signal).then((options) => {
360
+ if (this.binding !== binding) return;
361
+ this.state.set({
362
+ ...this.state.getSnapshot(),
363
+ status: "ready",
364
+ options,
365
+ active: 0,
366
+ error: null
367
+ });
368
+ }, (error) => {
369
+ if (this.binding !== binding) return;
370
+ console.error(`[ui-commands] popupSelect options failed for /${binding.command}:`, error);
371
+ this.state.set({
372
+ ...this.state.getSnapshot(),
373
+ status: "failed",
374
+ options: [],
375
+ active: 0,
376
+ error: errorText(error)
377
+ });
378
+ });
379
+ }
380
+ /** Re-run a failed options fetch (search survives; no-op unless status is 'failed'). */
381
+ retry() {
382
+ const binding = this.binding;
383
+ const s = this.state.getSnapshot();
384
+ if (binding === null || !s.open || s.status !== "failed") return;
385
+ this.state.set({
386
+ ...s,
387
+ status: "pending",
388
+ error: null
389
+ });
390
+ this.load(binding);
391
+ }
392
+ /**
393
+ * Replace the local search text (pure local filter — the provider is never
394
+ * re-queried) and rebase the highlight onto the new filtered list.
395
+ * @param search - the shell search input's text.
396
+ */
397
+ setSearch(search) {
398
+ const s = this.state.getSnapshot();
399
+ if (!s.open || s.submitting || s.confirming !== null || search === s.search) return;
400
+ this.state.set({
401
+ ...s,
402
+ search,
403
+ active: 0
404
+ });
405
+ }
406
+ /**
407
+ * Move the highlight across the filtered rows (wraps around; no-op unless
408
+ * options are ready and no selection is in flight).
409
+ * @param dir - +1 down, -1 up.
410
+ */
411
+ move(dir) {
412
+ const s = this.state.getSnapshot();
413
+ if (!s.open || s.status !== "ready" || s.submitting || s.confirming !== null) return;
414
+ const rows = filterOptions(s.options, s.search);
415
+ if (rows.length === 0) return;
416
+ const active = (s.active + dir + rows.length) % rows.length;
417
+ this.state.set({
418
+ ...s,
419
+ active
420
+ });
421
+ }
422
+ /**
423
+ * Set the highlight directly (pointer hover; no-op unless ready, idle, and
424
+ * in filtered range).
425
+ * @param index - filtered-row index.
426
+ */
427
+ highlight(index) {
428
+ const s = this.state.getSnapshot();
429
+ if (!s.open || s.status !== "ready" || s.submitting || s.confirming !== null) return;
430
+ if (index < 0 || index >= filterOptions(s.options, s.search).length || index === s.active) return;
431
+ this.state.set({
432
+ ...s,
433
+ active: index
434
+ });
435
+ }
436
+ /**
437
+ * Select one filtered row: single-flight — the first call enters
438
+ * `submitting` and later calls no-op until it settles. Success consumes the
439
+ * open-time token segment (a false CAS answer is benign), closes, and
440
+ * returns focus to the composer. Failure keeps the shell open with search,
441
+ * highlight, and token intact, surfaces the error, and re-arms select as
442
+ * the retry.
443
+ * @param index - filtered-row index (callers pass the highlight or the clicked row).
444
+ * @returns settled when the attempt has closed the shell or surfaced its failure.
445
+ */
446
+ async select(index) {
447
+ const binding = this.binding;
448
+ const s = this.state.getSnapshot();
449
+ if (binding === null || !s.open || s.status !== "ready" || s.submitting || s.confirming !== null) return;
450
+ const option = filterOptions(s.options, s.search)[index];
451
+ if (option === void 0) return;
452
+ if (option.confirmation !== void 0) {
453
+ this.state.set({
454
+ ...s,
455
+ confirming: option,
456
+ acknowledged: false,
457
+ error: null
458
+ });
459
+ return;
460
+ }
461
+ await this.settle(binding, option);
462
+ }
463
+ /**
464
+ * Update the explicit checkbox for the currently pending risk gate.
465
+ * @param acknowledged - whether the user has acknowledged the displayed risk.
466
+ */
467
+ acknowledge(acknowledged) {
468
+ const s = this.state.getSnapshot();
469
+ if (!s.open || s.submitting || s.confirming === null || s.acknowledged === acknowledged) return;
470
+ this.state.set({
471
+ ...s,
472
+ acknowledged
473
+ });
474
+ }
475
+ /** Cancel only the risk gate and return to the still-open option picker. */
476
+ cancelConfirmation() {
477
+ const s = this.state.getSnapshot();
478
+ if (!s.open || s.submitting || s.confirming === null) return;
479
+ this.state.set({
480
+ ...s,
481
+ confirming: null,
482
+ acknowledged: false
483
+ });
484
+ }
485
+ /** Settle the gated option only after the checkbox is acknowledged. */
486
+ async confirm() {
487
+ const binding = this.binding;
488
+ const s = this.state.getSnapshot();
489
+ if (binding === null || !s.open || s.submitting || s.confirming === null || !s.acknowledged) return;
490
+ await this.settle(binding, s.confirming);
491
+ }
492
+ /** Run the business settlement for an already admitted option. */
493
+ async settle(binding, option) {
494
+ const s = this.state.getSnapshot();
495
+ if (this.binding !== binding || !s.open || s.submitting) return;
496
+ this.state.set({
497
+ ...s,
498
+ submitting: true,
499
+ confirming: null,
500
+ acknowledged: false,
501
+ error: null
502
+ });
503
+ try {
504
+ await binding.spec.onSelect(option, binding.context);
505
+ } catch (error) {
506
+ console.error(`[ui-commands] popupSelect onSelect failed for /${binding.command}:`, error);
507
+ if (this.binding !== binding) return;
508
+ this.state.set({
509
+ ...this.state.getSnapshot(),
510
+ submitting: false,
511
+ error: errorText(error)
512
+ });
513
+ return;
514
+ }
515
+ if (this.binding !== binding) return;
516
+ this.deps.consume(binding.segment);
517
+ this.binding = null;
518
+ this.state.set(CLOSED);
519
+ this.deps.focusComposer();
520
+ }
521
+ /**
522
+ * Close the shell; aborts a flying options fetch and revokes settlement
523
+ * rights. An outside pointer interaction dismisses plainly (the click's own
524
+ * target takes focus); Escape passes focusComposer to return focus explicitly.
525
+ * @param opts - focusComposer: also restore composer focus (Escape path).
526
+ */
527
+ dismiss(opts) {
528
+ if (this.binding === null) return;
529
+ this.binding.abort.abort();
530
+ this.binding = null;
531
+ this.state.set(CLOSED);
532
+ if (opts?.focusComposer === true) this.deps.focusComposer();
533
+ }
534
+ /** Scope-teardown disposer: abort in-flight work and clear state (no focus side effect). */
535
+ dispose() {
536
+ this.binding?.abort.abort();
537
+ this.binding = null;
538
+ this.state.set(CLOSED);
539
+ }
540
+ };
541
+ //#endregion
542
+ //#region lib/types/client/presentation.js
543
+ /** Row names per section, highest usage first; rows outside both lists close the Commands section in catalog order. */
544
+ const SECTION_ROWS = {
545
+ add: [
546
+ "file",
547
+ "goal",
548
+ "plan",
549
+ "feedback"
550
+ ],
551
+ commands: [
552
+ "compact",
553
+ "permission",
554
+ "model",
555
+ "export"
556
+ ]
557
+ };
558
+ /** One built-in Host command's face, keyed by its dictionary entries. */
559
+ function hostFace(name, icon) {
560
+ return [name, {
561
+ label: `label.${name}`,
562
+ description: `description.${name}`,
563
+ icon
564
+ }];
565
+ }
566
+ /** Built-in Host commands whose client face this package owns. */
567
+ const HOST_FACES = new Map([
568
+ hostFace("goal", _x1a0f3n9_dsh_client_ui_primitives.IconGoalOutline16),
569
+ hostFace("plan", _x1a0f3n9_dsh_client_ui_primitives.IconPlanOutline14),
570
+ hostFace("feedback", _x1a0f3n9_dsh_client_ui_primitives.IconSendOutline16),
571
+ hostFace("compact", _x1a0f3n9_dsh_client_ui_primitives.IconCompactOutline16),
572
+ hostFace("permission", _x1a0f3n9_dsh_client_ui_primitives.IconShieldOutline16),
573
+ hostFace("export", _x1a0f3n9_dsh_client_ui_primitives.IconDownloadOutline16)
574
+ ]);
575
+ /**
576
+ * The localized menu face of a catalog row.
577
+ * @param descriptor - effective Host command descriptor.
578
+ * @param t - the `command` namespace translator.
579
+ * @returns title, description, and glyph for a built-in command; undefined
580
+ * for any other row, which keeps its catalog description.
581
+ */
582
+ function builtinRowFace(descriptor, t) {
583
+ const name = builtinCommandName(descriptor);
584
+ const face = name === void 0 ? void 0 : HOST_FACES.get(name);
585
+ return face === void 0 ? void 0 : {
586
+ label: t(face.label),
587
+ description: t(face.description),
588
+ icon: face.icon
589
+ };
590
+ }
591
+ /**
592
+ * Arrange the empty-query menu: the Add section, then the Commands section,
593
+ * each in usage order, with unlisted rows closing Commands in their input
594
+ * order; each row carries its section heading.
595
+ * @param rows - the visible candidates in catalog-then-contribution order.
596
+ * @param t - the `command` namespace translator.
597
+ * @returns the sectioned rows.
598
+ */
599
+ function sectionRows(rows, t) {
600
+ const listed = new Set([...SECTION_ROWS.add, ...SECTION_ROWS.commands]);
601
+ const byName = new Map(rows.map((row) => [row.name, row]));
602
+ const pick = (names) => names.flatMap((name) => {
603
+ const row = byName.get(name);
604
+ return row === void 0 ? [] : [row];
605
+ });
606
+ const add = pick(SECTION_ROWS.add).map((row) => ({
607
+ ...row,
608
+ section: t("section.add")
609
+ }));
610
+ const commands = [...pick(SECTION_ROWS.commands), ...rows.filter((row) => !listed.has(row.name))].map((row) => ({
611
+ ...row,
612
+ section: t("section.commands")
613
+ }));
614
+ return [...add, ...commands];
615
+ }
616
+ //#endregion
617
+ //#region lib/types/client/service.js
618
+ /**
619
+ * CommandUiRuntime (`ctx.commandUi`): the '/' command source over the
620
+ * session-keyed directory, the client-contribution registry, and the
621
+ * per-session popupSelect controllers. Candidate synthesis merges the host
622
+ * catalog with contributions by availability, gives built-in Host rows their
623
+ * localized face (presentation.ts), then position-filters; an empty query
624
+ * lists the Add and Commands sections in usage order, a typed query ranks
625
+ * every row by the `/` menu's shared name-and-label ranking (ui-primitives
626
+ * `rankByName`). A host/contribution name collision fails loud. Every
627
+ * execute addresses the session's agent by sessionId — sessions are always
628
+ * agent-backed.
629
+ */
630
+ /** Recover the command name from a line the Host confirmed as executed. */
631
+ function submittedCommandName(line) {
632
+ const trimmed = line.trim();
633
+ const separator = trimmed.search(/\s/u);
634
+ return (separator === -1 ? trimmed : trimmed.slice(0, separator)).slice(1);
635
+ }
636
+ /** Command surface: session-keyed directory + '/' source + contribution registry + per-session popups. */
637
+ var CommandUiRuntime = class extends _deepseek_ai_cordis.Service {
638
+ static inject = [
639
+ "inputTriggers",
640
+ "sessions",
641
+ "remote",
642
+ "remote.commands"
643
+ ];
644
+ directory;
645
+ live = {
646
+ contributions: /* @__PURE__ */ new Map(),
647
+ decorations: /* @__PURE__ */ new Map(),
648
+ popups: /* @__PURE__ */ new Map()
649
+ };
650
+ /** `command`-namespace translator (composer refusal notices). */
651
+ t;
652
+ /**
653
+ * @param ctx - owning root context (plugin fiber; the service registers
654
+ * itself as `command` and follows that fiber's lifetime).
655
+ */
656
+ constructor(ctx) {
657
+ super(ctx, "commandUi");
658
+ const locale = ctx.get("locale");
659
+ if (locale === void 0) throw new Error("ui-commands: locale service unavailable");
660
+ this.t = locale.bind("command");
661
+ this.directory = new CommandDirectory(async (sessionId) => {
662
+ if (this.sessions().subagentAddress(sessionId) !== void 0) return [];
663
+ const result = await ctx.remote.commands.list(sessionId);
664
+ if (!result.ok) throw new Error(`command.list failed: ${result.error.code}: ${result.error.message}`);
665
+ return result.value;
666
+ });
667
+ const inputTriggers = ctx.get("inputTriggers");
668
+ if (inputTriggers === void 0) throw new Error("ui-commands: slash service unavailable");
669
+ ctx.effect(() => inputTriggers.registerSource({
670
+ trigger: "/",
671
+ name: "command",
672
+ candidates: (session, req) => this.candidates(session, req),
673
+ onPick: (pick) => this.dispatch(pick),
674
+ matchSpace: (session, token) => this.matchSpace(session, token),
675
+ matchEnter: (session, line, signal, envelope) => this.matchEnter(session, line, signal, envelope),
676
+ warm: (session) => {
677
+ this.directory.warm(session.sessionId);
678
+ }
679
+ }), "command: slash source");
680
+ ctx.remote.$on("commands/change", () => {
681
+ this.directory.invalidateAll();
682
+ });
683
+ ctx.remote.$on("agent-preset/selected", (sessionId) => {
684
+ this.directory.resetSession(sessionId);
685
+ });
686
+ ctx.on("connection/reset", () => {
687
+ this.directory.resetConnected();
688
+ });
689
+ }
690
+ /**
691
+ * Register one client command contribution; effect disposer (rides the
692
+ * caller's fiber). Duplicate names throw.
693
+ * @param contribution - the contribution (descriptor + availability + popup spec).
694
+ * @returns the disposer removing the registration.
695
+ */
696
+ register(contribution) {
697
+ const dispose = this.ctx.effect(() => {
698
+ const { contributions } = this.live;
699
+ if (contributions.has(contribution.name)) throw new Error(`ui-commands: duplicate contribution for /${contribution.name}`);
700
+ contributions.set(contribution.name, contribution);
701
+ return () => {
702
+ contributions.delete(contribution.name);
703
+ };
704
+ }, "command.register()");
705
+ return () => {
706
+ dispose();
707
+ };
708
+ }
709
+ /**
710
+ * Hang a bare-invocation decoration on one host command; effect disposer
711
+ * (rides the caller's fiber). Duplicate names throw.
712
+ * @param decoration - host command name + availability + popup spec.
713
+ * @returns the disposer removing the registration.
714
+ */
715
+ decorate(decoration) {
716
+ const dispose = this.ctx.effect(() => {
717
+ const { decorations } = this.live;
718
+ if (decorations.has(decoration.name)) throw new Error(`ui-commands: duplicate decoration for /${decoration.name}`);
719
+ decorations.set(decoration.name, decoration);
720
+ return () => {
721
+ decorations.delete(decoration.name);
722
+ };
723
+ }, "command.decorate()");
724
+ return () => {
725
+ dispose();
726
+ };
727
+ }
728
+ /**
729
+ * Resolve the per-session popup controller (lazy; dies with the session
730
+ * scope). The controller's consume callback dispatches the scoped
731
+ * consume-token event back to this session; focusComposer reaches the
732
+ * composer through the overlay slot currency.
733
+ * @param actx - session-scope ctx.
734
+ * @returns the resident controller.
735
+ */
736
+ popupFor(actx) {
737
+ const id = this.sessions().scopeOf(actx);
738
+ if (id === void 0) throw new Error("command.popupFor requires a session scope");
739
+ const { popups } = this.live;
740
+ const existing = popups.get(id);
741
+ if (existing !== void 0) return existing;
742
+ const controller = new PopupSelectController({
743
+ consume: (segment) => actx.bail(actx, "slash/input-consume-token", { guard: segment.via === "menu" ? {
744
+ kind: "span",
745
+ span: segment.span
746
+ } : {
747
+ kind: "bare-token",
748
+ token: segment.token
749
+ } }) === true,
750
+ focusComposer: () => {
751
+ this.focusHooks.get(id)?.();
752
+ }
753
+ });
754
+ popups.set(id, controller);
755
+ actx.effect(() => () => {
756
+ controller.dispose();
757
+ popups.delete(id);
758
+ this.focusHooks.delete(id);
759
+ }, "command: session popup");
760
+ return controller;
761
+ }
762
+ /** Composer focus hooks by session (the overlay wiring binds the textarea focus here). */
763
+ focusHooks = /* @__PURE__ */ new Map();
764
+ /**
765
+ * Bind one session's composer-focus hook (overlay slot wiring; unbind on unmount).
766
+ * @param id - session id.
767
+ * @param focus - textarea focus callback.
768
+ * @returns the unbind disposer.
769
+ */
770
+ bindComposerFocus(id, focus) {
771
+ this.focusHooks.set(id, focus);
772
+ return () => {
773
+ if (this.focusHooks.get(id) === focus) this.focusHooks.delete(id);
774
+ };
775
+ }
776
+ /**
777
+ * Menu candidates: host catalog + contribution availability, built-in rows
778
+ * localized, then position filtering; sections for an empty query, the
779
+ * shared name-and-label ranking for a typed one.
780
+ */
781
+ async candidates(session, req) {
782
+ const list = await this.directory.ensureReady(session.sessionId, req.signal);
783
+ const rows = [];
784
+ const seen = /* @__PURE__ */ new Set();
785
+ for (const c of list) {
786
+ seen.add(c.name);
787
+ rows.push({
788
+ name: c.name,
789
+ ...builtinRowFace(c, this.t) ?? { description: c.description },
790
+ ...c.input !== void 0 ? { hint: c.input.hint } : {}
791
+ });
792
+ }
793
+ for (const contribution of this.live.contributions.values()) {
794
+ if (!contribution.available(session)) continue;
795
+ if (seen.has(contribution.name)) throw new Error(`ui-commands: contribution /${contribution.name} collides with a host command`);
796
+ rows.push({
797
+ name: contribution.name,
798
+ ...contribution.label === void 0 ? {} : { label: contribution.label() },
799
+ ...contribution.description === void 0 ? {} : { description: contribution.description() },
800
+ ...contribution.icon === void 0 ? {} : { icon: contribution.icon }
801
+ });
802
+ }
803
+ const visible = rows.filter((c) => req.position === "leading" || c.hint === void 0);
804
+ return req.query === "" ? sectionRows(visible, this.t) : (0, _x1a0f3n9_dsh_client_ui_primitives.rankByName)(visible, req.query);
805
+ }
806
+ /** Decision table, menu column: contribution/decorated-host → popup or action; host input → claim; host bare → detached execute. */
807
+ dispatch(pick) {
808
+ const name = pick.candidate.name;
809
+ const contribution = this.live.contributions.get(name);
810
+ if (contribution !== void 0 && contribution.available(pick.session)) {
811
+ this.invoke(name, contribution.ui, pick.session, {
812
+ via: "menu",
813
+ span: pick.span
814
+ });
815
+ return "handled";
816
+ }
817
+ const desc = this.directory.resolve(pick.session.sessionId, name);
818
+ if (desc === void 0) return void 0;
819
+ const decoration = this.live.decorations.get(name);
820
+ if (decoration !== void 0 && decoration.available(pick.session)) {
821
+ this.invoke(name, decoration.ui, pick.session, {
822
+ via: "menu",
823
+ span: pick.span
824
+ });
825
+ return "handled";
826
+ }
827
+ if (desc.input !== void 0) return { claim: this.leadingClaim(desc, pick.session, claimToken(desc, this.t)) };
828
+ this.consumeVia(pick.session.sessionId, {
829
+ via: "menu",
830
+ span: pick.span
831
+ });
832
+ this.runDetached(desc, pick.session, `/${name}`);
833
+ return "handled";
834
+ }
835
+ /** Decision table, space column: hot-key sync check; only host leadingInput claims. */
836
+ matchSpace(session, token) {
837
+ if (!token.startsWith("/")) return void 0;
838
+ if (this.live.contributions.has(token.slice(1))) return void 0;
839
+ const desc = this.directory.resolve(session.sessionId, token.slice(1));
840
+ if (desc === void 0 || desc.input === void 0) return void 0;
841
+ return { claim: this.leadingClaim(desc, session, token.slice(1)) };
842
+ }
843
+ /**
844
+ * Decision table, enter column. Strong-waits the session's catalog (a
845
+ * warmup failure rejects — never a silent downgrade). Contributions and
846
+ * bare host commands act on the bare token only; leadingInput claims
847
+ * args-tolerant.
848
+ *
849
+ * Envelope policy: an enter submission carrying attachments resolves only
850
+ * through a command declaring attachment acceptance. Every other submitting
851
+ * route — popup, non-accepting claim, bare detached execute — throws the
852
+ * refusal so the machine surfaces one composer notice and the draft and
853
+ * attachments stay in place; nothing executes and nothing is dropped. An
854
+ * action submits nothing and runs regardless.
855
+ *
856
+ * A typed token is resolved through the localized claim tokens, so a line
857
+ * written as `/计划` reaches the `plan` descriptor and executes as `/plan`.
858
+ */
859
+ async matchEnter(session, line, signal, envelope) {
860
+ const trimmed = line.trim();
861
+ if (!trimmed.startsWith("/")) return void 0;
862
+ const ws = trimmed.search(/\s/);
863
+ const token = ws === -1 ? trimmed : trimmed.slice(0, ws);
864
+ const bare = ws === -1;
865
+ const typedName = token.slice(1);
866
+ if (typedName === "") return void 0;
867
+ const refuseAttachments = () => {
868
+ throw new Error(this.t("notice.attachmentsUnsupported", { command: typedName }));
869
+ };
870
+ const contribution = this.live.contributions.get(typedName);
871
+ if (contribution !== void 0 && contribution.available(session)) {
872
+ if (!bare) return void 0;
873
+ if (envelope.attachments > 0 && contribution.ui.kind !== "action") refuseAttachments();
874
+ this.invoke(typedName, contribution.ui, session, {
875
+ via: "enter",
876
+ token
877
+ });
878
+ return "handled";
879
+ }
880
+ await this.directory.ensureReady(session.sessionId, signal);
881
+ const desc = this.directory.resolve(session.sessionId, typedName);
882
+ if (desc === void 0) return void 0;
883
+ const name = desc.name;
884
+ const canonical = `/${name}${trimmed.slice(token.length)}`;
885
+ if (bare) {
886
+ const decoration = this.live.decorations.get(name);
887
+ if (decoration !== void 0 && decoration.available(session)) {
888
+ if (envelope.attachments > 0 && decoration.ui.kind !== "action") refuseAttachments();
889
+ this.invoke(name, decoration.ui, session, {
890
+ via: "enter",
891
+ token
892
+ });
893
+ return "handled";
894
+ }
895
+ }
896
+ if (desc.input !== void 0) {
897
+ if (envelope.attachments > 0 && desc.input.attachments !== true) refuseAttachments();
898
+ return { claim: this.leadingClaim(desc, session, token.slice(1)) };
899
+ }
900
+ if (!bare) return void 0;
901
+ if (envelope.attachments > 0) refuseAttachments();
902
+ this.consumeVia(session.sessionId, {
903
+ via: "enter",
904
+ token
905
+ });
906
+ this.runDetached(desc, session, canonical);
907
+ return "handled";
908
+ }
909
+ /**
910
+ * Invoke one contribution or decoration (menu pick / bare enter): open the
911
+ * session's popup, or consume the token and run the action.
912
+ */
913
+ invoke(name, ui, session, segment) {
914
+ if (ui.kind === "action") {
915
+ this.consumeVia(session.sessionId, segment);
916
+ ui.run(session);
917
+ return;
918
+ }
919
+ const actx = this.scopeFor(session.sessionId);
920
+ if (actx === void 0) return;
921
+ this.popupFor(actx).open(name, ui, session, segment);
922
+ }
923
+ /**
924
+ * Build the leadingInput claim. The composer keeps the claimed token in
925
+ * the draft and reads the arguments after it, so the token is the spelling
926
+ * the draft will carry: the locale's token for a menu pick, the typed
927
+ * spelling for Space and Enter. The command.execute submit transaction
928
+ * always sends the catalog name.
929
+ */
930
+ leadingClaim(desc, session, shown) {
931
+ const token = `/${shown} `;
932
+ const line = `/${desc.name} `;
933
+ return {
934
+ name: desc.name,
935
+ token,
936
+ ...desc.input !== void 0 ? { hint: desc.input.hint } : {},
937
+ ...desc.input?.attachments === true ? { attachments: true } : {},
938
+ submit: (args, _actx, attachments) => this.execute(session, line + args, attachments)
939
+ };
940
+ }
941
+ /**
942
+ * The command.execute transaction, addressed to the session's agent — pure
943
+ * admission semantics. An unmatched line reports an error outcome (the
944
+ * composer's immediate admission feedback); an admitted command reports
945
+ * plain success regardless of its handler outcome, because the host
946
+ * executor durably logged the lifecycle (`command/run`/`command/done`) and
947
+ * the outcome renders as a persistent flow node — the composer never
948
+ * echoes it. A handler error result reports an error outcome so the
949
+ * composer keeps the draft and attachments for correction.
950
+ * A refused call throws.
951
+ */
952
+ async execute(session, line, attachments = []) {
953
+ const result = await this.ctx.remote.commands.execute(session.sessionId, line, attachments);
954
+ if (!result.ok) throw new Error(`command.execute failed: ${result.error.code}: ${result.error.message}`);
955
+ if (result.value === void 0) return {
956
+ kind: "error",
957
+ text: `unknown or malformed command: ${line}`
958
+ };
959
+ this.notifyExecuted(session.sessionId, submittedCommandName(line), result.value.result);
960
+ if (attachments.length > 0 && result.value.result.kind === "error") return {
961
+ kind: "error",
962
+ text: result.value.result.text
963
+ };
964
+ return { kind: "success" };
965
+ }
966
+ /** Publish the local acknowledgment without letting an observer change command admission. */
967
+ notifyExecuted(sessionId, name, result) {
968
+ const args = [
969
+ "command/executed",
970
+ sessionId,
971
+ name,
972
+ result
973
+ ];
974
+ for (const listener of this.ctx.events.dispatch("emit", args)) try {
975
+ const returned = listener(sessionId, name, result);
976
+ if (returned != null && typeof returned.then === "function") Promise.resolve(returned).then(void 0, (error) => {
977
+ this.warnExecutedListenerFailure(name, error);
978
+ });
979
+ } catch (error) {
980
+ this.warnExecutedListenerFailure(name, error);
981
+ }
982
+ }
983
+ /** Log one contained `command/executed` observer failure. */
984
+ warnExecutedListenerFailure(name, error) {
985
+ this.ctx.logger.warn("client command: a command/executed listener for \"%s\" failed", name);
986
+ this.ctx.logger.warn(error);
987
+ }
988
+ /**
989
+ * Fire-and-forget execute for the internal ('handled') paths. Outcomes are
990
+ * NOT surfaced here: the host executor durably logs the command lifecycle
991
+ * (`command/run`/`command/done`), and the mux-broadcast events render as a
992
+ * persistent flow node on every tab. Only an admission failure — which never
993
+ * entered a handler and therefore never logged — falls back to the composer
994
+ * notice as immediate feedback.
995
+ */
996
+ runDetached(desc, session, line) {
997
+ this.execute(session, line).then((outcome) => {
998
+ if (outcome.kind === "error") this.noticeFor(session.sessionId, "error", outcome.text ?? `/${desc.name} failed`);
999
+ }, (error) => {
1000
+ this.noticeFor(session.sessionId, "error", error instanceof Error ? error.message : String(error));
1001
+ });
1002
+ }
1003
+ /** Dispatch a consume-token event to one session (menu-pick / bare-enter execute paths). */
1004
+ consumeVia(id, segment) {
1005
+ const actx = this.scopeFor(id);
1006
+ if (actx === void 0) return;
1007
+ actx.bail(actx, "slash/input-consume-token", { guard: segment.via === "menu" ? {
1008
+ kind: "span",
1009
+ span: segment.span
1010
+ } : {
1011
+ kind: "bare-token",
1012
+ token: segment.token
1013
+ } });
1014
+ }
1015
+ /** Route an admission failure to the session's composer notice channel (scope gone = attempt died with it). */
1016
+ noticeFor(id, level, text) {
1017
+ const actx = this.scopeFor(id);
1018
+ if (actx === void 0) return;
1019
+ const conversation = actx.get("conversation");
1020
+ if (conversation === void 0) return;
1021
+ conversation.input.for(actx).notify(level, text);
1022
+ }
1023
+ /** id → actx interchange (registered exchange point: this service coordinates for projection-only sources). */
1024
+ scopeFor(id) {
1025
+ return this.sessions().scope(id);
1026
+ }
1027
+ sessions() {
1028
+ const sessions = this.ctx.get("sessions");
1029
+ if (sessions === void 0) throw new Error("ui-commands: sessions service unavailable");
1030
+ return sessions;
1031
+ }
1032
+ };
1033
+ //#endregion
1034
+ //#region ../../../node_modules/.pnpm/clsx@2.1.1/node_modules/clsx/dist/clsx.mjs
1035
+ function r(e) {
1036
+ var t, f, n = "";
1037
+ if ("string" == typeof e || "number" == typeof e) n += e;
1038
+ else if ("object" == typeof e) if (Array.isArray(e)) {
1039
+ var o = e.length;
1040
+ for (t = 0; t < o; t++) e[t] && (f = r(e[t])) && (n && (n += " "), n += f);
1041
+ } else for (f in e) e[f] && (n && (n += " "), n += f);
1042
+ return n;
1043
+ }
1044
+ function clsx() {
1045
+ for (var e, t, f = 0, n = "", o = arguments.length; f < o; f++) (e = arguments[f]) && (t = r(e)) && (n && (n += " "), n += t);
1046
+ return n;
1047
+ }
1048
+ //#endregion
1049
+ //#region \0dsh-css:/home/runner/work/deepseek-harness/deepseek-harness/packages/client/ui-commands/src/client/PopupSelectView.module.css.mjs
1050
+ 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);background:var(--dsw-specific-menu);--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;border-radius:20px;outline:none;flex-direction:column;padding:4px;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{cursor:pointer;color:var(--dsw-alias-label-primary);border-radius:8px;align-items:center;gap:8px;padding:6px 8px;font-size:13px;display:flex}.mufS8W_rowActive{background:var(--dsw-alias-interactive-bg-hover)}.mufS8W_label{white-space:nowrap;text-overflow:ellipsis;flex:auto;min-width:0;overflow:hidden}.mufS8W_detail{color:var(--dsw-alias-label-tertiary);white-space:nowrap;text-overflow:ellipsis;font-size:12px;overflow:hidden}.mufS8W_check{color:var(--dsw-alias-label-primary);flex:none;display:inline-flex}.mufS8W_status{color:var(--dsw-alias-label-tertiary);padding:8px 10px;font-size:13px}.mufS8W_search{border:.5px solid var(--dsw-alias-border-inverted);color:var(--dsw-alias-label-primary);background:0 0;border-radius:8px;outline:none;margin:2px 2px 4px;padding:6px 8px;font-size:13px}.mufS8W_error{color:var(--dsw-alias-state-error-primary);align-items:center;gap:8px;padding:6px 8px;font-size:12px;display:flex}.mufS8W_errorText{text-overflow:ellipsis;flex:1;overflow:hidden}.mufS8W_retry{border:.5px solid var(--dsw-alias-border-inverted);color:var(--dsw-alias-label-primary);cursor:pointer;background:0 0;border-radius:6px;padding:2px 8px;font-size:12px}";
1051
+ const tagId = "@x1a0f3n9/dsh-client-ui-commands/PopupSelectView.module.css";
1052
+ if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
1053
+ const tag = document.createElement("style");
1054
+ tag.dataset.plugin = "@x1a0f3n9/dsh-client-ui-commands";
1055
+ tag.dataset.pluginCss = tagId;
1056
+ tag.textContent = css;
1057
+ document.head.appendChild(tag);
1058
+ }
1059
+ var PopupSelectView_module_css_default = {
1060
+ "card": "mufS8W_card",
1061
+ "check": "mufS8W_check",
1062
+ "detail": "mufS8W_detail",
1063
+ "error": "mufS8W_error",
1064
+ "errorText": "mufS8W_errorText",
1065
+ "label": "mufS8W_label",
1066
+ "retry": "mufS8W_retry",
1067
+ "row": "mufS8W_row",
1068
+ "rowActive": "mufS8W_rowActive",
1069
+ "search": "mufS8W_search",
1070
+ "status": "mufS8W_status",
1071
+ "viewport": "mufS8W_viewport"
1072
+ };
1073
+ //#endregion
1074
+ //#region lib/types/client/PopupSelectView.js
1075
+ /**
1076
+ * Official popupSelect shell: renders one session's PopupSelectController
1077
+ * store into the conversation.input.overlay anchor. Unlike the slash menu
1078
+ * (combobox — textarea keeps focus), this shell HOLDS focus while open: the
1079
+ * inner search input takes focus, plain typing filters the loaded options
1080
+ * locally, Enter/↑↓ drive the filtered highlight (scrolled into view), Escape
1081
+ * dismisses back to the composer, and ←→ keep the search input's native
1082
+ * caret. Any pointer interaction outside the box dismisses (the click's own
1083
+ * target takes focus). Closed state renders null; the overlay slot stays
1084
+ * mounted. The card height clamps to the space above the composer.
1085
+ */
1086
+ /** Design cap on the card height (same MenuDropdown family as the slash menu). */
1087
+ const MAX_HEIGHT = 320;
1088
+ /**
1089
+ * Render the popupSelect shell overlay entry.
1090
+ * @param props - injected face: the session's shell controller; `t` rides the standard locale seat.
1091
+ * @returns the select card while open; null while closed.
1092
+ */
1093
+ function PopupSelectView({ popup, t }) {
1094
+ const state = (0, react.useSyncExternalStore)((fn) => popup.state.subscribe(fn), () => popup.state.getSnapshot());
1095
+ const cardRef = (0, react.useRef)(null);
1096
+ const searchRef = (0, react.useRef)(null);
1097
+ const maxHeight = (0, _x1a0f3n9_dsh_client_ui_primitives.useAnchoredMaxHeight)(cardRef, MAX_HEIGHT, state);
1098
+ const active = state.open ? state.active : null;
1099
+ (0, react.useEffect)(() => {
1100
+ if (active === null) return;
1101
+ cardRef.current?.querySelector("[aria-selected=\"true\"]")?.scrollIntoView({ block: "nearest" });
1102
+ }, [active]);
1103
+ (0, react.useEffect)(() => {
1104
+ if (!state.open || state.confirming !== null) return;
1105
+ const onPointerDown = (ev) => {
1106
+ if (cardRef.current !== null && ev.target instanceof Node && cardRef.current.contains(ev.target)) return;
1107
+ popup.dismiss();
1108
+ };
1109
+ document.addEventListener("pointerdown", onPointerDown, true);
1110
+ return () => {
1111
+ document.removeEventListener("pointerdown", onPointerDown, true);
1112
+ };
1113
+ }, [
1114
+ state.open,
1115
+ state.confirming,
1116
+ popup
1117
+ ]);
1118
+ (0, react.useEffect)(() => {
1119
+ if (state.open && state.confirming === null) searchRef.current?.focus();
1120
+ }, [state.open, state.confirming]);
1121
+ if (!state.open) return null;
1122
+ const rows = filterOptions(state.options, state.search);
1123
+ const confirmation = state.confirming?.confirmation;
1124
+ const onKeyDown = (ev) => {
1125
+ switch (ev.key) {
1126
+ case "ArrowDown":
1127
+ ev.preventDefault();
1128
+ popup.move(1);
1129
+ return;
1130
+ case "ArrowUp":
1131
+ ev.preventDefault();
1132
+ popup.move(-1);
1133
+ return;
1134
+ case "Enter":
1135
+ ev.preventDefault();
1136
+ popup.select(state.active);
1137
+ return;
1138
+ case "Escape":
1139
+ ev.preventDefault();
1140
+ popup.dismiss({ focusComposer: true });
1141
+ return;
1142
+ default:
1143
+ }
1144
+ };
1145
+ return (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [state.confirming === null && (0, react_jsx_runtime.jsxs)("div", {
1146
+ ref: cardRef,
1147
+ className: PopupSelectView_module_css_default.card,
1148
+ style: { maxHeight },
1149
+ "aria-label": t("overlay.aria", { command: String(state.command) }),
1150
+ onKeyDown,
1151
+ children: [
1152
+ (0, react_jsx_runtime.jsx)("input", {
1153
+ ref: searchRef,
1154
+ className: PopupSelectView_module_css_default.search,
1155
+ type: "text",
1156
+ placeholder: t("search.placeholder"),
1157
+ "aria-label": t("search.aria"),
1158
+ value: state.search,
1159
+ readOnly: state.submitting,
1160
+ onChange: (ev) => {
1161
+ popup.setSearch(ev.currentTarget.value);
1162
+ }
1163
+ }),
1164
+ state.error !== null && (0, react_jsx_runtime.jsxs)("div", {
1165
+ className: PopupSelectView_module_css_default.error,
1166
+ role: "alert",
1167
+ children: [(0, react_jsx_runtime.jsx)("span", {
1168
+ className: PopupSelectView_module_css_default.errorText,
1169
+ children: state.error
1170
+ }), state.status === "failed" && (0, react_jsx_runtime.jsx)("button", {
1171
+ type: "button",
1172
+ className: PopupSelectView_module_css_default.retry,
1173
+ onClick: () => {
1174
+ popup.retry();
1175
+ },
1176
+ children: t("retry")
1177
+ })]
1178
+ }),
1179
+ state.status === "pending" && (0, react_jsx_runtime.jsx)("div", {
1180
+ className: PopupSelectView_module_css_default.status,
1181
+ children: t("status.loading")
1182
+ }),
1183
+ state.submitting && (0, react_jsx_runtime.jsx)("div", {
1184
+ className: PopupSelectView_module_css_default.status,
1185
+ children: t("status.applying")
1186
+ }),
1187
+ state.status === "ready" && rows.length === 0 && (0, react_jsx_runtime.jsx)("div", {
1188
+ className: PopupSelectView_module_css_default.status,
1189
+ children: t("status.empty")
1190
+ }),
1191
+ state.status === "ready" && (0, react_jsx_runtime.jsx)("div", {
1192
+ role: "listbox",
1193
+ "aria-label": t("listbox.aria", { command: String(state.command) }),
1194
+ className: PopupSelectView_module_css_default.viewport,
1195
+ children: rows.map((option, index) => (0, react_jsx_runtime.jsxs)("div", {
1196
+ role: "option",
1197
+ "aria-selected": index === state.active,
1198
+ className: clsx(PopupSelectView_module_css_default.row, index === state.active && PopupSelectView_module_css_default.rowActive),
1199
+ onClick: () => {
1200
+ popup.select(index);
1201
+ },
1202
+ onMouseEnter: () => {
1203
+ popup.highlight(index);
1204
+ },
1205
+ children: [
1206
+ (0, react_jsx_runtime.jsx)("span", {
1207
+ className: PopupSelectView_module_css_default.label,
1208
+ children: option.label
1209
+ }),
1210
+ option.detail !== void 0 && (0, react_jsx_runtime.jsx)("span", {
1211
+ className: PopupSelectView_module_css_default.detail,
1212
+ children: option.detail
1213
+ }),
1214
+ option.active === true && (0, react_jsx_runtime.jsx)("span", {
1215
+ className: PopupSelectView_module_css_default.check,
1216
+ children: (0, react_jsx_runtime.jsx)(_x1a0f3n9_dsh_client_ui_primitives.IconCheckOutline16, {})
1217
+ })
1218
+ ]
1219
+ }, option.id))
1220
+ })
1221
+ ]
1222
+ }), confirmation !== void 0 && (0, react_jsx_runtime.jsx)(_x1a0f3n9_dsh_client_ui_primitives.RiskConfirmation, {
1223
+ open: true,
1224
+ title: confirmation.title,
1225
+ description: confirmation.description,
1226
+ acknowledgeLabel: confirmation.acknowledgeLabel,
1227
+ cancelLabel: confirmation.cancelLabel,
1228
+ closeLabel: t("close"),
1229
+ confirmLabel: confirmation.confirmLabel,
1230
+ acknowledged: state.acknowledged,
1231
+ onAcknowledgedChange: (value) => {
1232
+ popup.acknowledge(value);
1233
+ },
1234
+ onCancel: () => {
1235
+ popup.cancelConfirmation();
1236
+ },
1237
+ onConfirm: () => {
1238
+ popup.confirm();
1239
+ }
1240
+ })] });
1241
+ }
1242
+ //#endregion
1243
+ //#region lib/types/client/index.js
1244
+ /** Dictionary namespace owned by this plugin. */
1245
+ const NS = "command";
1246
+ /** Required services: the '/' source registry, session scopes, commands Remote, and locale registry. */
1247
+ const inject = [
1248
+ "inputTriggers",
1249
+ "sessions",
1250
+ "remote",
1251
+ "remote.commands",
1252
+ "locale"
1253
+ ];
1254
+ /**
1255
+ * Mount the command service and its per-session popupSelect overlay.
1256
+ * @param ctx - client root context.
1257
+ */
1258
+ function apply(ctx) {
1259
+ ctx.effect(() => ctx.locale.register(NS, {
1260
+ zh,
1261
+ en
1262
+ }), "ui-commands: dictionaries");
1263
+ ctx.plugin(CommandUiRuntime);
1264
+ ctx.inject([
1265
+ "slots",
1266
+ "commandUi",
1267
+ "sessions"
1268
+ ], (scope) => {
1269
+ const command = scope.commandUi;
1270
+ const sessions = scope.get("sessions");
1271
+ scope.slots.inject("conversation.input.overlay", () => scope.slots.register({
1272
+ name: "conversation.input.overlay",
1273
+ id: "command-popup",
1274
+ order: 1,
1275
+ locale: NS,
1276
+ inject: (sessionId) => {
1277
+ const actx = sessions.scope(sessionId);
1278
+ if (actx === void 0) throw new Error(`ui-commands: session "${String(sessionId)}" resolved no scope`);
1279
+ return { popup: command.popupFor(actx) };
1280
+ }
1281
+ }, PopupSelectView));
1282
+ });
1283
+ }
1284
+ //#endregion
1285
+ exports.CommandDirectory = CommandDirectory;
1286
+ exports.CommandUiRuntime = CommandUiRuntime;
1287
+ exports.PopupSelectController = PopupSelectController;
1288
+ exports.apply = apply;
1289
+ exports.filterOptions = filterOptions;
1290
+ exports.inject = inject;
1291
+ return module.exports;
1292
+ }
1293
+ });
1294
+
1295
+ //# sourceMappingURL=client.js.map