@klarkxy/dsh-git-commit 0.2.4 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -9,32 +9,65 @@ let react = require("react");
9
9
  let react_dom = require("react-dom");
10
10
  let _deepseek_ai_dsh_client_ui_primitives = require("@deepseek-ai/dsh-client-ui-primitives");
11
11
  let react_jsx_runtime = require("react/jsx-runtime");
12
- //#region ../dsh-model-route/src/route.ts
13
- const KEY_SEPARATOR = "";
14
- /** Stable key for one route, safe to use as a select-option value. */
15
- function modelRouteKey(provider, model) {
16
- return provider && model ? `${provider}${KEY_SEPARATOR}${model}` : "";
17
- }
18
- /** Inverse of modelRouteKey; undefined for malformed keys. */
19
- function parseModelRouteKey(value) {
20
- const index = value.indexOf(KEY_SEPARATOR);
21
- if (index <= 0) return void 0;
22
- const provider = value.slice(0, index);
23
- const model = value.slice(index + 1);
24
- if (!provider || !model) return void 0;
25
- return {
26
- provider,
27
- model
28
- };
29
- }
30
- //#endregion
31
12
  //#region ../dsh-model-route/src/catalog.ts
13
+ /** Both services are required: Cordis checks the nested namespace separately. */
14
+ const MODEL_CATALOG_INJECT = ["remote", "remote.session"];
32
15
  function asRecord(value) {
33
16
  return value && typeof value === "object" && !Array.isArray(value) ? value : void 0;
34
17
  }
35
18
  function asText(value) {
36
19
  return typeof value === "string" && value.trim() ? value.trim() : void 0;
37
20
  }
21
+ /** A useful diagnostic even when a transport rejects with a plain object. */
22
+ function modelCatalogErrorMessage(value) {
23
+ if (value instanceof Error && value.message) return value.message;
24
+ return asText(asRecord(value)?.message) ?? asText(value) ?? "Unable to load the model catalog.";
25
+ }
26
+ /**
27
+ * Validate and unwrap a successful catalog without discarding provider failures
28
+ * or newer host metadata. Names and metadata remain optional for older hosts.
29
+ * A broken response is never presented as a successfully loaded empty menu.
30
+ */
31
+ function checkedModelCatalog(value) {
32
+ const envelope = asRecord(value);
33
+ if (envelope?.ok === false) throw new Error(modelCatalogErrorMessage(envelope.error ?? envelope));
34
+ const root = envelope?.ok === true ? asRecord(envelope.value) : envelope;
35
+ const malformed = (field) => {
36
+ throw new Error(`Invalid model catalog: ${field}.`);
37
+ };
38
+ if (!root || !Array.isArray(root.groups)) return malformed("groups must be an array");
39
+ for (const value of root.groups) {
40
+ const group = asRecord(value);
41
+ if (!group || !asText(group.id) || !Array.isArray(group.models)) return malformed("each group needs an id and models array");
42
+ for (const value of group.models) {
43
+ const model = asRecord(value);
44
+ if (!model || !asText(model.id)) return malformed("each model needs an id");
45
+ if (model.reasoning !== void 0) {
46
+ const reasoning = asRecord(model.reasoning);
47
+ if (!reasoning || !Array.isArray(reasoning.efforts) || reasoning.efforts.some((value) => !parseEffort(value))) return malformed("reasoning efforts must be an array of identified efforts");
48
+ }
49
+ }
50
+ }
51
+ if (root.failures !== void 0 && (!Array.isArray(root.failures) || root.failures.some((value) => {
52
+ const failure = asRecord(value);
53
+ return !failure || !asText(failure.id) || typeof failure.name !== "string" || typeof failure.message !== "string";
54
+ }))) return malformed("failures must be an array of provider failures");
55
+ return root;
56
+ }
57
+ /** Read through the injected session namespace; absence and rejection are errors. */
58
+ async function readModelCatalog(client) {
59
+ const session = client.remote?.session;
60
+ if (typeof session?.modelCatalog !== "function") throw new Error("Model catalog unavailable: inject both remote and remote.session.");
61
+ return checkedModelCatalog(await session.modelCatalog());
62
+ }
63
+ /** Provider failures are partial results, not a transport failure. */
64
+ function modelCatalogFailures(value) {
65
+ try {
66
+ return checkedModelCatalog(value).failures ?? [];
67
+ } catch {
68
+ return [];
69
+ }
70
+ }
38
71
  function parseEffort(value) {
39
72
  const row = asRecord(value);
40
73
  const id = row ? asText(row.id) : void 0;
@@ -90,6 +123,850 @@ function modelMenuEffortOptions(choice, current) {
90
123
  });
91
124
  return listed;
92
125
  }
126
+ //#endregion
127
+ //#region ../dsh-model-route/src/menu.ts
128
+ /** Localize standard effort ids only when the provider has not supplied a display name. */
129
+ function modelMenuEffortLabel(effort, locale) {
130
+ if (effort.name !== effort.id) return effort.name;
131
+ return {
132
+ off: ["关闭", "Off"],
133
+ none: ["无", "None"],
134
+ minimal: ["最低", "Minimal"],
135
+ low: ["低", "Low"],
136
+ medium: ["中", "Medium"],
137
+ high: ["高", "High"],
138
+ xhigh: ["极高", "Extra high"],
139
+ max: ["最高", "Maximum"],
140
+ ultra: ["超高", "Ultra"]
141
+ }[effort.id]?.[locale?.toLowerCase().startsWith("zh") ? 0 : 1] ?? effort.name;
142
+ }
143
+ /** The short model label shown inside a provider group. */
144
+ function modelMenuShortName(choice) {
145
+ const sep = choice.label.indexOf(" / ");
146
+ return sep > 0 ? choice.label.slice(sep + 3) : choice.model;
147
+ }
148
+ /** Group catalog choices by provider and filter them by a case-insensitive query. */
149
+ function groupModelMenuChoices(choices, query) {
150
+ const needle = query.trim().toLowerCase();
151
+ const matched = needle ? choices.filter((choice) => choice.label.toLowerCase().includes(needle) || choice.model.toLowerCase().includes(needle) || choice.provider.toLowerCase().includes(needle)) : choices;
152
+ const groups = [];
153
+ for (const choice of matched) {
154
+ const sep = choice.label.indexOf(" / ");
155
+ const name = sep > 0 ? choice.label.slice(0, sep) : choice.provider;
156
+ const last = groups.at(-1);
157
+ if (last && last.provider === choice.provider) last.items.push(choice);
158
+ else groups.push({
159
+ provider: choice.provider,
160
+ name,
161
+ items: [choice]
162
+ });
163
+ }
164
+ return groups;
165
+ }
166
+ //#endregion
167
+ //#region ../dsh-model-route/src/catalog-ui.tsx
168
+ /**
169
+ * Latest refresh wins, including retries while a previous request is pending.
170
+ * Keep load stable (for example with useCallback); changing it invalidates the
171
+ * previous source and fetches again, so switching clients cannot publish stale data.
172
+ */
173
+ function useModelCatalog(load, options = {}) {
174
+ const enabled = options.enabled !== false;
175
+ const enabledRef = (0, react.useRef)(enabled);
176
+ enabledRef.current = enabled;
177
+ const live = (0, react.useRef)(false);
178
+ const source = (0, react.useRef)(load);
179
+ const revision = (0, react.useRef)(0);
180
+ const [state, setState] = (0, react.useState)(() => {
181
+ try {
182
+ return {
183
+ catalog: options.initialCatalog === void 0 ? void 0 : checkedModelCatalog(options.initialCatalog),
184
+ loading: enabled,
185
+ error: ""
186
+ };
187
+ } catch (error) {
188
+ return {
189
+ catalog: void 0,
190
+ loading: enabled,
191
+ error: modelCatalogErrorMessage(error)
192
+ };
193
+ }
194
+ });
195
+ const refresh = (0, react.useCallback)(async () => {
196
+ if (!live.current || !enabledRef.current) return;
197
+ const request = ++revision.current;
198
+ setState((previous) => ({
199
+ ...previous,
200
+ loading: true,
201
+ error: ""
202
+ }));
203
+ try {
204
+ const catalog = checkedModelCatalog(await load());
205
+ if (live.current && request === revision.current) setState({
206
+ catalog,
207
+ loading: false,
208
+ error: ""
209
+ });
210
+ } catch (error) {
211
+ if (live.current && request === revision.current) setState((previous) => ({
212
+ ...previous,
213
+ loading: false,
214
+ error: modelCatalogErrorMessage(error)
215
+ }));
216
+ }
217
+ }, [load, enabled]);
218
+ (0, react.useEffect)(() => {
219
+ live.current = true;
220
+ if (source.current !== load) {
221
+ source.current = load;
222
+ setState({
223
+ catalog: void 0,
224
+ loading: enabled,
225
+ error: ""
226
+ });
227
+ }
228
+ if (enabled) refresh();
229
+ else setState((previous) => ({
230
+ ...previous,
231
+ loading: false
232
+ }));
233
+ return () => {
234
+ live.current = false;
235
+ ++revision.current;
236
+ };
237
+ }, [enabled, refresh]);
238
+ (0, react.useEffect)(() => {
239
+ if (!enabled) return;
240
+ const reload = () => {
241
+ refresh();
242
+ };
243
+ if (typeof window !== "undefined") window.addEventListener("focus", reload);
244
+ let unsubscribe;
245
+ try {
246
+ unsubscribe = options.subscribe?.(reload);
247
+ } catch (error) {
248
+ setState((previous) => ({
249
+ ...previous,
250
+ error: modelCatalogErrorMessage(error)
251
+ }));
252
+ }
253
+ return () => {
254
+ if (typeof window !== "undefined") window.removeEventListener("focus", reload);
255
+ unsubscribe?.();
256
+ };
257
+ }, [
258
+ enabled,
259
+ refresh,
260
+ options.subscribe
261
+ ]);
262
+ const choices = (0, react.useMemo)(() => parseModelMenuChoices(state.catalog), [state.catalog]);
263
+ const failures = (0, react.useMemo)(() => state.catalog === void 0 ? [] : modelCatalogFailures(state.catalog), [state.catalog]);
264
+ return {
265
+ ...state,
266
+ choices,
267
+ failures,
268
+ refresh
269
+ };
270
+ }
271
+ /** Also accepts a mapped state from a caller that owns broader async orchestration. */
272
+ function ModelCatalogStatus({ state, locale }) {
273
+ const zh = locale?.toLowerCase().startsWith("zh") === true;
274
+ const copy = zh ? {
275
+ loading: "正在加载模型…",
276
+ error: "模型列表加载失败",
277
+ partial: "部分提供商的模型未能加载",
278
+ retry: "重试"
279
+ } : {
280
+ loading: "Loading models…",
281
+ error: "Unable to load models",
282
+ partial: "Some providers could not load their models",
283
+ retry: "Retry"
284
+ };
285
+ if (!state.loading && !state.error && state.failures.length === 0) return null;
286
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
287
+ className: "dsh-model-catalog-status",
288
+ lang: zh ? "zh" : "en",
289
+ children: [
290
+ state.loading ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
291
+ role: "status",
292
+ children: copy.loading
293
+ }) : null,
294
+ state.error ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
295
+ role: "alert",
296
+ children: [
297
+ copy.error,
298
+ ": ",
299
+ state.error
300
+ ]
301
+ }) : null,
302
+ state.failures.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
303
+ role: "status",
304
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { children: copy.partial }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", { children: state.failures.map((failure, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", { children: [
305
+ failure.name || failure.id,
306
+ ": ",
307
+ failure.message
308
+ ] }, `${failure.id}:${index}`)) })]
309
+ }) : null,
310
+ state.error || state.failures.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
311
+ type: "button",
312
+ size: "sm",
313
+ disabled: state.loading,
314
+ onClick: () => {
315
+ state.refresh();
316
+ },
317
+ children: copy.retry
318
+ }) : null
319
+ ]
320
+ });
321
+ }
322
+ //#endregion
323
+ //#region ../dsh-model-route/src/ui.tsx
324
+ /**
325
+ * ModelMenu: the standard editor of one model-route field — the visible half
326
+ * of the x-model-route contract. A feature plugin's settings page renders it
327
+ * for its own marked field; the model hub renders one per detected field.
328
+ *
329
+ * It is a plugin-side copy of the host's composer model picker
330
+ * (dsh-client-ui-model-selection's ModelSelect), rebuilt from the official
331
+ * primitives and the published bundle's behavior: one card that opens on the
332
+ * root pane (Model / Effort cells), drills into the provider-grouped,
333
+ * searchable model list or the effort list, and Escapes back before closing.
334
+ * Sticky group headings, subsequence ranking, highlight walking and focus
335
+ * hand-back all match the host. SYNC POINT: when the host's ModelSelect or
336
+ * ModelSelect.module.css changes, this copy must be re-transcribed; styles
337
+ * below may only reference --dsw-* theme tokens, never literal colors.
338
+ *
339
+ * It statically imports the primitives, so only browser clients should load
340
+ * this entry; host code and node tests use the core entry (`./index.ts`).
341
+ */
342
+ /** Unplaced portal card: hidden but laid out at a fixed origin so measurements are real. */
343
+ const MEASURE_STYLE = {
344
+ visibility: "hidden",
345
+ left: 0,
346
+ top: 0
347
+ };
348
+ function ModelMenu(props) {
349
+ const language = props.locale?.toLowerCase().startsWith("zh") ? "zh" : "en";
350
+ const copy = language === "zh" ? {
351
+ model: "模型",
352
+ effort: "思考强度",
353
+ default: "默认",
354
+ search: "搜索模型…",
355
+ empty: "没有匹配的模型",
356
+ clear: "清除搜索"
357
+ } : {
358
+ model: "Model",
359
+ effort: "Reasoning",
360
+ default: "Default",
361
+ search: "Search models…",
362
+ empty: "No matching models",
363
+ clear: "Clear search"
364
+ };
365
+ const { open, onOpenChange, anchor, choices, selected, onPick, leading = [], onPickLeading, efforts = [], selectedEffort, onPickEffort, modelLabel = copy.model, modelValue = "—", effortLabel = copy.effort, effortValue, defaultEffortLabel = copy.default, searchPlaceholder = copy.search, emptyLabel = copy.empty, clearSearchLabel = copy.clear, className, listClassName } = props;
366
+ const rootRef = (0, react.useRef)(null);
367
+ const openRef = (0, react.useRef)(open);
368
+ openRef.current = open;
369
+ const pendingOpenRef = (0, react.useRef)(false);
370
+ const pendingRestoreFocus = (0, react.useRef)(false);
371
+ const [requestedOpen, setRequestedOpen] = (0, react.useState)(false);
372
+ const menuRef = (0, react.useRef)(null);
373
+ const groupsRef = (0, react.useRef)(null);
374
+ const searchRef = (0, react.useRef)(null);
375
+ const itemRefs = (0, react.useRef)([]);
376
+ const paneFocus = (0, react.useRef)(null);
377
+ const [pane, setPane] = (0, react.useState)("root");
378
+ const [query, setQuery] = (0, react.useState)("");
379
+ const [highlightedIndex, setHighlightedIndex] = (0, react.useState)(null);
380
+ const [menuPos, setMenuPos] = (0, react.useState)(null);
381
+ const id = (0, react.useId)();
382
+ const showSearch = choices.length > 4;
383
+ const effortRows = (0, react.useMemo)(() => [{
384
+ id: "",
385
+ name: defaultEffortLabel
386
+ }, ...efforts.map((effort) => ({
387
+ ...effort,
388
+ name: modelMenuEffortLabel(effort, language)
389
+ }))], [
390
+ defaultEffortLabel,
391
+ efforts,
392
+ language
393
+ ]);
394
+ const filteredGroups = (0, react.useMemo)(() => groupModelMenuChoices(choices, "").map((group) => ({
395
+ ...group,
396
+ items: (0, _deepseek_ai_dsh_client_ui_primitives.rankByName)(group.items.map((choice) => ({
397
+ choice,
398
+ name: modelMenuShortName(choice),
399
+ label: choice.model
400
+ })), showSearch ? query.trim() : "")
401
+ })).filter((group) => group.items.length > 0), [
402
+ choices,
403
+ query,
404
+ showSearch
405
+ ]);
406
+ const visibleModels = (0, react.useMemo)(() => filteredGroups.flatMap((group) => group.items.map((item) => item.choice)), [filteredGroups]);
407
+ const currentVisibleIndex = visibleModels.findIndex((choice) => choice.provider === selected?.provider && choice.model === selected.model);
408
+ const activeModelIndex = Math.min(highlightedIndex ?? Math.max(0, currentVisibleIndex), visibleModels.length - 1);
409
+ const changeOpen = (next) => {
410
+ if (openRef.current === next) return;
411
+ openRef.current = next;
412
+ onOpenChange(next);
413
+ };
414
+ const requestOpen = () => {
415
+ if (open || pendingOpenRef.current) return;
416
+ pendingOpenRef.current = true;
417
+ pendingRestoreFocus.current = false;
418
+ setRequestedOpen(true);
419
+ };
420
+ const close = (restoreFocus = false) => {
421
+ const active = document.activeElement;
422
+ pendingRestoreFocus.current = restoreFocus && (active === document.body || rootRef.current?.contains(active) === true || menuRef.current?.contains(active) === true);
423
+ changeOpen(false);
424
+ };
425
+ const drill = (next) => {
426
+ setQuery("");
427
+ setHighlightedIndex(null);
428
+ paneFocus.current = "drill";
429
+ setPane(next);
430
+ };
431
+ /** Leave a drilled pane for the root one, handing the keyboard back to its cell. */
432
+ const back = (from) => {
433
+ paneFocus.current = from;
434
+ setPane("root");
435
+ };
436
+ const moveFocus = (offset) => {
437
+ const items = itemRefs.current.filter((item) => item !== null && !item.disabled);
438
+ if (!items.length) return;
439
+ const active = items.findIndex((item) => item === document.activeElement);
440
+ items[active === -1 ? offset > 0 ? 0 : items.length - 1 : (active + offset + items.length) % items.length]?.focus();
441
+ };
442
+ (0, react.useLayoutEffect)(() => {
443
+ if (open || !pendingRestoreFocus.current) return;
444
+ const active = document.activeElement;
445
+ if (active !== document.body && !rootRef.current?.contains(active)) {
446
+ pendingRestoreFocus.current = false;
447
+ return;
448
+ }
449
+ const trigger = rootRef.current?.querySelector("button");
450
+ if (!trigger || trigger.disabled) return;
451
+ trigger.focus();
452
+ if (document.activeElement === trigger) pendingRestoreFocus.current = false;
453
+ });
454
+ (0, react.useEffect)(() => {
455
+ if (open || !pendingRestoreFocus.current) return;
456
+ const cancelOutside = (event) => {
457
+ if (event.target instanceof Node && !rootRef.current?.contains(event.target)) pendingRestoreFocus.current = false;
458
+ };
459
+ document.addEventListener("focusin", cancelOutside);
460
+ document.addEventListener("pointerdown", cancelOutside);
461
+ return () => {
462
+ document.removeEventListener("focusin", cancelOutside);
463
+ document.removeEventListener("pointerdown", cancelOutside);
464
+ };
465
+ });
466
+ (0, react.useEffect)(() => {
467
+ if (!requestedOpen) return;
468
+ pendingOpenRef.current = false;
469
+ setRequestedOpen(false);
470
+ if (!open) changeOpen(true);
471
+ }, [requestedOpen, open]);
472
+ (0, react.useEffect)(() => {
473
+ if (!open) return;
474
+ setPane("root");
475
+ setQuery("");
476
+ setHighlightedIndex(null);
477
+ }, [open]);
478
+ (0, react.useEffect)(() => {
479
+ if (!open) return;
480
+ const closeOutside = (event) => {
481
+ const target = event.target;
482
+ if (rootRef.current?.contains(target) || menuRef.current?.contains(target)) return;
483
+ changeOpen(false);
484
+ };
485
+ document.addEventListener("mousedown", closeOutside);
486
+ return () => document.removeEventListener("mousedown", closeOutside);
487
+ }, [open, onOpenChange]);
488
+ (0, react.useEffect)(() => {
489
+ const intent = paneFocus.current;
490
+ if (!open || menuPos === null || intent === null) return;
491
+ if (intent !== "drill" && pane !== "root") return;
492
+ paneFocus.current = null;
493
+ if (intent === "drill") {
494
+ if (pane === "model" && showSearch) {
495
+ searchRef.current?.focus();
496
+ return;
497
+ }
498
+ (menuRef.current?.querySelector("[role=\"menuitemradio\"][aria-checked=\"true\"]:not([disabled])") ?? itemRefs.current.find((item) => item !== null && !item.disabled))?.focus();
499
+ return;
500
+ }
501
+ itemRefs.current[intent === "effort" ? 1 : 0]?.focus();
502
+ }, [
503
+ open,
504
+ pane,
505
+ showSearch,
506
+ menuPos
507
+ ]);
508
+ (0, react.useEffect)(() => {
509
+ const viewport = groupsRef.current;
510
+ if (viewport === null) return;
511
+ return (0, _deepseek_ai_dsh_client_ui_primitives.observeStickyMenuGroups)(viewport);
512
+ }, [
513
+ open,
514
+ pane,
515
+ filteredGroups
516
+ ]);
517
+ (0, react.useLayoutEffect)(() => {
518
+ if (open && pane === "model" && activeModelIndex >= 0) itemRefs.current[leading.length + activeModelIndex]?.scrollIntoView({ block: "nearest" });
519
+ }, [
520
+ open,
521
+ pane,
522
+ activeModelIndex,
523
+ visibleModels,
524
+ leading.length
525
+ ]);
526
+ (0, react.useLayoutEffect)(() => {
527
+ if (!open) {
528
+ setMenuPos(null);
529
+ return;
530
+ }
531
+ const place = () => {
532
+ const rect = rootRef.current?.getBoundingClientRect();
533
+ if (!rect) return;
534
+ const margin = 12, lw = menuRef.current?.offsetWidth ?? 0, lh = menuRef.current?.offsetHeight ?? 0;
535
+ let x = rect.right - lw, y = rect.top - 8 - lh;
536
+ if (lw > 0) x = Math.min(Math.max(x, margin), window.innerWidth - lw - margin);
537
+ if (lh > 0) y = Math.min(Math.max(y, margin), window.innerHeight - lh - margin);
538
+ setMenuPos({
539
+ left: x,
540
+ top: y
541
+ });
542
+ };
543
+ place();
544
+ window.addEventListener("scroll", place, true);
545
+ window.addEventListener("resize", place);
546
+ return () => {
547
+ window.removeEventListener("scroll", place, true);
548
+ window.removeEventListener("resize", place);
549
+ };
550
+ }, [
551
+ open,
552
+ pane,
553
+ choices,
554
+ query
555
+ ]);
556
+ function onKeyDown(event) {
557
+ if (event.defaultPrevented || event.nativeEvent.isComposing) return;
558
+ if (!open && [
559
+ "Enter",
560
+ " ",
561
+ "ArrowDown",
562
+ "ArrowUp"
563
+ ].includes(event.key)) {
564
+ const button = event.target instanceof Element ? event.target.closest("button") : null;
565
+ if (!(button instanceof HTMLButtonElement) || button.disabled || !rootRef.current?.contains(button)) return;
566
+ event.preventDefault();
567
+ paneFocus.current = "model";
568
+ requestOpen();
569
+ return;
570
+ }
571
+ if (event.key === "Escape" && open) {
572
+ event.preventDefault();
573
+ if (pane !== "root") back(pane);
574
+ else close(true);
575
+ return;
576
+ }
577
+ if (!open) return;
578
+ if (pane === "model" && showSearch && (event.key === "ArrowDown" || event.key === "ArrowUp")) {
579
+ event.preventDefault();
580
+ if (visibleModels.length > 0) {
581
+ setHighlightedIndex((activeModelIndex + (event.key === "ArrowDown" ? 1 : -1) + visibleModels.length) % visibleModels.length);
582
+ searchRef.current?.focus();
583
+ }
584
+ return;
585
+ }
586
+ if (pane === "model" && showSearch && event.target instanceof HTMLInputElement && (event.key === "Enter" || event.key === "Tab" && !event.shiftKey)) {
587
+ if (event.key === "Tab" && visibleModels.length === 0) return;
588
+ event.preventDefault();
589
+ const highlighted = visibleModels[activeModelIndex];
590
+ if (highlighted) {
591
+ onPick({
592
+ provider: highlighted.provider,
593
+ model: highlighted.model
594
+ });
595
+ close(true);
596
+ }
597
+ return;
598
+ }
599
+ if (event.key === "Tab") {
600
+ if (event.shiftKey) {
601
+ event.preventDefault();
602
+ if (pane !== "root") back(pane);
603
+ else close(true);
604
+ return;
605
+ }
606
+ const focused = document.activeElement;
607
+ const rows = itemRefs.current.filter((item) => item !== null);
608
+ if (focused instanceof HTMLButtonElement && rows.includes(focused)) {
609
+ event.preventDefault();
610
+ focused.click();
611
+ return;
612
+ }
613
+ if (pane === "model" && showSearch) {
614
+ event.preventDefault();
615
+ setHighlightedIndex(null);
616
+ searchRef.current?.focus();
617
+ }
618
+ return;
619
+ }
620
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
621
+ event.preventDefault();
622
+ moveFocus(event.key === "ArrowDown" ? 1 : -1);
623
+ }
624
+ }
625
+ function onBlur(event) {
626
+ if (event.relatedTarget instanceof Node && (rootRef.current?.contains(event.relatedTarget) || menuRef.current?.contains(event.relatedTarget))) return;
627
+ if (open) changeOpen(false);
628
+ }
629
+ itemRefs.current = [];
630
+ let itemIndex = 0;
631
+ let modelIndex = 0;
632
+ const itemRef = () => {
633
+ const at = itemIndex++;
634
+ return (node) => {
635
+ itemRefs.current[at] = node;
636
+ };
637
+ };
638
+ const menu = open && typeof document !== "undefined" ? (0, react_dom.createPortal)(/* @__PURE__ */ (0, react_jsx_runtime.jsxs)(_deepseek_ai_dsh_client_ui_primitives.MenuSurface, {
639
+ ref: menuRef,
640
+ id: `${id}-menu`,
641
+ className: `dsh-model-menu-menu${listClassName ? ` ${listClassName}` : ""}`,
642
+ style: menuPos ?? MEASURE_STYLE,
643
+ role: pane === "model" ? "group" : "menu",
644
+ "aria-label": modelLabel,
645
+ lang: language,
646
+ children: [
647
+ pane === "root" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
648
+ ref: itemRef(),
649
+ type: "button",
650
+ role: "menuitem",
651
+ className: "dsh-model-menu-cell",
652
+ onClick: () => drill("model"),
653
+ children: [
654
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
655
+ className: "dsh-model-menu-cellLabel",
656
+ children: modelLabel
657
+ }),
658
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
659
+ className: "dsh-model-menu-cellValue",
660
+ children: modelValue
661
+ }),
662
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconChevronRightOutlineRegular, { className: "dsh-model-menu-cellChevron" })
663
+ ]
664
+ }), efforts.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
665
+ ref: itemRef(),
666
+ type: "button",
667
+ role: "menuitem",
668
+ className: "dsh-model-menu-cell",
669
+ onClick: () => drill("effort"),
670
+ children: [
671
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
672
+ className: "dsh-model-menu-cellLabel",
673
+ children: effortLabel
674
+ }),
675
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
676
+ className: "dsh-model-menu-cellValue",
677
+ children: effortValue ?? effortRows.find((row) => row.id === (selectedEffort ?? ""))?.name ?? defaultEffortLabel
678
+ }),
679
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconChevronRightOutlineRegular, { className: "dsh-model-menu-cellChevron" })
680
+ ]
681
+ }) : null] }) : null,
682
+ pane === "model" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
683
+ showSearch ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
684
+ className: "dsh-model-menu-searchRow",
685
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Input, {
686
+ ref: searchRef,
687
+ className: `dsh-model-menu-search${query !== "" ? " dsh-model-menu-searchWithQuery" : ""}`,
688
+ type: "text",
689
+ role: "searchbox",
690
+ "aria-label": searchPlaceholder,
691
+ "aria-controls": `${id}-models`,
692
+ "aria-activedescendant": activeModelIndex < 0 ? void 0 : `${id}-model-${activeModelIndex}`,
693
+ placeholder: searchPlaceholder,
694
+ value: query,
695
+ onChange: (event) => {
696
+ setQuery(event.target.value);
697
+ setHighlightedIndex(0);
698
+ }
699
+ }), query !== "" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
700
+ type: "button",
701
+ className: "dsh-model-menu-searchClear",
702
+ "aria-label": clearSearchLabel,
703
+ onClick: () => {
704
+ setQuery("");
705
+ setHighlightedIndex(null);
706
+ searchRef.current?.focus();
707
+ },
708
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconCloseFillRegular, {})
709
+ }) : null]
710
+ }) : null,
711
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
712
+ ref: groupsRef,
713
+ id: `${id}-models`,
714
+ className: "dsh-model-menu-groups scrollable",
715
+ role: "menu",
716
+ "aria-label": modelLabel,
717
+ hidden: filteredGroups.length === 0,
718
+ children: [leading.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
719
+ className: "dsh-model-menu-leading",
720
+ children: leading.map((item) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
721
+ ref: itemRef(),
722
+ type: "button",
723
+ role: "menuitemradio",
724
+ "aria-checked": item.selected === true,
725
+ className: `dsh-model-menu-option${item.selected === true ? " dsh-model-menu-selected" : ""}`,
726
+ onClick: () => {
727
+ onPickLeading?.(item.id);
728
+ close(true);
729
+ },
730
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
731
+ className: "dsh-model-menu-optionCopy",
732
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
733
+ className: "dsh-model-menu-modelName",
734
+ children: item.label
735
+ })
736
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
737
+ className: "dsh-model-menu-check",
738
+ children: item.selected === true ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconCheckOutlineRegular, {}) : null
739
+ })]
740
+ }, item.id))
741
+ }) : null, filteredGroups.map((group) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.MenuGroup, {
742
+ label: group.name,
743
+ children: group.items.map((item) => {
744
+ const index = modelIndex++;
745
+ const choice = item.choice;
746
+ const checked = selected?.provider === choice.provider && selected.model === choice.model;
747
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
748
+ ref: itemRef(),
749
+ type: "button",
750
+ role: "menuitemradio",
751
+ "aria-checked": checked,
752
+ id: `${id}-model-${index}`,
753
+ tabIndex: showSearch ? -1 : 0,
754
+ onFocus: () => setHighlightedIndex(index),
755
+ "data-highlighted": index === activeModelIndex ? "" : void 0,
756
+ className: `dsh-model-menu-option dsh-model-menu-modelOption${checked ? " dsh-model-menu-selected" : ""}${index === activeModelIndex ? " dsh-model-menu-optionActive" : ""}`,
757
+ onMouseMove: index === activeModelIndex ? void 0 : () => {
758
+ if (showSearch) setHighlightedIndex(index);
759
+ else itemRefs.current[leading.length + index]?.focus();
760
+ },
761
+ title: choice.label,
762
+ onClick: () => {
763
+ onPick({
764
+ provider: choice.provider,
765
+ model: choice.model
766
+ });
767
+ close(true);
768
+ },
769
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
770
+ className: "dsh-model-menu-optionCopy",
771
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
772
+ className: "dsh-model-menu-modelName",
773
+ children: modelMenuShortName(choice)
774
+ })
775
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
776
+ className: "dsh-model-menu-check",
777
+ children: checked ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconCheckOutlineRegular, {}) : null
778
+ })]
779
+ }, `${choice.provider}/${choice.model}`);
780
+ })
781
+ }, group.provider))]
782
+ }),
783
+ filteredGroups.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
784
+ className: "dsh-model-menu-empty",
785
+ role: "status",
786
+ children: emptyLabel
787
+ }) : null
788
+ ] }) : null,
789
+ pane === "effort" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(react_jsx_runtime.Fragment, { children: effortRows.map((row) => {
790
+ const checked = (selectedEffort ?? "") === row.id;
791
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
792
+ ref: itemRef(),
793
+ type: "button",
794
+ role: "menuitemradio",
795
+ "aria-checked": checked,
796
+ className: `dsh-model-menu-option${checked ? " dsh-model-menu-selected" : ""}`,
797
+ onClick: () => {
798
+ onPickEffort?.(row.id || void 0);
799
+ close(true);
800
+ },
801
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
802
+ className: "dsh-model-menu-optionCopy",
803
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
804
+ className: "dsh-model-menu-modelName",
805
+ children: row.name
806
+ })
807
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
808
+ className: "dsh-model-menu-check",
809
+ children: checked ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconCheckOutlineRegular, {}) : null
810
+ })]
811
+ }, row.id || "__default");
812
+ }) }) : null
813
+ ]
814
+ }), document.body) : null;
815
+ const trigger = (0, react.isValidElement)(anchor) ? (0, react.cloneElement)(anchor, {
816
+ "aria-haspopup": "menu",
817
+ "aria-expanded": open,
818
+ "aria-controls": open ? `${id}-menu` : void 0
819
+ }) : anchor;
820
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
821
+ ref: rootRef,
822
+ className: `dsh-model-menu-root${className ? ` ${className}` : ""}`,
823
+ onKeyDown,
824
+ onBlur,
825
+ onClick: (event) => {
826
+ if (event.defaultPrevented) return;
827
+ const button = event.target instanceof Element ? event.target.closest("button") : null;
828
+ if (!(button instanceof HTMLButtonElement) || button.disabled || !rootRef.current?.contains(button)) return;
829
+ requestOpen();
830
+ },
831
+ onMouseDown: (event) => {
832
+ if (event.target instanceof Element && menuRef.current?.contains(event.target) && event.target.closest("button") !== null) event.preventDefault();
833
+ },
834
+ children: [trigger, menu]
835
+ });
836
+ }
837
+ /**
838
+ * ModelMenu styles, transcribed from the official ModelSelect.module.css (the
839
+ * published dsh-client-ui-model-selection bundle): card capped at
840
+ * min(360px, 100vh - 96px), 34px option cells at 13/18 with 5px 7px padding,
841
+ * 34px root cells at 13/20, a borderless 12px search row with a clear button,
842
+ * sticky group headings (MenuGroup's own CSS), and the l1-stroke prominent
843
+ * elevation at portal z-index 1100. The trigger mirrors the composer's 28px
844
+ * model pill, chevron rotating open. MenuSurface paints fill and backdrop.
845
+ * Only --dsw-* theme tokens may appear here — a literal color is a bug.
846
+ */
847
+ const modelMenuCss = `
848
+ .dsh-model-menu-root { min-width: 0; position: relative; display: inline-flex; }
849
+ .dsh-model-menu-trigger {
850
+ border-radius: var(--dsw-radius-sm);
851
+ min-width: 0;
852
+ max-width: min(360px, 45cqw);
853
+ height: 28px;
854
+ color: var(--dsw-alias-label-secondary);
855
+ cursor: pointer;
856
+ background: none;
857
+ border: none;
858
+ outline: none;
859
+ align-items: center;
860
+ gap: 4px;
861
+ padding: 0 4px 0 8px;
862
+ font-size: 13px;
863
+ font-weight: 400;
864
+ line-height: 20px;
865
+ display: flex;
866
+ }
867
+ .dsh-model-menu-trigger:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover); }
868
+ .dsh-model-menu-trigger:focus-visible:not([data-selection-focus]) { box-shadow: 0 0 0 2px var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); }
869
+ .dsh-model-menu-trigger:disabled { color: var(--dsw-alias-label-dimmed); cursor: default; }
870
+ .dsh-model-menu-triggerLabel { text-overflow: ellipsis; white-space: nowrap; min-width: 0; overflow: hidden; }
871
+ .dsh-model-menu-triggerEffort { text-overflow: ellipsis; white-space: nowrap; min-width: 0; color: var(--dsw-alias-label-caption); flex-shrink: 1000; overflow: hidden; }
872
+ .dsh-model-menu-chevron { color: var(--dsw-alias-label-caption); flex: none; transition: transform 0.12s; }
873
+ .dsh-model-menu-chevronOpen { transform: rotate(180deg); }
874
+ .dsh-model-menu-menu {
875
+ z-index: 1100;
876
+ --dsw-elevation-stroke-color: var(--dsw-alias-border-l1);
877
+ width: max-content;
878
+ min-width: min(240px, 100vw - 32px);
879
+ max-width: min(420px, 100vw - 32px);
880
+ max-height: min(360px, 100vh - 96px);
881
+ box-shadow: var(--dsw-elevation-prominent);
882
+ color: var(--dsw-alias-label-primary);
883
+ --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
884
+ --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
885
+ border: 0;
886
+ flex-direction: column;
887
+ padding: 4px;
888
+ display: flex;
889
+ position: fixed;
890
+ overflow: hidden;
891
+ }
892
+ .dsh-model-menu-status, .dsh-model-menu-empty { color: var(--dsw-alias-label-tertiary); padding: 8px; font-size: 12px; line-height: 18px; }
893
+ .dsh-model-menu-searchRow { flex-shrink: 0; margin: 2px 0 3px; position: relative; }
894
+ .dsh-model-menu-searchRow .dsh-model-menu-search { border-radius: var(--dsw-radius-md); background: none; border: 0 solid transparent; height: auto; padding: 5px 7px; display: flex; }
895
+ .dsh-model-menu-searchRow .dsh-model-menu-search:focus-within { border-color: transparent; }
896
+ .dsh-model-menu-searchWithQuery { padding-right: 34px; }
897
+ .dsh-model-menu-searchRow .dsh-model-menu-search input { padding: 0; font-size: 12px; line-height: normal; }
898
+ .dsh-model-menu-searchRow .dsh-model-menu-search input::placeholder { color: var(--dsw-alias-label-caption); }
899
+ .dsh-model-menu-searchClear {
900
+ width: 24px;
901
+ height: 24px;
902
+ color: var(--dsw-alias-label-secondary);
903
+ cursor: pointer;
904
+ background: none;
905
+ border: none;
906
+ border-radius: 50%;
907
+ justify-content: center;
908
+ align-items: center;
909
+ padding: 0;
910
+ display: inline-flex;
911
+ position: absolute;
912
+ top: 50%;
913
+ right: 4px;
914
+ transform: translateY(-50%);
915
+ }
916
+ .dsh-model-menu-searchClear:hover, .dsh-model-menu-searchClear:focus-visible { background: var(--dsw-alias-interactive-bg-hover); outline: none; }
917
+ .dsh-model-menu-groups { min-height: 0; overflow-y: auto; }
918
+ .dsh-model-menu-leading { border-bottom: 0.5px solid var(--dsw-alias-border-l2); margin-bottom: 3px; padding-bottom: 3px; }
919
+ .dsh-model-menu-option {
920
+ box-sizing: border-box;
921
+ border-radius: var(--dsw-radius-md);
922
+ width: auto;
923
+ min-width: 100%;
924
+ min-height: 34px;
925
+ color: inherit;
926
+ text-align: left;
927
+ cursor: pointer;
928
+ background: none;
929
+ border: none;
930
+ outline: none;
931
+ align-items: center;
932
+ gap: 6px;
933
+ padding: 5px 7px;
934
+ display: flex;
935
+ }
936
+ .dsh-model-menu-option:not(.dsh-model-menu-modelOption):hover:not(:disabled),
937
+ .dsh-model-menu-option:focus-visible,
938
+ .dsh-model-menu-optionActive:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover); }
939
+ .dsh-model-menu-modelOption:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover); }
940
+ .dsh-model-menu-selected { background: none; }
941
+ .dsh-model-menu-option:disabled { color: var(--dsw-alias-label-dimmed); cursor: default; }
942
+ .dsh-model-menu-optionCopy { flex-direction: column; flex: 1; min-width: 0; display: flex; }
943
+ .dsh-model-menu-modelName { color: inherit; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; font-weight: 400; line-height: 18px; overflow: hidden; }
944
+ .dsh-model-menu-check { color: var(--dsw-alias-label-primary); flex: 0 0 14px; place-items: center; display: grid; }
945
+ .dsh-model-menu-check svg { width: 14px; height: 14px; }
946
+ .dsh-model-menu-cell {
947
+ box-sizing: border-box;
948
+ border-radius: var(--dsw-radius-md);
949
+ width: auto;
950
+ min-width: 100%;
951
+ height: 34px;
952
+ color: var(--dsw-alias-label-primary);
953
+ cursor: pointer;
954
+ text-align: left;
955
+ background: none;
956
+ border: none;
957
+ outline: none;
958
+ align-items: center;
959
+ gap: 6px;
960
+ padding: 0 8px;
961
+ font-size: 13px;
962
+ line-height: 20px;
963
+ display: flex;
964
+ }
965
+ .dsh-model-menu-cell:hover, .dsh-model-menu-cell:focus-visible { background: var(--dsw-alias-interactive-bg-hover); }
966
+ .dsh-model-menu-cellLabel { white-space: nowrap; flex: none; }
967
+ .dsh-model-menu-cellValue { text-overflow: ellipsis; white-space: nowrap; text-align: right; min-width: 0; color: var(--dsw-alias-label-tertiary); flex: auto; overflow: hidden; }
968
+ .dsh-model-menu-cellChevron { width: 12px; height: 12px; color: var(--dsw-alias-menu-icon); flex: none; }
969
+ `;
93
970
  Object.values({
94
971
  /** Surfaces, from the page up to the topmost nested card. */
95
972
  surface: [
@@ -452,6 +1329,29 @@ ${scope} {
452
1329
  & .dsh-ui-select:disabled { color: var(--dsw-alias-label-tertiary); cursor: default; }
453
1330
  & .dsh-ui-select[aria-invalid='true'] { border-color: var(--dsw-alias-state-error-primary); }
454
1331
 
1332
+ /* --- Native textarea. The primitives ship no textarea either, so a settings
1333
+ * prompt keeps the platform element and the same field shell as the select:
1334
+ * 0.5px l4, radius-md, layer-3, 13/1.5 type. It grows vertically instead of
1335
+ * locking to the select's 34px row. --- */
1336
+ & .dsh-ui-textarea {
1337
+ box-sizing: border-box;
1338
+ width: 100%;
1339
+ min-width: 0;
1340
+ resize: vertical;
1341
+ padding: 8px 12px;
1342
+ border: 0.5px solid var(--dsw-alias-border-l4);
1343
+ border-radius: var(--dsw-radius-md);
1344
+ background: var(--dsw-alias-bg-layer-3);
1345
+ font: inherit;
1346
+ font-size: 13px;
1347
+ line-height: 1.5;
1348
+ color: var(--dsw-alias-label-primary);
1349
+ }
1350
+ & .dsh-ui-textarea:focus-visible { outline: none; border-color: var(--dsw-alias-state-business-primary); }
1351
+ & .dsh-ui-textarea:disabled { color: var(--dsw-alias-label-tertiary); cursor: default; }
1352
+ & .dsh-ui-textarea::placeholder { color: var(--dsw-alias-label-dimmed); }
1353
+ & .dsh-ui-textarea[aria-invalid='true'] { border-color: var(--dsw-alias-state-error-primary); }
1354
+
455
1355
  /* Long panels scroll inside themselves instead of growing the host page. */
456
1356
  & .dsh-ui-panel { display: flex; flex-direction: column; gap: 16px; min-width: 0; }
457
1357
  & .dsh-ui-panel-wide { max-width: 100%; }
@@ -464,25 +1364,18 @@ ${scope} {
464
1364
  }
465
1365
  //#endregion
466
1366
  //#region ../dsh-plugin-kit/src/contracts.ts
467
- /**
468
- * Remote contribution for model-settings change notifications.
469
- * Provided by `@klarkxy/dsh-model-hub` (`ModelSettingsChangesController`);
470
- * subscribers degrade silently when that package is not installed.
471
- * The stream carries invalidations only — `ready` is never a settings snapshot.
472
- */
473
- const MODEL_SETTINGS_CHANGES_REMOTE = {
474
- package: "@klarkxy/dsh-model-hub",
475
- descriptors: [{
476
- id: "modelSettingsChanges/watch",
477
- service: "modelSettingsChangesController",
478
- namespace: "modelSettingsChanges",
479
- method: "watch",
480
- mode: "stream",
481
- invocation: { kind: "direct" },
482
- parameters: [],
483
- cancellation: { parameter: "signal" }
484
- }]
485
- };
1367
+ /** `@klarkxy/dsh-git-commit` becomes `KlarkxyDshGitCommitModelSettingsChanges`. */
1368
+ function modelSettingsChangesIdentity(packageName) {
1369
+ const scoped = /^@([^/]+)\/(.+)$/.exec(packageName);
1370
+ const parts = (scoped ? `${scoped[1]}/${scoped[2]}` : packageName).split(/[^A-Za-z0-9]+/).filter(Boolean);
1371
+ if (!parts.length) throw new Error("Model settings package name has no RPC segment");
1372
+ const segment = `${parts.map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join("")}ModelSettingsChanges`;
1373
+ if (!/^[A-Z][A-Za-z0-9]*$/.test(segment)) throw new Error(`Model settings sync name is not an RPC segment: ${segment}`);
1374
+ return {
1375
+ service: segment,
1376
+ namespace: segment
1377
+ };
1378
+ }
486
1379
  //#endregion
487
1380
  //#region ../dsh-plugin-kit/src/model-settings-client.ts
488
1381
  /** This stream carries invalidations only; ready is never a settings snapshot. */
@@ -497,17 +1390,44 @@ function parseModelSettingsChangeFrame(value) {
497
1390
  };
498
1391
  throw new Error("Invalid model settings change frame");
499
1392
  }
500
- MODEL_SETTINGS_CHANGES_REMOTE.package, MODEL_SETTINGS_CHANGES_REMOTE.descriptors.map((descriptor) => ({
501
- ...descriptor,
502
- result: {
503
- mode: "strict",
504
- typeSymbol: "ModelSettings.ChangeFrame",
505
- create: () => ({ parse: parseModelSettingsChangeFrame })
506
- }
507
- }));
1393
+ /** One owner's contribution. Each package mounts only its own endpoint. */
1394
+ function modelSettingsChangesRemote(packageName, identity = modelSettingsChangesIdentity(packageName)) {
1395
+ return {
1396
+ package: packageName,
1397
+ descriptors: [{
1398
+ id: `${identity.namespace}/watch`,
1399
+ service: identity.service,
1400
+ namespace: identity.namespace,
1401
+ method: "watch",
1402
+ mode: "stream",
1403
+ invocation: { kind: "direct" },
1404
+ parameters: [],
1405
+ cancellation: { parameter: "signal" },
1406
+ result: {
1407
+ mode: "strict",
1408
+ typeSymbol: "ModelSettings.ChangeFrame",
1409
+ create: () => ({ parse: parseModelSettingsChangeFrame })
1410
+ }
1411
+ }]
1412
+ };
1413
+ }
1414
+ /** Mount from a synchronous apply. Disposal waits for an in-flight mount and
1415
+ * then withdraws it, so the contribution cannot outlive the plugin. */
1416
+ function mountModelSettingsChanges(ctx, packageName) {
1417
+ const contribution = modelSettingsChangesRemote(packageName);
1418
+ ctx.inject(["remote"], (injected) => {
1419
+ const scope = injected;
1420
+ const mounting = scope.remote.$mount(contribution);
1421
+ mounting.catch(() => {});
1422
+ scope.effect(() => async () => {
1423
+ await (await mounting.catch(() => void 0))?.();
1424
+ });
1425
+ });
1426
+ }
508
1427
  /** Native RemoteStream owns carrier reconnects; no timers or browser transport.
509
1428
  * A factory shares one logical stream while at least one subscriber remains. */
510
- function createModelSettingsSubscriber(ctx, namespace) {
1429
+ function createModelSettingsSubscriber(ctx, packageName, identity = modelSettingsChangesIdentity(packageName), accept = (ns) => ns === packageName) {
1430
+ const endpoint = identity;
511
1431
  const listeners = /* @__PURE__ */ new Set();
512
1432
  let injection;
513
1433
  let current;
@@ -533,12 +1453,18 @@ function createModelSettingsSubscriber(ctx, namespace) {
533
1453
  };
534
1454
  const start = () => {
535
1455
  const life = ++lifecycle;
536
- injection = ctx.inject(["remote", "remote.modelSettingsChanges"], (injected) => {
1456
+ injection = ctx.inject(["remote", `remote.${endpoint.namespace}`], (injected) => {
537
1457
  if (life !== lifecycle || !listeners.size) return;
538
1458
  const scope = injected;
1459
+ const service = scope.remote[endpoint.namespace];
1460
+ if (!service || !("watch" in service) || !service.watch) {
1461
+ report("Model settings sync service unavailable");
1462
+ return;
1463
+ }
1464
+ const watch = service.watch;
539
1465
  const stream = scope.remote.$stream({
540
1466
  name: "Model settings sync",
541
- open: (signal) => scope.remote.modelSettingsChanges.watch(signal),
1467
+ open: (signal) => watch(signal),
542
1468
  ended: (accepted) => /* @__PURE__ */ new Error(`Model settings sync stream ended ${accepted ? "after" : "before"} ready; refresh or reopen the page`)
543
1469
  });
544
1470
  const session = {
@@ -573,7 +1499,7 @@ function createModelSettingsSubscriber(ctx, namespace) {
573
1499
  invalidate();
574
1500
  } else {
575
1501
  if (!ready) throw new Error("Model settings sync change arrived before ready");
576
- if (namespace === "*" || frame.ns === namespace) invalidate();
1502
+ if (accept(frame.ns)) invalidate();
577
1503
  }
578
1504
  }
579
1505
  } catch (error) {
@@ -629,8 +1555,7 @@ const name = "dsh-git-commit-client";
629
1555
  const inject = [
630
1556
  "slots",
631
1557
  "connection",
632
- "remote",
633
- "remote.session",
1558
+ ...MODEL_CATALOG_INJECT,
634
1559
  "locale"
635
1560
  ];
636
1561
  const NS = "dsh-git-commit.action";
@@ -1107,7 +2032,7 @@ function GitCommitAction({ sessionId, useSession, call, t }) {
1107
2032
  }), document.body)]
1108
2033
  });
1109
2034
  }
1110
- const clientCss = `${officialUiCss("gcm-root")}
2035
+ const clientCss = `${officialUiCss("gcm-root")}${modelMenuCss}
1111
2036
  /* Only the floating panel's geometry is this plugin's own: the material, the
1112
2037
  * rounded edge and the scrollbar tokens all come from the shared contract.
1113
2038
  *
@@ -1136,7 +2061,6 @@ const settingsZh = {
1136
2061
  pageTitle: "Git 提交",
1137
2062
  model: "提交规划模型",
1138
2063
  modelDefault: "默认模型",
1139
- modelEffort: "思考强度",
1140
2064
  modelHint: "留空则使用当前会话模型,再回落到宿主默认对话模型。",
1141
2065
  fallback: "模型不可用时仍然提交",
1142
2066
  fallbackHint: "默认关闭:模型规划失败会取消本次提交,工作区不变。开启后全部改动会合并成一个提交,由插件代写提交信息。",
@@ -1152,7 +2076,6 @@ const settingsEn = {
1152
2076
  pageTitle: "Git Commit",
1153
2077
  model: "Commit planning model",
1154
2078
  modelDefault: "Default model",
1155
- modelEffort: "Reasoning effort",
1156
2079
  modelHint: "Leave empty to use the current session model, then the host default chat model.",
1157
2080
  fallback: "Commit even without a model plan",
1158
2081
  fallbackHint: "Off by default: a failed plan cancels the run and leaves the workspace untouched. When on, every change lands in one commit with a plugin-written message.",
@@ -1171,8 +2094,12 @@ function settingsCopy(locale) {
1171
2094
  function GitCommitSettingsPanel(props) {
1172
2095
  const hostLocale = useHostLocale(props.client);
1173
2096
  const text = settingsCopy(props.locale ?? hostLocale);
2097
+ const language = String(props.locale ?? hostLocale).toLowerCase().startsWith("zh") ? "zh" : "en";
1174
2098
  const [settings, setSettings] = (0, react.useState)(void 0);
1175
- const [choices, setChoices] = (0, react.useState)([]);
2099
+ const catalogState = useModelCatalog((0, react.useCallback)(() => readModelCatalog(props.client), [props.client]), { subscribe: (0, react.useCallback)((change) => props.client.connection.generation?.subscribe(change), [props.client]) });
2100
+ const { refresh: refreshCatalog } = catalogState;
2101
+ const choices = parseModelMenuChoices(catalogState.catalog, settings?.model);
2102
+ const [menuOpen, setMenuOpen] = (0, react.useState)(false);
1176
2103
  const [error, setError] = (0, react.useState)("");
1177
2104
  const [note, setNote] = (0, react.useState)(false);
1178
2105
  const [busy, setBusy] = (0, react.useState)(false);
@@ -1235,6 +2162,7 @@ function GitCommitSettingsPanel(props) {
1235
2162
  const stop = props.subscribeSettings?.(() => {
1236
2163
  if (live) {
1237
2164
  setSyncError("");
2165
+ refreshCatalog();
1238
2166
  refreshRef.current();
1239
2167
  }
1240
2168
  }, (message) => {
@@ -1244,27 +2172,10 @@ function GitCommitSettingsPanel(props) {
1244
2172
  live = false;
1245
2173
  stop?.();
1246
2174
  };
1247
- }, [props.subscribeSettings, props.client]);
1248
- const boundProvider = settings?.model.provider ?? "";
1249
- const boundModel = settings?.model.model ?? "";
1250
- (0, react.useEffect)(() => {
1251
- let live = true;
1252
- const bound = boundProvider && boundModel ? {
1253
- provider: boundProvider,
1254
- model: boundModel
1255
- } : void 0;
1256
- props.client.remote?.session?.modelCatalog?.()?.then((value) => {
1257
- if (live) setChoices(parseModelMenuChoices(value, bound));
1258
- }).catch(() => {
1259
- if (live) setChoices([]);
1260
- });
1261
- return () => {
1262
- live = false;
1263
- };
1264
2175
  }, [
2176
+ props.subscribeSettings,
1265
2177
  props.client,
1266
- boundProvider,
1267
- boundModel
2178
+ refreshCatalog
1268
2179
  ]);
1269
2180
  const save = (0, react.useCallback)((patch) => {
1270
2181
  const state = operation.current;
@@ -1326,8 +2237,12 @@ function GitCommitSettingsPanel(props) {
1326
2237
  ]
1327
2238
  });
1328
2239
  const current = settings.model;
1329
- const selected = choices.find((item) => item.provider === current.provider && item.model === current.model);
1330
- const efforts = modelMenuEffortOptions(selected, current.reasoningEffort);
2240
+ const follow = !current.provider || !current.model;
2241
+ const selected = follow ? void 0 : choices.find((item) => item.provider === current.provider && item.model === current.model);
2242
+ const efforts = follow ? [] : modelMenuEffortOptions(selected, current.reasoningEffort);
2243
+ const currentLabel = follow ? text.modelDefault : selected?.label ?? `${current.provider} / ${current.model}`;
2244
+ const selectedEffort = efforts.find((item) => item.id === (current.reasoningEffort || ""));
2245
+ const effortName = selectedEffort ? modelMenuEffortLabel(selectedEffort, language) : void 0;
1331
2246
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
1332
2247
  className: "gcm-root gcm-settings",
1333
2248
  "data-testid": "git-commit-settings",
@@ -1359,57 +2274,72 @@ function GitCommitSettingsPanel(props) {
1359
2274
  })
1360
2275
  ]
1361
2276
  }),
1362
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
1363
- className: "dsh-ui-field",
1364
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1365
- className: "dsh-ui-label",
1366
- children: text.model
1367
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
1368
- className: "dsh-ui-select",
1369
- value: modelRouteKey(current.provider, current.model),
1370
- disabled: busy,
1371
- onChange: (event) => {
1372
- const key = event.target.value;
1373
- if (!key) {
1374
- save({ model: {
1375
- provider: "",
1376
- model: ""
1377
- } });
1378
- return;
1379
- }
1380
- const parsed = parseModelRouteKey(key);
1381
- if (parsed) save({ model: parsed });
1382
- },
1383
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
1384
- value: "",
1385
- children: text.modelDefault
1386
- }), choices.map((choice) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
1387
- value: modelRouteKey(choice.provider, choice.model),
1388
- children: choice.label
1389
- }, modelRouteKey(choice.provider, choice.model)))]
1390
- })]
1391
- }),
1392
- selected && efforts.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
2277
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1393
2278
  className: "dsh-ui-field",
1394
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1395
- className: "dsh-ui-label",
1396
- children: text.modelEffort
1397
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
1398
- className: "dsh-ui-select",
1399
- value: current.reasoningEffort ?? "",
1400
- disabled: busy,
1401
- onChange: (event) => save({ model: {
1402
- ...current,
1403
- reasoningEffort: event.target.value || void 0
1404
- } }),
1405
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
1406
- value: "",
1407
- children: text.modelDefault
1408
- }), efforts.map((effort) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
1409
- value: effort.id,
1410
- children: effort.name
1411
- }, effort.id))]
1412
- })]
2279
+ children: [
2280
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2281
+ className: "dsh-ui-label",
2282
+ children: text.model
2283
+ }),
2284
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ModelCatalogStatus, {
2285
+ state: catalogState,
2286
+ locale: language
2287
+ }),
2288
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ModelMenu, {
2289
+ locale: language,
2290
+ open: menuOpen && !busy,
2291
+ onOpenChange: (next) => setMenuOpen(next && !busy),
2292
+ listClassName: "gcm-root",
2293
+ anchor: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
2294
+ type: "button",
2295
+ className: "dsh-model-menu-trigger",
2296
+ disabled: busy,
2297
+ "aria-label": `${text.model}: ${currentLabel}`,
2298
+ children: [
2299
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2300
+ className: "dsh-model-menu-triggerLabel",
2301
+ children: currentLabel
2302
+ }),
2303
+ !follow && efforts.length ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2304
+ className: "dsh-model-menu-triggerEffort",
2305
+ children: effortName ?? text.modelDefault
2306
+ }) : null,
2307
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconChevronDownOutlineRegular, { className: `dsh-model-menu-chevron${menuOpen && !busy ? " dsh-model-menu-chevronOpen" : ""}` })
2308
+ ]
2309
+ }),
2310
+ choices,
2311
+ selected: follow ? void 0 : {
2312
+ provider: current.provider,
2313
+ model: current.model
2314
+ },
2315
+ onPick: (picked) => save({ model: {
2316
+ provider: picked.provider,
2317
+ model: picked.model
2318
+ } }),
2319
+ leading: [{
2320
+ id: "default",
2321
+ label: text.modelDefault,
2322
+ selected: follow
2323
+ }],
2324
+ onPickLeading: () => save({ model: {
2325
+ provider: "",
2326
+ model: ""
2327
+ } }),
2328
+ efforts,
2329
+ selectedEffort: current.reasoningEffort || "",
2330
+ onPickEffort: (effort) => save({ model: follow ? {
2331
+ provider: "",
2332
+ model: ""
2333
+ } : {
2334
+ provider: current.provider,
2335
+ model: current.model,
2336
+ ...effort ? { reasoningEffort: effort } : {}
2337
+ } }),
2338
+ modelLabel: text.model,
2339
+ modelValue: currentLabel,
2340
+ defaultEffortLabel: text.modelDefault
2341
+ })
2342
+ ]
1413
2343
  }),
1414
2344
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1415
2345
  className: "dsh-ui-meta",
@@ -1441,6 +2371,7 @@ function GitCommitSettingsPanel(props) {
1441
2371
  }
1442
2372
  function apply(ctx) {
1443
2373
  const client = ctx;
2374
+ mountModelSettingsChanges(ctx, PLUGIN_NAME);
1444
2375
  const settingsSubscriber = createModelSettingsSubscriber(ctx, PLUGIN_NAME);
1445
2376
  const subscribeSettings = (change, error) => settingsSubscriber.subscribe(change, error);
1446
2377
  ctx.provide(GIT_COMMIT_CLIENT_SERVICE, { active: true });