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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.i18n.yaml CHANGED
@@ -6,8 +6,8 @@
6
6
  en: f39813648aeef5a0
7
7
  zh: eea25094e8bfc031
8
8
  /deepseek-ai-dsh-client-ui-model-selection:
9
- en: 5e007d579a0e22f0
10
- zh: edfff0d021712077
9
+ en: 4cb6feb12e46f3a0
10
+ zh: eea39dd76fe1fa8b
11
11
  /deepseek-ai-dsh-client-ui-model-selection/summary:
12
12
  en: 2fb361ef704528fb
13
13
  zh: 2537d2afb4d2cb45
@@ -15,11 +15,11 @@
15
15
  en: d152484eb41ac6b4
16
16
  zh: 09388d293f9be9cb
17
17
  /deepseek-ai-dsh-client-ui-model-selection/use-this-package:
18
- en: e5a1df3a4dfe5041
19
- zh: 6269e73026b32a33
18
+ en: 62235f8c8c9e1e7e
19
+ zh: c18dc9c38ef9d4f9
20
20
  /deepseek-ai-dsh-client-ui-model-selection/use-this-package/model-and-effort:
21
- en: e38d0195677a7b69
22
- zh: 4eea8a9b1664afd9
21
+ en: 48f8157ffdfe6e69
22
+ zh: 64ec9a533e2aa8f4
23
23
  /deepseek-ai-dsh-client-ui-model-selection/use-this-package/unroutable-sessions:
24
24
  en: 7f20428c747ff052
25
25
  zh: 975e6de728e02fa0
@@ -27,8 +27,8 @@
27
27
  en: 261bbf42296243af
28
28
  zh: 72779fb7b8fbc473
29
29
  /deepseek-ai-dsh-client-ui-model-selection/understand-the-implementation:
30
- en: 493088e8de6be91c
31
- zh: 9ecee4605b4dbad4
30
+ en: f3d38594439eab3d
31
+ zh: f255974e61696dca
32
32
  /deepseek-ai-dsh-client-ui-model-selection/further-exploration:
33
33
  en: 14acd4692e923480
34
34
  zh: ff89aed852aa0da8
package/README.md CHANGED
@@ -7,6 +7,8 @@ kind: "package-reference"
7
7
 
8
8
  English | [中文](README.zh.md)
9
9
 
10
+ Desktop product events use the optional [product analytics service](../product-analytics/README.md); ordinary Web interactions are excluded.
11
+
10
12
  ## Summary
11
13
 
12
14
  The Web GUI lets users switch the model and reasoning effort for an existing session through either the `/model` popup or the composer's model control. Both surfaces present the same provider-grouped choices, and the selected model determines the available effort names and default. A complete selection applies to the next request; a running step keeps the model and effort it started with. If the selected model is unavailable, the composer stays disabled until the user selects an available model or that exact model becomes available again.
@@ -29,13 +31,17 @@ DeepSeek account and API-key routes appear as separate provider groups, each exp
29
31
 
30
32
  The unselected model label uses the same regular weight as an available model name and retains the saved reasoning effort caption for existing and new sessions; effort editing requires an available model. Clicking the unselected trigger opens the model list directly; Escape closes it.
31
33
 
32
- Mount this plugin alongside `ui-conversation` and the commands package; the composer then shows the model seat next to the pending indicator, and `/model` opens the same directory as a popup. While the seat's menu is open, `↑`/`↓` move focus across the rows of the shown pane, Tab settles the focused row, and Escape and `Shift+Tab` leave a drilled pane first and otherwise close back to the trigger. Drilling lands on the row of the value in use, and going back lands on the cell that opened the pane left. The composer shows the catalog name while the selected model is available, and its saved `provider/model` ID when the model or provider is removed, including account sign-out. The stored provider, model, and reasoning effort remain unchanged.
34
+ Mount this plugin alongside `ui-conversation` and the commands package; the composer then shows the model seat next to the pending indicator, and `/model` opens the same directory as a popup. While the seat's menu is open, `↑`/`↓` move focus through root and effort rows, or move the model-list highlight without leaving search. Enter and Tab settle the highlighted model or focused row; Escape and `Shift+Tab` leave a drilled pane first and otherwise close back to the trigger. Drilling focuses model search when shown, otherwise the current model or effort row, and going back lands on the cell that opened the pane left. The composer shows the catalog name while the selected model is available, and its saved `provider/model` ID when the model or provider is removed, including account sign-out. The stored provider, model, and reasoning effort remain unchanged.
33
35
 
34
- Mouse selection uses native browser clicks, including their cancellation behavior; a press alone never selects. Opening the menu focuses its trigger, and clicking the trigger again closes the menu and returns focus there. While a selection from either entry is pending, focus stays on the trigger, the trigger shows a spinner in place of its chevron, and each row whose value the selection carries shows one in place of its check; a rejected selection leaves the menu open, and Tab returns to the current row.
36
+ Mouse selection uses native browser clicks, including their cancellation behavior; a press alone never selects. Opening the root menu focuses its trigger, and clicking the trigger again closes the menu and returns focus there. Root rows and the search-clear button show the same fill for keyboard focus and hover, without a native outline. Selecting a model or effort returns focus to the trigger without a focus ring; leaving the trigger or reopening the menu restores its normal focus indication. While a selection from either entry is pending, focus stays on the trigger, the trigger shows a spinner in place of its chevron, and each row whose value the selection carries shows one in place of its check; a rejected selection leaves the menu open. Tab returns to model search with the current model highlighted when search is shown, otherwise to the current row.
35
37
 
36
38
  ### Model and effort
37
39
 
38
- Models stay grouped by provider. The composer menu shows model and effort names only, with DeepSeek Account first and DeepSeek second; third-party providers retain their catalog order. Navigation chevrons use `--dsw-alias-menu-icon`. The `/model` popup shows provider names and catalog descriptions; it localizes the two built-in DeepSeek descriptions and leaves external provider descriptions verbatim. The popup applies the selected model's default effort; the composer can then choose any advertised effort. An adapter without reasoning metadata leaves the Effort row absent; there is no arbitrary effort input.
40
+ The button's model menu shows search only when its full catalog contains more than four models; smaller catalogs focus the current model or first row and support keyboard navigation directly. The `/model` command always keeps its search field. Both search fields match model names case-insensitively, including nonconsecutive characters in order; leading and trailing spaces are ignored. Within each provider, prefix matches come first, followed by alignment score and then catalog order. Empty groups are hidden. Empty catalogs and queries without matches are announced through a status region. Search keeps focus while arrow keys cycle the highlighted result across provider groups; Enter or Tab selects it. Left and right arrows keep their native caret behavior. Opening highlights the current model or the first available row, and editing the query resets the highlight to the first result. Reopening the model pane clears the query.
41
+
42
+ Model and reasoning-effort names use weight 400 (regular) in the composer menu, including the selected item. The search field follows the command popup's compact treatment with transparent background and border in both palettes, with no leading icon and a caption-tone placeholder. The clear button appears for a nonempty query and restores the full list with focus in the search field.
43
+
44
+ Both entries group models by provider, with DeepSeek Account first and DeepSeek second; third-party providers retain their catalog order. Both use the shared, asynchronously observed sticky headings from [ui-primitives](../ui-primitives/README.md#understand-the-implementation): transparent at rest, with the theme's 94%-opaque fill only while pinned, and `md` corners outside macOS Desktop. The composer menu shows model and effort names only. Navigation chevrons use `--dsw-alias-menu-icon`. The `/model` popup shows provider names as group headings and model names as rows, without repeating the provider on each row or showing catalog descriptions. Its search placeholder, no-match text, and empty-catalog text use the same localized labels as the composer model menu. The popup applies the selected model's default effort; the composer can then choose any advertised effort. An adapter without reasoning metadata leaves the Effort row absent; there is no arbitrary effort input.
39
45
 
40
46
  The composer replaces the model and effort text with the Models icon when the expanded controls cannot share one line, and restores the text when space permits. The full selection remains available in the trigger's accessible name, tooltip, and menu.
41
47
 
@@ -57,6 +63,8 @@ Menus use the shared `MenuSurface` material, including the macOS backing for bac
57
63
  <details>
58
64
  <summary>Implementation internals — click to expand</summary>
59
65
 
66
+ The composer `ModelSelect` and `/model` option builder share [provider ordering](src/client/provider-order.ts), while both searches use `rankByName` within each provider. The command supplies optional groups and `searchMode: 'fuzzy-label'` through the [popupSelect API](../ui-commands/README.md#use-this-package); both entries use `MenuGroup` and rebuild its sticky observer when the rendered groups change. The command popup fills the composer overlay, while the button retains its compact menu.
67
+
60
68
  Two entries over ONE per-session directory owned by `ModelDirectoryResolver` (`ctx.modelDirectories`): the `/model` popupSelect contribution (registered through `ctx.commandUi`) and the composer's named `conversation.input.model` seat both load the session's available directory through `session.models` and submit through `session.selectModel` via the same `ModelDirectory` instance, so a switch made in either entry is what the other shows next. Directory loads and selections share a generation counter so an older response never overwrites a newer one. The directory publishes the latest submitted selection as `pending` until it settles or a connection reset invalidates it; a connection reset drops every resident projection and repulls the Host-restored selection before display. Directories are per-session, resolved lazily, and disposed with the session scope; addressed subagent sessions expose neither entry. Every resident directory refetches directly on forwarded `llm/adapters-updated`, `settings/document-updated`, and credential update events.
61
69
 
62
70
  </details>
package/README.zh.md CHANGED
@@ -7,6 +7,8 @@ kind: "package-reference"
7
7
 
8
8
  [English](README.md) | 中文
9
9
 
10
+ 桌面端产品事件使用可选的[产品埋点服务](../product-analytics/README.zh.md),不包含普通 Web 交互。
11
+
10
12
  ## 概述
11
13
 
12
14
  Web GUI 允许用户通过 `/model` 弹窗或 composer 模型控件切换既有会话使用的模型与推理(reasoning)强度。两个界面呈现同一组按提供方分组的选择;所选模型决定可用的推理强度名称与默认值。完整选择从下一次请求开始生效;运行中的步骤保留其启动时的模型与推理强度。所选模型不可用时,composer 保持停用,直到用户选择可用模型或同一模型恢复可用。
@@ -29,13 +31,17 @@ DeepSeek 账号和 API Key 路由显示为独立提供方分组,各自展示
29
31
 
30
32
  未选模型文案与可用模型名使用相同的常规字重,并在既有会话与新会话中保留已保存的推理强度显示;调整推理强度需要先选择可用模型。点击未选模型控件直接打开模型列表,Escape 关闭列表。
31
33
 
32
- 与 `ui-conversation` 及命令包一起挂载本插件;composer 随即在待处理指示器旁显示模型位,`/model` 则以弹窗打开同一份目录。模型位菜单打开期间,`↑`/`↓` 在所显示面板的行间移动焦点,`Tab` 选定聚焦行,Escape 与 `Shift+Tab` 先退出已下钻的面板,否则关闭并回到触发器。下钻落在正在使用的那一行,返回则落在打开该面板的格子上。所选模型可用时,composer 显示目录中的模型名称;模型或提供方被删除时(包括账号退登),改为显示已保存的 `provider/model` ID。保存的提供方、模型和推理强度均保持不变。
34
+ 与 `ui-conversation` 及命令包一起挂载本插件;composer 随即在待处理指示器旁显示模型位,`/model` 则以弹窗打开同一份目录。模型位菜单打开期间,`↑`/`↓` 在根菜单和推理等级行间移动焦点,或在模型列表中移动高亮并让焦点保留在搜索框。Enter 和 Tab 选定高亮模型或聚焦行;Escape 与 `Shift+Tab` 先退出已下钻的面板,否则关闭并回到触发器。下钻时若显示模型搜索框则聚焦搜索框,否则聚焦当前模型或推理强度行,返回则落在打开该面板的格子上。所选模型可用时,composer 显示目录中的模型名称;模型或提供方被删除时(包括账号退登),改为显示已保存的 `provider/model` ID。保存的提供方、模型和推理强度均保持不变。
33
35
 
34
- 鼠标选择沿用浏览器原生点击及其取消行为;仅按下按钮不会选定。打开菜单时聚焦触发按钮,再次点击触发按钮会关闭菜单并把焦点还给它。等待任一入口发起的选择结果时,焦点停在触发按钮,触发按钮以加载图标代替下拉箭头,该选择包含的值所在行以加载图标代替勾选标记;选择被拒绝后菜单保持打开,Tab 可回到当前选中的行。
36
+ 鼠标选择沿用浏览器原生点击及其取消行为;仅按下按钮不会选定。打开根菜单时聚焦触发按钮,再次点击触发按钮会关闭菜单并把焦点还给它。根菜单行和搜索清除按钮的键盘焦点与悬停使用相同底色,不显示原生焦点轮廓。选定模型或推理等级后,焦点返回触发按钮但不显示焦点环;焦点离开按钮或重新打开菜单后恢复正常焦点提示。等待任一入口发起的选择结果时,焦点停在触发按钮,触发按钮以加载图标代替下拉箭头,该选择包含的值所在行以加载图标代替勾选标记;选择被拒绝后菜单保持打开。显示模型搜索框时,Tab 返回搜索框并高亮当前模型,否则返回当前选中行。
35
37
 
36
38
  ### 模型与推理强度
37
39
 
38
- 模型按提供方分组。composer 菜单只显示模型与推理强度名称,DeepSeek 账号排第一,DeepSeek 排第二,第三方提供方保持目录原有顺序。导航箭头使用 `--dsw-alias-menu-icon` 文本色。`/model` 弹窗显示提供方名称与目录说明;其中两个内置 DeepSeek 模型的说明使用当前语言,外部提供方说明保持原文。弹窗应用所选模型的默认推理强度;composer 随后可以选择任一已公布的推理强度。适配器没有推理元数据时不显示 Effort 行;不存在任意推理强度输入。
40
+ 按钮模型菜单仅在完整目录超过四个模型时显示搜索框;较小目录聚焦当前模型或首行,直接支持键盘导航。`/model` 命令始终保留搜索框。两个搜索框均按模型名称进行不区分大小写的匹配,支持按顺序输入不连续字符,并忽略首尾空格。每个提供方内的结果优先按前缀匹配、其次按匹配得分、最后按目录顺序排列。没有匹配项的分组会隐藏。空目录和无匹配结果均通过状态区域通知屏幕阅读器。方向键在各提供方分组的结果间循环移动高亮,焦点保留在搜索框;Enter 或 Tab 选定高亮项。左右方向键仍用于移动输入光标。打开时高亮当前模型或首个可用选项,修改搜索内容后高亮首个结果。重新打开模型面板会清空搜索内容。
41
+
42
+ composer 菜单中的模型名称和推理等级均使用 400(regular)字重,包括选中项。搜索框沿用 command 弹窗的紧凑样式,在浅/深色主题下均保持背景和边框透明,不显示前置图标,占位文字使用 caption 色。搜索内容非空时显示清除按钮;点击后恢复完整列表,并将焦点留在搜索框。
43
+
44
+ 两个入口均按提供方分组,DeepSeek 账号排第一,DeepSeek 排第二,第三方提供方保持目录原有顺序。两者采用 [ui-primitives](../ui-primitives/README.zh.md#understand-the-implementation) 异步观察的共享吸顶标题:原位透明,仅吸顶时使用主题的 94% 不透明填充,macOS 桌面端以外使用 `md` 圆角。composer 菜单只显示模型与推理强度名称。导航箭头使用 `--dsw-alias-menu-icon` 文本色。`/model` 弹窗以提供方名称作为分组标题、模型名称作为选项行,不在每行重复提供方,也不显示目录说明。搜索占位文字、无匹配提示和空目录提示与 composer 模型菜单共用同一组本地化文案。弹窗应用所选模型的默认推理强度;composer 随后可以选择任一已公布的推理强度。适配器没有推理元数据时不显示 Effort 行;不存在任意推理强度输入。
39
45
 
40
46
  展开的控件无法排在同一行时,composer 将模型与推理强度文字替换为模型图标;空间足够后恢复文字。触发器的无障碍名称、提示和菜单仍提供完整选择。
41
47
 
@@ -57,6 +63,8 @@ DeepSeek 账号和 API Key 路由显示为独立提供方分组,各自展示
57
63
  <details>
58
64
  <summary>实现细节——点击展开</summary>
59
65
 
66
+ composer 的 `ModelSelect` 与 `/model` 选项构建器共用[提供方排序](src/client/provider-order.ts),两个搜索框均在每个提供方内使用 `rankByName`。命令通过 [popupSelect API](../ui-commands/README.zh.md#use-this-package) 提供可选分组与 `searchMode: 'fuzzy-label'`;两个入口均使用 `MenuGroup`,并在渲染分组变化时重建其吸顶观察器。命令弹窗撑满 composer 浮层,按钮则保留紧凑菜单。
67
+
60
68
  两个入口共用一份由 `ModelDirectoryResolver`(`ctx.modelDirectories`)持有的会话级目录:`/model` popupSelect 贡献项(经 `ctx.commandUi` 注册)与 composer 的具名 `conversation.input.model` 位都经 `session.models` 加载会话的可用目录、经 `session.selectModel` 通过同一个 `ModelDirectory` 实例提交,因此任一入口所做的切换正是另一个入口接下来显示的。目录加载与选择共享一个代次计数器,旧响应不会覆盖新结果。目录把最近一次提交的选择发布为 `pending`,直到它完成或被连接重置作废;连接重置丢弃所有常驻投影,并在显示前重新拉取 Host 恢复的选择。目录按会话惰性解析,随会话作用域一并 dispose(资源释放);已寻址 subagent 会话不公开任一入口。每份常驻目录都会直接在转发的 `llm/adapters-updated`、`settings/document-updated` 与凭据更新事件上重拉。
61
69
 
62
70
  </details>
package/lib/client.js CHANGED
@@ -173,6 +173,8 @@ window.__ModuleLoader__.load({
173
173
  available;
174
174
  catalog;
175
175
  projected;
176
+ isBlank;
177
+ track;
176
178
  /** The shared snapshot both entries render from (uSES-safe store). */
177
179
  store = (0, _deepseek_ai_dsh_client_store.createSnapshotStore)({
178
180
  current: null,
@@ -194,13 +196,17 @@ window.__ModuleLoader__.load({
194
196
  * @param available - whether this session may use Agent-bound model RPCs.
195
197
  * @param catalog - Host-generation catalog shared by every Session.
196
198
  * @param projected - durable model selection projected from Session history.
199
+ * @param isBlank - whether this Session has no first message yet.
200
+ * @param track - desktop-only callback after a successful user selection.
197
201
  */
198
- constructor(sessions, sessionId, available, catalog, projected) {
202
+ constructor(sessions, sessionId, available, catalog, projected, isBlank, track) {
199
203
  this.sessions = sessions;
200
204
  this.sessionId = sessionId;
201
205
  this.available = available;
202
206
  this.catalog = catalog;
203
207
  this.projected = projected;
208
+ this.isBlank = isBlank;
209
+ this.track = track;
204
210
  this.unsubscribeCatalog = catalog.store.subscribe(() => {
205
211
  this.syncInputs();
206
212
  });
@@ -228,6 +234,9 @@ window.__ModuleLoader__.load({
228
234
  */
229
235
  async select(selection) {
230
236
  this.assertAvailable();
237
+ const previous = this.store.getSnapshot().current;
238
+ const previousEffort = previous?.reasoningEffort ?? (previous === null ? void 0 : this.catalog.reasoningFor(previous)?.defaultEffort);
239
+ const nextEffort = selection.reasoningEffort ?? this.catalog.reasoningFor(selection)?.defaultEffort;
231
240
  const generation = ++this.generation;
232
241
  this.store.update((s) => {
233
242
  s.status = "selecting";
@@ -252,6 +261,21 @@ window.__ModuleLoader__.load({
252
261
  });
253
262
  return result;
254
263
  }
264
+ if (previous !== null) {
265
+ const from = `${previous.provider}/${previous.model}`;
266
+ const to = `${selection.provider}/${selection.model}`;
267
+ if (from !== to) this.track?.("model_switch", {
268
+ ...this.isBlank() ? {} : { session_id: this.sessionId },
269
+ switch_from: from,
270
+ switch_to: to
271
+ });
272
+ if (from === to && previousEffort !== nextEffort) this.track?.("thinking_level_switch", {
273
+ ...this.isBlank() ? {} : { session_id: this.sessionId },
274
+ model_name: to,
275
+ switch_from: previousEffort ?? "default",
276
+ switch_to: nextEffort ?? "default"
277
+ });
278
+ }
255
279
  this.store.update((s) => {
256
280
  s.status = "ready";
257
281
  s.pending = null;
@@ -325,20 +349,6 @@ window.__ModuleLoader__.load({
325
349
  }
326
350
  //#endregion
327
351
  //#region lib/types/client/service.js
328
- /**
329
- * ModelDirectoryResolver (`ctx.modelDirectories`): the root owner of per-session
330
- * {@link ModelDirectory} instances. Both selection entries (the /model popup
331
- * and the composer model seat) resolve their session's directory through
332
- * this service, which is what makes the dual entry one shared state.
333
- *
334
- * Per-session storage follows the client service pattern (InputTriggerService /
335
- * CommandUiRuntime): a lazy service-internal map whose entry is deleted by the
336
- * owning scope's disposer. The host `dsh-scope` ScopedLayers registry does
337
- * does not belong here: it derives scope from the host carrier mechanism
338
- * (object-keyed), while client scopes tag contexts with branded SessionId
339
- * strings, and it models global+shadow named registries — this is a
340
- * per-session singleton with no global layer to merge.
341
- */
342
352
  /** The `ctx.modelDirectories` session model-selection service. */
343
353
  var ModelDirectoryResolver = class extends _deepseek_ai_cordis.Service {
344
354
  static inject = [
@@ -387,7 +397,7 @@ window.__ModuleLoader__.load({
387
397
  if (binding === void 0) throw new Error(`ui-model-selection: session "${String(sessionId)}" resolved no binding`);
388
398
  const existing = live.directories.get(binding);
389
399
  if (existing !== void 0) return existing;
390
- const directory = new ModelDirectory(this.ctx.remote.session, sessionId, () => sessions.subagentAddress(sessionId) === void 0, this.catalog, binding.session.projections.faceOf("modelSelection"));
400
+ const directory = new ModelDirectory(this.ctx.remote.session, sessionId, () => sessions.subagentAddress(sessionId) === void 0, this.catalog, binding.session.projections.faceOf("modelSelection"), () => binding.session.getSnapshot().blank, (name, attributes) => this.ctx.get("productAnalytics")?.track(name, attributes));
391
401
  live.directories.set(binding, directory);
392
402
  actx.effect(() => () => {
393
403
  directory.dispose();
@@ -413,7 +423,7 @@ window.__ModuleLoader__.load({
413
423
  }
414
424
  //#endregion
415
425
  //#region \0dsh-css:/home/runner/work/deepseek-harness/deepseek-harness/packages/client/ui-model-selection/src/client/ModelSelect.module.css.mjs
416
- const css = "._7KE1Ra_root{min-width:0;position:relative}._7KE1Ra_trigger{border-radius:var(--dsw-radius-sm);min-width:0;max-width:min(360px,45cqw);height:28px;color:var(--dsw-alias-label-secondary);cursor:pointer;background:0 0;border:none;outline:none;align-items:center;gap:4px;padding:0 4px 0 8px;font-size:13px;font-weight:400;line-height:20px;display:flex}._7KE1Ra_trigger:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}._7KE1Ra_trigger:focus-visible{box-shadow:0 0 0 2px var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary))}._7KE1Ra_trigger:disabled{color:var(--dsw-alias-label-dimmed);cursor:default}._7KE1Ra_triggerLabel{text-overflow:ellipsis;white-space:nowrap;min-width:0;overflow:hidden}._7KE1Ra_triggerEffort{text-overflow:ellipsis;white-space:nowrap;min-width:0;color:var(--dsw-alias-label-caption);flex-shrink:1000;overflow:hidden}._7KE1Ra_triggerIcon{display:var(--dsh-composer-model-icon-display,none);flex:none}._7KE1Ra_triggerLabel,._7KE1Ra_triggerEffort{display:var(--dsh-composer-model-text-display,block)}._7KE1Ra_chevron{color:var(--dsw-alias-label-caption);flex:none;transition:transform .12s}._7KE1Ra_chevronOpen{transform:rotate(180deg)}._7KE1Ra_menu{z-index:1100;--dsw-elevation-stroke-color:var(--dsw-alias-border-l1);width:max-content;min-width:min(240px,100vw - 32px);max-width:min(420px,100vw - 32px);max-height:min(360px,100vh - 96px);box-shadow:var(--dsw-elevation-prominent);color:var(--dsw-alias-label-primary);--dsh-scrollbar-thumb:var(--dsw-alias-scrollbar-bg-l2);--dsh-scrollbar-thumb-hover:var(--dsw-alias-scrollbar-hover-l2);border:0;flex-direction:column;padding:4px;display:flex;position:fixed;overflow:hidden}._7KE1Ra_status,._7KE1Ra_empty{color:var(--dsw-alias-label-tertiary);padding:8px;font-size:12px;line-height:18px}._7KE1Ra_error,._7KE1Ra_warning{border-radius:var(--dsw-radius-md);background:var(--dsw-alias-interactive-bg-hover-danger);color:var(--dsw-alias-state-error-primary);justify-content:space-between;align-items:flex-start;gap:6px;margin-bottom:3px;padding:6px 7px;font-size:11px;line-height:16px;display:flex}._7KE1Ra_warning{background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-state-warn-label)}._7KE1Ra_retry{color:inherit;font:inherit;cursor:pointer;background:0 0;border:none;flex:none;padding:0;font-weight:600}._7KE1Ra_groups{min-height:0;overflow-y:auto}._7KE1Ra_group+._7KE1Ra_group{margin-top:3px}._7KE1Ra_groupTitle{z-index:1;background:var(--dsw-specific-menu);color:var(--dsw-alias-label-tertiary);padding:4px 7px 2px;font-size:11px;font-weight:500;line-height:16px;position:sticky;top:0}._7KE1Ra_option{box-sizing:border-box;border-radius:var(--dsw-radius-md);width:auto;min-width:100%;min-height:34px;color:inherit;text-align:left;cursor:pointer;background:0 0;border:none;outline:none;align-items:center;gap:6px;padding:5px 7px;display:flex}._7KE1Ra_option:hover:not(:disabled),._7KE1Ra_option:focus-visible{background:var(--dsw-alias-interactive-bg-hover)}._7KE1Ra_selected{background:0 0}._7KE1Ra_option:disabled{color:var(--dsw-alias-label-dimmed);cursor:default}._7KE1Ra_optionCopy{flex-direction:column;flex:1;min-width:0;display:flex}._7KE1Ra_modelName{color:inherit;text-overflow:ellipsis;white-space:nowrap;font-size:13px;font-weight:500;line-height:18px;overflow:hidden}._7KE1Ra_check{color:var(--dsw-alias-label-primary);flex:0 0 14px;place-items:center;display:grid}._7KE1Ra_check svg{width:14px;height:14px}._7KE1Ra_cell{box-sizing:border-box;border-radius:var(--dsw-radius-md);width:auto;min-width:100%;height:34px;color:var(--dsw-alias-label-primary);cursor:pointer;text-align:left;background:0 0;border:none;align-items:center;gap:6px;padding:0 8px;font-size:13px;line-height:20px;display:flex}._7KE1Ra_cell:hover{background:var(--dsw-alias-interactive-bg-hover)}._7KE1Ra_cellLabel{white-space:nowrap;flex:none}._7KE1Ra_cellValue{text-overflow:ellipsis;white-space:nowrap;text-align:right;min-width:0;color:var(--dsw-alias-label-tertiary);flex:auto;overflow:hidden}._7KE1Ra_cellChevron{width:12px;height:12px;color:var(--dsw-alias-menu-icon);flex:none}";
426
+ const css = "._7KE1Ra_root{min-width:0;position:relative}._7KE1Ra_trigger{border-radius:var(--dsw-radius-sm);min-width:0;max-width:min(360px,45cqw);height:28px;color:var(--dsw-alias-label-secondary);cursor:pointer;background:0 0;border:none;outline:none;align-items:center;gap:4px;padding:0 4px 0 8px;font-size:13px;font-weight:400;line-height:20px;display:flex}._7KE1Ra_trigger:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}._7KE1Ra_trigger:focus-visible:not([data-selection-focus]){box-shadow:0 0 0 2px var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary))}._7KE1Ra_trigger:disabled{color:var(--dsw-alias-label-dimmed);cursor:default}._7KE1Ra_triggerLabel{text-overflow:ellipsis;white-space:nowrap;min-width:0;overflow:hidden}._7KE1Ra_triggerEffort{text-overflow:ellipsis;white-space:nowrap;min-width:0;color:var(--dsw-alias-label-caption);flex-shrink:1000;overflow:hidden}._7KE1Ra_triggerIcon{display:var(--dsh-composer-model-icon-display,none);flex:none}._7KE1Ra_triggerLabel,._7KE1Ra_triggerEffort{display:var(--dsh-composer-model-text-display,block)}._7KE1Ra_chevron{color:var(--dsw-alias-label-caption);flex:none;transition:transform .12s}._7KE1Ra_chevronOpen{transform:rotate(180deg)}._7KE1Ra_menu{z-index:1100;--dsw-elevation-stroke-color:var(--dsw-alias-border-l1);width:max-content;min-width:min(240px,100vw - 32px);max-width:min(420px,100vw - 32px);max-height:min(360px,100vh - 96px);box-shadow:var(--dsw-elevation-prominent);color:var(--dsw-alias-label-primary);--dsh-scrollbar-thumb:var(--dsw-alias-scrollbar-bg-l2);--dsh-scrollbar-thumb-hover:var(--dsw-alias-scrollbar-hover-l2);border:0;flex-direction:column;padding:4px;display:flex;position:fixed;overflow:hidden}._7KE1Ra_status,._7KE1Ra_empty{color:var(--dsw-alias-label-tertiary);padding:8px;font-size:12px;line-height:18px}._7KE1Ra_error,._7KE1Ra_warning{border-radius:var(--dsw-radius-md);background:var(--dsw-alias-interactive-bg-hover-danger);color:var(--dsw-alias-state-error-primary);justify-content:space-between;align-items:flex-start;gap:6px;margin-bottom:3px;padding:6px 7px;font-size:11px;line-height:16px;display:flex}._7KE1Ra_warning{background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-state-warn-label)}._7KE1Ra_retry{color:inherit;font:inherit;cursor:pointer;background:0 0;border:none;flex:none;padding:0;font-weight:600}._7KE1Ra_searchRow{flex-shrink:0;margin:2px 0 3px;position:relative}._7KE1Ra_searchRow ._7KE1Ra_search{border-radius:var(--dsw-radius-md);background:0 0;border:0 solid #0000;height:auto;padding:5px 7px;display:flex}._7KE1Ra_searchRow ._7KE1Ra_searchWithQuery{padding-right:34px}._7KE1Ra_searchClear{corner-shape:round;width:24px;height:24px;color:var(--dsw-alias-label-secondary);cursor:pointer;background:0 0;border:none;border-radius:50%;justify-content:center;align-items:center;padding:0;display:inline-flex;position:absolute;top:50%;right:4px;transform:translateY(-50%)}._7KE1Ra_searchClear:hover,._7KE1Ra_searchClear:focus-visible{background:var(--dsw-alias-interactive-bg-hover);outline:none}._7KE1Ra_searchRow ._7KE1Ra_search:focus-within{border-color:#0000}._7KE1Ra_searchRow ._7KE1Ra_search input{padding:0;font-size:12px;line-height:normal}._7KE1Ra_searchRow ._7KE1Ra_search input::placeholder{color:var(--dsw-alias-label-caption)}._7KE1Ra_groups{min-height:0;overflow-y:auto}._7KE1Ra_option{box-sizing:border-box;border-radius:var(--dsw-radius-md);width:auto;min-width:100%;min-height:34px;color:inherit;text-align:left;cursor:pointer;background:0 0;border:none;outline:none;align-items:center;gap:6px;padding:5px 7px;display:flex}._7KE1Ra_option:not(._7KE1Ra_modelOption):hover:not(:disabled),._7KE1Ra_option:focus-visible,._7KE1Ra_optionActive:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}._7KE1Ra_selected{background:0 0}._7KE1Ra_option:disabled{color:var(--dsw-alias-label-dimmed);cursor:default}._7KE1Ra_optionCopy{flex-direction:column;flex:1;min-width:0;display:flex}._7KE1Ra_modelName{color:inherit;text-overflow:ellipsis;white-space:nowrap;font-size:13px;font-weight:400;line-height:18px;overflow:hidden}._7KE1Ra_check{color:var(--dsw-alias-label-primary);flex:0 0 14px;place-items:center;display:grid}._7KE1Ra_check svg{width:14px;height:14px}._7KE1Ra_cell{box-sizing:border-box;border-radius:var(--dsw-radius-md);width:auto;min-width:100%;height:34px;color:var(--dsw-alias-label-primary);cursor:pointer;text-align:left;background:0 0;border:none;outline:none;align-items:center;gap:6px;padding:0 8px;font-size:13px;line-height:20px;display:flex}._7KE1Ra_cell:hover,._7KE1Ra_cell:focus-visible{background:var(--dsw-alias-interactive-bg-hover)}._7KE1Ra_cellLabel{white-space:nowrap;flex:none}._7KE1Ra_cellValue{text-overflow:ellipsis;white-space:nowrap;text-align:right;min-width:0;color:var(--dsw-alias-label-tertiary);flex:auto;overflow:hidden}._7KE1Ra_cellChevron{width:12px;height:12px;color:var(--dsw-alias-menu-icon);flex:none}";
417
427
  const tagId = "@deepseek-ai/dsh-client-ui-model-selection/ModelSelect.module.css";
418
428
  if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
419
429
  const tag = document.createElement("style");
@@ -432,15 +442,19 @@ window.__ModuleLoader__.load({
432
442
  "chevronOpen": "_7KE1Ra_chevronOpen",
433
443
  "empty": "_7KE1Ra_empty",
434
444
  "error": "_7KE1Ra_error",
435
- "group": "_7KE1Ra_group",
436
- "groupTitle": "_7KE1Ra_groupTitle",
437
445
  "groups": "_7KE1Ra_groups",
438
446
  "menu": "_7KE1Ra_menu",
439
447
  "modelName": "_7KE1Ra_modelName",
448
+ "modelOption": "_7KE1Ra_modelOption",
440
449
  "option": "_7KE1Ra_option",
450
+ "optionActive": "_7KE1Ra_optionActive",
441
451
  "optionCopy": "_7KE1Ra_optionCopy",
442
452
  "retry": "_7KE1Ra_retry",
443
453
  "root": "_7KE1Ra_root",
454
+ "search": "_7KE1Ra_search",
455
+ "searchClear": "_7KE1Ra_searchClear",
456
+ "searchRow": "_7KE1Ra_searchRow",
457
+ "searchWithQuery": "_7KE1Ra_searchWithQuery",
444
458
  "selected": "_7KE1Ra_selected",
445
459
  "status": "_7KE1Ra_status",
446
460
  "trigger": "_7KE1Ra_trigger",
@@ -450,6 +464,17 @@ window.__ModuleLoader__.load({
450
464
  "warning": "_7KE1Ra_warning"
451
465
  };
452
466
  //#endregion
467
+ //#region lib/types/client/provider-order.js
468
+ /** Shared provider display order for the composer and command model pickers. */
469
+ /**
470
+ * Put the account and official providers first, preserving every other relative order.
471
+ * @param groups - Provider groups in catalog order.
472
+ * @returns a sorted copy; model order within each group is unchanged.
473
+ */
474
+ function orderModelProviders(groups) {
475
+ return groups.toSorted((left, right) => (left.id === "deepseek-account" ? 0 : left.id === "deepseek-official" ? 1 : 2) - (right.id === "deepseek-account" ? 0 : right.id === "deepseek-official" ? 1 : 2));
476
+ }
477
+ //#endregion
453
478
  //#region lib/types/client/ModelSelect.js
454
479
  /**
455
480
  * ModelSelect: the composer's named model seat (`conversation.input.model`).
@@ -458,19 +483,25 @@ window.__ModuleLoader__.load({
458
483
  * each drilling into its own list — the provider-grouped model list over
459
484
  * the shared directory, and the effort levels. The trigger (313:14108's
460
485
  * ToggleButton) shows both: model name + effort in the caption tone.
461
- * While open, ↑/↓ move focus across the rows of the shown pane (wrapping; a
462
- * step taken while the trigger still holds focus enters at the near end), Tab
463
- * settles like Enter, and Escape and Shift+Tab leave a drilled pane first and
464
- * otherwise close back to the trigger. A drilled pane hands focus to the row
465
- * of the value in use, and returning to the root pane hands it back to the
466
- * cell that opened it. Data and submission ride the SAME per-session
467
- * ModelDirectory as the /model popup; exact-model reasoning metadata and the
468
- * selected effort come from the Host rather than a client-owned vocabulary. A
469
- * rejected selection announces through the shared transient Toast anchored to
470
- * the composer card; the in-menu strip with Retry remains the catalog-load
471
- * surface. While the directory's pending selection is unsettled, the trigger
472
- * shows a spinner in place of its chevron, and each row whose value that
473
- * selection carries shows one in place of its check mark.
486
+ * Model catalogs above four entries show search, which retains focus while
487
+ * ↑/↓ cycle the highlighted result; Enter and Tab accept it. Smaller model
488
+ * catalogs, root panes, and effort panes move focus between rows. Escape and Shift+Tab leave a drilled pane first and otherwise close
489
+ * back to the trigger. A drilled pane focuses the current effort or model
490
+ * search field. Provider headings paint their background only while pinned
491
+ * by scrolling. Clearing a query restores the full list and search focus.
492
+ * Selecting restores trigger focus without a ring until the trigger loses focus
493
+ * or the menu reopens. Model names match a case-insensitive ordered subsequence
494
+ * within each provider group, ranked by
495
+ * prefix, alignment score, then catalog order. Returning to the root pane
496
+ * hands focus back to the cell that opened it. Data and submission ride the
497
+ * same per-session ModelDirectory as the /model popup; exact-model reasoning
498
+ * metadata and the selected effort come from the Host rather than a
499
+ * client-owned vocabulary. A rejected selection announces through the shared
500
+ * transient Toast anchored to the composer card; the in-menu strip with
501
+ * Retry remains the catalog-load surface. While the directory's pending
502
+ * selection is unsettled, the trigger shows a spinner in place of its
503
+ * chevron, and each row whose value that selection carries shows one in place
504
+ * of its check mark.
474
505
  */
475
506
  /** Unplaced portal card: hidden but laid out at a fixed origin so offsetWidth/offsetHeight are real (Menu primitive's measure pass). */
476
507
  const MEASURE_STYLE = {
@@ -488,16 +519,21 @@ window.__ModuleLoader__.load({
488
519
  const state = (0, react.useSyncExternalStore)((fn) => directory.subscribe(fn), () => directory.getSnapshot());
489
520
  const [open, setOpen] = (0, react.useState)(false);
490
521
  const [pane, setPane] = (0, react.useState)("root");
522
+ const [query, setQuery] = (0, react.useState)("");
523
+ const [highlightedIndex, setHighlightedIndex] = (0, react.useState)(null);
524
+ const [selectionFocus, setSelectionFocus] = (0, react.useState)(false);
491
525
  const lastActionRef = (0, react.useRef)("load");
492
526
  const [toast, setToast] = (0, react.useState)(null);
493
527
  const toastSeq = (0, react.useRef)(0);
494
528
  const rootRef = (0, react.useRef)(null);
495
529
  const triggerRef = (0, react.useRef)(null);
530
+ const searchRef = (0, react.useRef)(null);
496
531
  const menuRef = (0, react.useRef)(null);
532
+ const groupsRef = (0, react.useRef)(null);
497
533
  const [menuPos, setMenuPos] = (0, react.useState)(null);
498
534
  const itemRefs = (0, react.useRef)([]);
499
535
  const id = (0, react.useId)();
500
- const groups = (0, react.useMemo)(() => state.groups.toSorted((left, right) => (left.id === "deepseek-account" ? 0 : left.id === "deepseek-official" ? 1 : 2) - (right.id === "deepseek-account" ? 0 : right.id === "deepseek-official" ? 1 : 2)), [state.groups]);
536
+ const groups = (0, react.useMemo)(() => orderModelProviders(state.groups), [state.groups]);
501
537
  const choices = (0, react.useMemo)(() => groups.flatMap((group) => group.models.map((model) => ({
502
538
  group,
503
539
  model,
@@ -507,6 +543,21 @@ window.__ModuleLoader__.load({
507
543
  ...model.reasoning?.defaultEffort === void 0 ? {} : { reasoningEffort: model.reasoning.defaultEffort }
508
544
  }
509
545
  }))), [groups]);
546
+ const showSearch = choices.length > 4;
547
+ const filteredGroups = (0, react.useMemo)(() => groups.map((group) => ({
548
+ ...group,
549
+ models: (0, _deepseek_ai_dsh_client_ui_primitives.rankByName)(group.models, showSearch ? query.trim() : "")
550
+ })).filter((group) => group.models.length > 0), [
551
+ groups,
552
+ query,
553
+ showSearch
554
+ ]);
555
+ const visibleModels = (0, react.useMemo)(() => filteredGroups.flatMap((group) => group.models.map((model) => ({
556
+ provider: group.id,
557
+ model: model.id
558
+ }))), [filteredGroups]);
559
+ const currentVisibleIndex = visibleModels.findIndex((model) => model.provider === state.current?.provider && model.model === state.current.model);
560
+ const activeModelIndex = Math.min(highlightedIndex ?? Math.max(0, currentVisibleIndex), visibleModels.length - 1);
510
561
  const currentChoice = choices[state.current === null ? -1 : choices.findIndex((c) => c.selection.provider === state.current?.provider && c.selection.model === state.current.model)];
511
562
  const reasoning = currentChoice?.model.reasoning;
512
563
  const effectiveEffort = state.current?.reasoningEffort ?? reasoning?.defaultEffort;
@@ -538,18 +589,53 @@ window.__ModuleLoader__.load({
538
589
  document.removeEventListener("mousedown", closeOutside);
539
590
  };
540
591
  }, [open]);
592
+ (0, react.useLayoutEffect)(() => {
593
+ if (!showSearch) {
594
+ setQuery("");
595
+ setHighlightedIndex(null);
596
+ }
597
+ }, [showSearch]);
541
598
  const paneFocus = (0, react.useRef)(null);
599
+ const previousShowSearch = (0, react.useRef)(showSearch);
542
600
  (0, react.useEffect)(() => {
543
- const intent = paneFocus.current;
601
+ const changedSearchMode = previousShowSearch.current !== showSearch;
602
+ previousShowSearch.current = showSearch;
603
+ const intent = paneFocus.current ?? (changedSearchMode && pane === "model" ? "drill" : null);
544
604
  paneFocus.current = null;
545
605
  if (!open || intent === null) return;
546
606
  if (intent === "drill") {
607
+ if (pane === "model" && showSearch) {
608
+ searchRef.current?.focus();
609
+ return;
610
+ }
547
611
  (menuRef.current?.querySelector("[role=\"menuitemradio\"][aria-checked=\"true\"]:not([disabled])") ?? itemRefs.current.find((item) => item !== null && !item.disabled) ?? triggerRef.current)?.focus();
548
612
  return;
549
613
  }
550
614
  const cell = itemRefs.current[intent === "effort" ? 1 : 0];
551
615
  (cell !== null && cell !== void 0 && !cell.disabled ? cell : triggerRef.current)?.focus();
552
- }, [open, pane]);
616
+ }, [
617
+ open,
618
+ pane,
619
+ showSearch
620
+ ]);
621
+ (0, react.useEffect)(() => {
622
+ const viewport = groupsRef.current;
623
+ if (viewport === null) return;
624
+ return (0, _deepseek_ai_dsh_client_ui_primitives.observeStickyMenuGroups)(viewport);
625
+ }, [
626
+ available,
627
+ open,
628
+ pane,
629
+ filteredGroups
630
+ ]);
631
+ (0, react.useLayoutEffect)(() => {
632
+ if (open && pane === "model" && activeModelIndex >= 0) itemRefs.current[activeModelIndex]?.scrollIntoView({ block: "nearest" });
633
+ }, [
634
+ open,
635
+ pane,
636
+ activeModelIndex,
637
+ visibleModels
638
+ ]);
553
639
  (0, react.useLayoutEffect)(() => {
554
640
  if (!open) {
555
641
  setMenuPos(null);
@@ -581,16 +667,24 @@ window.__ModuleLoader__.load({
581
667
  }, [
582
668
  open,
583
669
  pane,
584
- state
670
+ state,
671
+ query
585
672
  ]);
586
673
  if (!available) return null;
587
674
  const show = () => {
675
+ setSelectionFocus(false);
588
676
  triggerRef.current?.focus();
677
+ setQuery("");
678
+ setHighlightedIndex(null);
589
679
  if (state.current === null) paneFocus.current = "drill";
590
680
  setPane(state.current === null ? "model" : "root");
591
681
  setOpen(true);
592
682
  reload();
593
683
  };
684
+ const changeQuery = (next) => {
685
+ setQuery(next);
686
+ setHighlightedIndex(0);
687
+ };
594
688
  const close = (restoreFocus = false) => {
595
689
  setOpen(false);
596
690
  setPane("root");
@@ -598,7 +692,13 @@ window.__ModuleLoader__.load({
598
692
  triggerRef.current?.focus();
599
693
  });
600
694
  };
695
+ const closeAfterSelection = () => {
696
+ setSelectionFocus(true);
697
+ close(true);
698
+ };
601
699
  const drill = (next) => {
700
+ setQuery("");
701
+ setHighlightedIndex(null);
602
702
  paneFocus.current = "drill";
603
703
  setPane(next);
604
704
  };
@@ -614,6 +714,7 @@ window.__ModuleLoader__.load({
614
714
  items[active === -1 ? offset > 0 ? 0 : items.length - 1 : (active + offset + items.length) % items.length]?.focus();
615
715
  };
616
716
  const onRootKeyDown = (event) => {
717
+ if (event.nativeEvent.isComposing) return;
617
718
  if (event.key === "Escape" && open) {
618
719
  event.preventDefault();
619
720
  if (pane !== "root" && state.current !== null) back(pane);
@@ -621,6 +722,21 @@ window.__ModuleLoader__.load({
621
722
  return;
622
723
  }
623
724
  if (!open) return;
725
+ if (pane === "model" && showSearch && (event.key === "ArrowDown" || event.key === "ArrowUp")) {
726
+ event.preventDefault();
727
+ if (!busy && visibleModels.length > 0) {
728
+ setHighlightedIndex((activeModelIndex + (event.key === "ArrowDown" ? 1 : -1) + visibleModels.length) % visibleModels.length);
729
+ searchRef.current?.focus();
730
+ }
731
+ return;
732
+ }
733
+ if (pane === "model" && showSearch && event.target instanceof HTMLInputElement && (event.key === "Enter" || event.key === "Tab" && !event.shiftKey)) {
734
+ if (event.key === "Tab" && visibleModels.length === 0) return;
735
+ event.preventDefault();
736
+ const highlighted = visibleModels[activeModelIndex];
737
+ if (!busy && highlighted !== void 0) choose(highlighted);
738
+ return;
739
+ }
624
740
  if (event.key === "Tab") {
625
741
  if (event.shiftKey) {
626
742
  event.preventDefault();
@@ -637,6 +753,11 @@ window.__ModuleLoader__.load({
637
753
  }
638
754
  if (focused !== triggerRef.current) return;
639
755
  event.preventDefault();
756
+ if (pane === "model" && showSearch) {
757
+ setHighlightedIndex(null);
758
+ searchRef.current?.focus();
759
+ return;
760
+ }
640
761
  (menuRef.current?.querySelector("[role=\"menuitemradio\"][aria-checked=\"true\"]:not([disabled])") ?? rows.find((item) => !item.disabled))?.focus();
641
762
  return;
642
763
  }
@@ -652,7 +773,7 @@ window.__ModuleLoader__.load({
652
773
  const settleSelection = (result) => {
653
774
  if (result === void 0) return;
654
775
  if (result.ok) {
655
- if (rootRef.current !== null) close(true);
776
+ if (rootRef.current !== null) closeAfterSelection();
656
777
  return;
657
778
  }
658
779
  const { error } = result;
@@ -664,12 +785,13 @@ window.__ModuleLoader__.load({
664
785
  };
665
786
  const submit = (selection) => {
666
787
  lastActionRef.current = "select";
788
+ setSelectionFocus(true);
667
789
  triggerRef.current?.focus();
668
790
  select(selection).then(settleSelection);
669
791
  };
670
792
  const choose = (selection) => {
671
793
  if (state.current?.provider === selection.provider && state.current.model === selection.model) {
672
- close(true);
794
+ closeAfterSelection();
673
795
  return;
674
796
  }
675
797
  submit(selection);
@@ -677,7 +799,7 @@ window.__ModuleLoader__.load({
677
799
  const chooseEffort = (effort) => {
678
800
  if (state.current === null) return;
679
801
  if (effectiveEffort === effort) {
680
- close(true);
802
+ closeAfterSelection();
681
803
  return;
682
804
  }
683
805
  submit({
@@ -695,6 +817,7 @@ window.__ModuleLoader__.load({
695
817
  });
696
818
  itemRefs.current = [];
697
819
  let itemIndex = 0;
820
+ let modelIndex = 0;
698
821
  const itemRef = () => {
699
822
  const at = itemIndex++;
700
823
  return (node) => {
@@ -720,6 +843,10 @@ window.__ModuleLoader__.load({
720
843
  "aria-controls": open ? `${id}-menu` : void 0,
721
844
  title: triggerLabel,
722
845
  "aria-busy": busy,
846
+ "data-selection-focus": selectionFocus ? "" : void 0,
847
+ onBlur: () => {
848
+ setSelectionFocus(false);
849
+ },
723
850
  disabled: locked,
724
851
  onClick: () => {
725
852
  if (open) close(true);
@@ -746,7 +873,7 @@ window.__ModuleLoader__.load({
746
873
  id: `${id}-menu`,
747
874
  className: ModelSelect_module_css_default.menu,
748
875
  style: menuPos ?? MEASURE_STYLE,
749
- role: "menu",
876
+ role: pane === "model" ? "group" : "menu",
750
877
  "aria-label": t("menu.aria"),
751
878
  "aria-busy": state.status === "loading" || busy,
752
879
  children: [
@@ -790,6 +917,34 @@ window.__ModuleLoader__.load({
790
917
  ]
791
918
  })] }),
792
919
  pane === "model" && (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
920
+ showSearch && (0, react_jsx_runtime.jsxs)("div", {
921
+ className: ModelSelect_module_css_default.searchRow,
922
+ children: [(0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Input, {
923
+ ref: searchRef,
924
+ className: clsx(ModelSelect_module_css_default.search, query !== "" && ModelSelect_module_css_default.searchWithQuery),
925
+ type: "text",
926
+ role: "searchbox",
927
+ "aria-label": t("search.placeholder"),
928
+ "aria-controls": `${id}-models`,
929
+ "aria-activedescendant": activeModelIndex < 0 ? void 0 : `${id}-model-${activeModelIndex}`,
930
+ placeholder: t("search.placeholder"),
931
+ value: query,
932
+ readOnly: busy,
933
+ onChange: (event) => {
934
+ changeQuery(event.target.value);
935
+ }
936
+ }), query !== "" && (0, react_jsx_runtime.jsx)("button", {
937
+ type: "button",
938
+ className: ModelSelect_module_css_default.searchClear,
939
+ "aria-label": t("search.clear"),
940
+ disabled: busy,
941
+ onClick: () => {
942
+ changeQuery("");
943
+ searchRef.current?.focus();
944
+ },
945
+ children: (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconCloseFillRegular, {})
946
+ })]
947
+ }),
793
948
  state.status === "loading" && (0, react_jsx_runtime.jsx)("div", {
794
949
  className: ModelSelect_module_css_default.status,
795
950
  children: t("status.loading")
@@ -816,25 +971,34 @@ window.__ModuleLoader__.load({
816
971
  })]
817
972
  }, failure.id)),
818
973
  (0, react_jsx_runtime.jsx)("div", {
974
+ ref: groupsRef,
975
+ id: `${id}-models`,
819
976
  className: clsx(ModelSelect_module_css_default.groups, "scrollable"),
820
- children: groups.map((group) => {
821
- const headingId = `${id}-${group.id}`;
822
- return (0, react_jsx_runtime.jsxs)("section", {
823
- role: "group",
824
- "aria-labelledby": headingId,
825
- className: ModelSelect_module_css_default.group,
826
- children: [(0, react_jsx_runtime.jsx)("div", {
827
- className: ModelSelect_module_css_default.groupTitle,
828
- id: headingId,
829
- children: group.id === "deepseek-account" ? t("provider.account") : group.name
830
- }), group.models.map((model) => {
977
+ role: "menu",
978
+ "aria-label": t("menu.model"),
979
+ hidden: filteredGroups.length === 0,
980
+ children: filteredGroups.map((group) => {
981
+ return (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.MenuGroup, {
982
+ label: group.id === "deepseek-account" ? t("provider.account") : group.name,
983
+ children: group.models.map((model) => {
984
+ const index = modelIndex++;
831
985
  const selected = state.current?.provider === group.id && state.current.model === model.id;
832
986
  return (0, react_jsx_runtime.jsxs)("button", {
833
987
  ref: itemRef(),
834
988
  type: "button",
835
989
  role: "menuitemradio",
836
990
  "aria-checked": selected,
837
- className: clsx(ModelSelect_module_css_default.option, selected && ModelSelect_module_css_default.selected),
991
+ id: `${id}-model-${index}`,
992
+ tabIndex: showSearch ? -1 : 0,
993
+ onFocus: () => {
994
+ setHighlightedIndex(index);
995
+ },
996
+ "data-highlighted": index === activeModelIndex ? "" : void 0,
997
+ className: clsx(ModelSelect_module_css_default.option, ModelSelect_module_css_default.modelOption, selected && ModelSelect_module_css_default.selected, index === activeModelIndex && ModelSelect_module_css_default.optionActive),
998
+ onMouseMove: busy || index === activeModelIndex ? void 0 : () => {
999
+ if (showSearch) setHighlightedIndex(index);
1000
+ else itemRefs.current[index]?.focus();
1001
+ },
838
1002
  title: model.name,
839
1003
  disabled: busy,
840
1004
  onClick: () => {
@@ -854,13 +1018,14 @@ window.__ModuleLoader__.load({
854
1018
  children: pending?.provider === group.id && pending.model === model.id ? (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.StateDot, { state: "ongoing" }) : selected ? (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconCheckOutlineRegular, {}) : null
855
1019
  })]
856
1020
  }, model.id);
857
- })]
1021
+ })
858
1022
  }, group.id);
859
1023
  })
860
1024
  }),
861
- state.status === "ready" && choices.length === 0 && (0, react_jsx_runtime.jsx)("div", {
1025
+ state.status === "ready" && filteredGroups.length === 0 && (0, react_jsx_runtime.jsx)("div", {
862
1026
  className: ModelSelect_module_css_default.empty,
863
- children: t("empty.models")
1027
+ role: "status",
1028
+ children: t(choices.length === 0 ? "empty.models" : "search.empty")
864
1029
  })
865
1030
  ] }),
866
1031
  pane === "effort" && (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [state.error !== null && lastActionRef.current === "load" && (0, react_jsx_runtime.jsxs)("div", {
@@ -925,8 +1090,6 @@ window.__ModuleLoader__.load({
925
1090
  "command.label": "模型",
926
1091
  "command.description": "选择本会话使用的模型",
927
1092
  "option.loadError": "目录加载失败:{message}",
928
- "option.deepseekV4Flash.description": "快速、高效且经济;适合目标明确、常规或并行任务。",
929
- "option.deepseekV4Pro.description": "更强的自主编码、知识与复杂推理能力;适合复杂或质量优先的任务,但成本更高。",
930
1093
  "trigger.fallback": "请选择模型",
931
1094
  "trigger.loading": "正在加载模型…",
932
1095
  "trigger.selectAria": "请选择模型",
@@ -941,6 +1104,9 @@ window.__ModuleLoader__.load({
941
1104
  "error.sessionInUse": "当前会话已被占用,可能是其他正在运行的 DSH 导致的(如其他 dsh web、桌面端),请退出其他正在运行的 DSH 后重试。",
942
1105
  "action.reload": "重新加载",
943
1106
  "warning.groupLoad": "{name} 加载失败:{message}",
1107
+ "search.placeholder": "搜索模型…",
1108
+ "search.clear": "清除搜索",
1109
+ "search.empty": "没有匹配的模型。",
944
1110
  "empty.models": "没有可用的模型。",
945
1111
  "empty.efforts": "当前模型未提供推理等级。"
946
1112
  };
@@ -950,8 +1116,6 @@ window.__ModuleLoader__.load({
950
1116
  "command.label": "Model",
951
1117
  "command.description": "Select the model for this conversation",
952
1118
  "option.loadError": "Catalog failed to load: {message}",
953
- "option.deepseekV4Flash.description": "Fast, efficient, and economical; suited to focused, routine, or parallel tasks.",
954
- "option.deepseekV4Pro.description": "Stronger agentic coding, knowledge, and difficult reasoning; suited to complex or quality-critical tasks at higher cost.",
955
1119
  "trigger.fallback": "Select model",
956
1120
  "trigger.loading": "Loading models…",
957
1121
  "trigger.selectAria": "Select model",
@@ -966,6 +1130,9 @@ window.__ModuleLoader__.load({
966
1130
  "error.sessionInUse": "This session is already in use, possibly by another running DSH instance (such as dsh web or the desktop app). Quit other running DSH instances and try again.",
967
1131
  "action.reload": "Reload",
968
1132
  "warning.groupLoad": "{name} failed to load: {message}",
1133
+ "search.placeholder": "Search models…",
1134
+ "search.clear": "Clear search",
1135
+ "search.empty": "No matching models.",
969
1136
  "empty.models": "No models available.",
970
1137
  "empty.efforts": "This model provides no reasoning effort levels."
971
1138
  };
@@ -975,30 +1142,20 @@ window.__ModuleLoader__.load({
975
1142
  function rowId(providerId, modelId) {
976
1143
  return `${providerId}/${modelId}`;
977
1144
  }
978
- const BUILTIN_DESCRIPTION_KEYS = {
979
- "deepseek-account/deepseek-v4-flash": "option.deepseekV4Flash.description",
980
- "deepseek-account/deepseek-v4-pro": "option.deepseekV4Pro.description",
981
- "deepseek-official/deepseek-v4-flash": "option.deepseekV4Flash.description",
982
- "deepseek-official/deepseek-v4-pro": "option.deepseekV4Pro.description"
983
- };
984
- function descriptionOf(providerId, model, t) {
985
- const key = BUILTIN_DESCRIPTION_KEYS[rowId(providerId, model.id)];
986
- return key !== void 0 && model.description === en[key] ? t(key) : model.description;
987
- }
988
1145
  /** Flatten the directory into popup rows; failure rows are listed for visibility but never selectable. */
989
1146
  function optionsOf(directory, t) {
990
1147
  const rows = [];
991
- for (const group of directory.groups) {
1148
+ for (const group of orderModelProviders(directory.groups)) {
992
1149
  const name = group.id === "deepseek-account" ? t("provider.account") : group.name;
993
- for (const model of group.models) {
994
- const description = descriptionOf(group.id, model, t);
995
- rows.push({
996
- id: rowId(group.id, model.id),
997
- label: model.name,
998
- detail: description !== void 0 ? `${name} · ${description}` : name,
999
- ...directory.current !== null && directory.current.provider === group.id && directory.current.model === model.id ? { active: true } : {}
1000
- });
1001
- }
1150
+ for (const model of group.models) rows.push({
1151
+ id: rowId(group.id, model.id),
1152
+ label: model.name,
1153
+ group: {
1154
+ name: group.id,
1155
+ label: name
1156
+ },
1157
+ ...directory.current !== null && directory.current.provider === group.id && directory.current.model === model.id ? { active: true } : {}
1158
+ });
1002
1159
  }
1003
1160
  for (const failure of directory.failures) rows.push({
1004
1161
  id: `failure/${failure.id}`,
@@ -1061,6 +1218,12 @@ window.__ModuleLoader__.load({
1061
1218
  available: (session) => sessions.subagentAddress(session.sessionId) === void 0,
1062
1219
  ui: {
1063
1220
  kind: "popupSelect",
1221
+ searchMode: "fuzzy-label",
1222
+ searchLabels: () => ({
1223
+ placeholder: t("search.placeholder"),
1224
+ empty: t("empty.models"),
1225
+ noResults: t("search.empty")
1226
+ }),
1064
1227
  options: async (session) => {
1065
1228
  if (sessions.subagentAddress(session.sessionId) !== void 0) throw new Error("model selection is unavailable for addressed subagent sessions");
1066
1229
  return optionsOf(await models.directoryFor(session.sessionId).load(), t);
@@ -4,6 +4,7 @@
4
4
  * Session's durable selection projection, then submit through the same
5
5
  * selectModel call. A switch made in either entry updates this shared state.
6
6
  */
7
+ import type { TrackProductEvent } from '@deepseek-ai/dsh-client-product-analytics/client';
7
8
  import type { ModelCatalogFailure, ModelProviderGroup, ModelSelection } from '@deepseek-ai/dsh-api-session-controller/types';
8
9
  import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client';
9
10
  import type { RemoteResult, TypertClientRemote } from '@deepseek-ai/dsh-typert-protocol';
@@ -35,6 +36,8 @@ export declare class ModelDirectory {
35
36
  private readonly available;
36
37
  private readonly catalog;
37
38
  private readonly projected;
39
+ private readonly isBlank;
40
+ private readonly track?;
38
41
  /** The shared snapshot both entries render from (uSES-safe store). */
39
42
  readonly store: SnapshotStore<ModelDirectoryState>;
40
43
  /** Latest selection operation wins; an older response never overwrites a newer one. */
@@ -48,8 +51,10 @@ export declare class ModelDirectory {
48
51
  * @param available - whether this session may use Agent-bound model RPCs.
49
52
  * @param catalog - Host-generation catalog shared by every Session.
50
53
  * @param projected - durable model selection projected from Session history.
54
+ * @param isBlank - whether this Session has no first message yet.
55
+ * @param track - desktop-only callback after a successful user selection.
51
56
  */
52
- constructor(sessions: Pick<TypertClientRemote['session'], 'selectModel'>, sessionId: SessionId, available: () => boolean, catalog: ModelCatalogDirectory, projected: ObservableSnapshot<unknown>);
57
+ constructor(sessions: Pick<TypertClientRemote['session'], 'selectModel'>, sessionId: SessionId, available: () => boolean, catalog: ModelCatalogDirectory, projected: ObservableSnapshot<unknown>, isBlank: () => boolean, track?: TrackProductEvent | undefined);
53
58
  /**
54
59
  * Ensure the Host generation's shared available catalog is loaded.
55
60
  * @returns the fresh directory value.
@@ -13,8 +13,6 @@ export declare const zh: {
13
13
  'command.label': string;
14
14
  'command.description': string;
15
15
  'option.loadError': string;
16
- 'option.deepseekV4Flash.description': string;
17
- 'option.deepseekV4Pro.description': string;
18
16
  'trigger.fallback': string;
19
17
  'trigger.loading': string;
20
18
  'trigger.selectAria': string;
@@ -29,6 +27,9 @@ export declare const zh: {
29
27
  'error.sessionInUse': string;
30
28
  'action.reload': string;
31
29
  'warning.groupLoad': string;
30
+ 'search.placeholder': string;
31
+ 'search.clear': string;
32
+ 'search.empty': string;
32
33
  'empty.models': string;
33
34
  'empty.efforts': string;
34
35
  };
@@ -40,8 +41,6 @@ export declare const en: {
40
41
  'command.label': string;
41
42
  'command.description': string;
42
43
  'option.loadError': string;
43
- 'option.deepseekV4Flash.description': string;
44
- 'option.deepseekV4Pro.description': string;
45
44
  'trigger.fallback': string;
46
45
  'trigger.loading': string;
47
46
  'trigger.selectAria': string;
@@ -56,6 +55,9 @@ export declare const en: {
56
55
  'error.sessionInUse': string;
57
56
  'action.reload': string;
58
57
  'warning.groupLoad': string;
58
+ 'search.placeholder': string;
59
+ 'search.clear': string;
60
+ 'search.empty': string;
59
61
  'empty.models': string;
60
62
  'empty.efforts': string;
61
63
  };
@@ -0,0 +1,10 @@
1
+ /** Shared provider display order for the composer and command model pickers. */
2
+ /**
3
+ * Put the account and official providers first, preserving every other relative order.
4
+ * @param groups - Provider groups in catalog order.
5
+ * @returns a sorted copy; model order within each group is unchanged.
6
+ */
7
+ export declare function orderModelProviders<T extends {
8
+ readonly id: string;
9
+ }>(groups: readonly T[]): T[];
10
+ //# sourceMappingURL=provider-order.d.ts.map
@@ -1,17 +1,3 @@
1
- /**
2
- * ModelDirectoryResolver (`ctx.modelDirectories`): the root owner of per-session
3
- * {@link ModelDirectory} instances. Both selection entries (the /model popup
4
- * and the composer model seat) resolve their session's directory through
5
- * this service, which is what makes the dual entry one shared state.
6
- *
7
- * Per-session storage follows the client service pattern (InputTriggerService /
8
- * CommandUiRuntime): a lazy service-internal map whose entry is deleted by the
9
- * owning scope's disposer. The host `dsh-scope` ScopedLayers registry does
10
- * does not belong here: it derives scope from the host carrier mechanism
11
- * (object-keyed), while client scopes tag contexts with branded SessionId
12
- * strings, and it models global+shadow named registries — this is a
13
- * per-session singleton with no global layer to merge.
14
- */
15
1
  import { Service } from '@deepseek-ai/cordis';
16
2
  import type { Context } from '@deepseek-ai/cordis';
17
3
  import type { SessionId } from '@deepseek-ai/dsh-session/types';
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@deepseek-ai/dsh-client-ui-model-selection",
3
3
  "description": "Model selection over the shared model catalog, Session projection, and session.selectModel",
4
- "version": "0.1.7-rc.2",
4
+ "version": "0.2.0-rc.2",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -46,22 +46,23 @@
46
46
  "react-dom": "^18.2.0",
47
47
  "@types/react-dom": "~18.3.0",
48
48
  "clsx": "^2.1.1",
49
- "@deepseek-ai/dsh-api-remotes": "0.1.7-rc.2",
50
- "@deepseek-ai/dsh-api-session-controller": "0.1.7-rc.2",
51
- "@deepseek-ai/dsh-client-locale": "0.1.7-rc.2",
52
- "@deepseek-ai/dsh-client-test-runtime": "0.1.7-rc.2",
53
- "@deepseek-ai/dsh-client-ui-commands": "0.1.7-rc.2",
54
- "@deepseek-ai/dsh-client-ui-conversation": "0.1.7-rc.2",
55
- "@deepseek-ai/dsh-client-ui-input-trigger": "0.1.7-rc.2",
56
- "@deepseek-ai/dsh-client-ui-primitives": "0.1.7-rc.2",
57
- "@deepseek-ai/dsh-client-ui-slots": "0.1.7-rc.2",
58
- "@deepseek-ai/dsh-typert-protocol": "0.1.7-rc.2",
49
+ "@deepseek-ai/dsh-api-session-controller": "0.2.0-rc.2",
50
+ "@deepseek-ai/dsh-client-locale": "0.2.0-rc.2",
51
+ "@deepseek-ai/dsh-api-remotes": "0.2.0-rc.2",
52
+ "@deepseek-ai/dsh-client-ui-commands": "0.2.0-rc.2",
53
+ "@deepseek-ai/dsh-client-test-runtime": "0.2.0-rc.2",
54
+ "@deepseek-ai/dsh-client-ui-input-trigger": "0.2.0-rc.2",
55
+ "@deepseek-ai/dsh-client-ui-slots": "0.2.0-rc.2",
56
+ "@deepseek-ai/dsh-typert-protocol": "0.2.0-rc.2",
59
57
  "@deepseek-ai/cordis": "~4.0.4",
60
- "@deepseek-ai/dsh-client-store": "0.1.7-rc.2",
61
- "@deepseek-ai/dsh-session": "0.1.7-rc.2",
62
- "@deepseek-ai/dsh-util-values": "0.1.7-rc.2",
63
- "@deepseek-ai/dsh-client-ui-renderer": "0.1.7-rc.2",
64
- "@deepseek-ai/dsh-client-ui-session": "0.1.7-rc.2"
58
+ "@deepseek-ai/dsh-client-store": "0.2.0-rc.2",
59
+ "@deepseek-ai/dsh-client-ui-primitives": "0.2.0-rc.2",
60
+ "@deepseek-ai/dsh-client-ui-conversation": "0.2.0-rc.2",
61
+ "@deepseek-ai/dsh-session": "0.2.0-rc.2",
62
+ "@deepseek-ai/dsh-util-values": "0.2.0-rc.2",
63
+ "@deepseek-ai/dsh-client-ui-renderer": "0.2.0-rc.2",
64
+ "@deepseek-ai/dsh-client-ui-session": "0.2.0-rc.2",
65
+ "@deepseek-ai/dsh-client-product-analytics": "0.2.0-rc.2"
65
66
  },
66
67
  "files": [
67
68
  "lib/index.js",