dsh-model-accordion 0.1.1 → 0.2.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.
Files changed (3) hide show
  1. package/README.md +42 -6
  2. package/lib/client.js +208 -33
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -6,9 +6,8 @@ Provider-folded model selector for the DeepSeek Harness Web composer.
6
6
 
7
7
  - Replaces the single composer model seat with a provider-folded selector.
8
8
  - Keeps provider groups collapsed by default and opens models on demand.
9
- - Reads reasoning-effort choices from each model's resolved catalog metadata instead of maintaining a hard-coded effort list.
10
- - Sends model and reasoning-effort selections through DSH's shared model-directory service.
11
- - Wraps vision-router mirror providers in one collapsed `识图镜像(带图会话)` section when those providers are present.
9
+ - Reads reasoning-effort choices from each model's resolved catalog metadata, with a built-in official-effort table as a fallback. A single automatic rule applies: a model with configured/backend-declared efforts uses them, and an unconfigured model falls back to the built-in official table. A user who configured their own levels keeps them; no per-model effort configuration is needed for the official fallback to work.
10
+ - Sends model and reasoning-effort selections through DSH's shared model-directory service; a picked effort the model does not genuinely support is dropped so the request uses the model's default instead of failing with `UNSUPPORTED_REASONING_EFFORT`.
12
11
  - Keeps the popup height-bounded with an internal scroll area and wraps effort controls below the model name.
13
12
  - Uses DSH semantic theme tokens for colors and supports light/dark theme changes.
14
13
 
@@ -34,12 +33,49 @@ dsh plugin --profile web add file:/absolute/path/to/dsh-model-accordion
34
33
 
35
34
  Refresh the Web UI after installation. The package declares a `dsh.bundle` patch and a Web client entry, so it is installed and loaded as a persistent profile bundle rather than a temporary runtime extension.
36
35
 
36
+ ## Customizing reasoning effort for a model
37
+
38
+ DSH persists model/provider configuration in your main `settings.yaml`, and this
39
+ plugin reads the same resolved model metadata the backend produces, so
40
+ **declared/backend effort configuration always wins over the built-in table**.
41
+ The effective priority is:
42
+
43
+ model's configured `reasoningEfforts` > provider-level default > built-in official table
44
+
45
+ To give a specific model — for one your relay exposes under a name this plugin's
46
+ table does not know — your own effort ladder, declare it on that model in
47
+ `settings.yaml` and the plugin uses exactly those levels:
48
+
49
+ providers:
50
+ my-relay:
51
+ api: openai-completions
52
+ baseURL: https://…
53
+ models:
54
+ - id: my-unknown-model
55
+ api: openai-completions
56
+ contextWindow: 200000
57
+ maxTokens: 32000
58
+ input: [text]
59
+ reasoningEfforts:
60
+ off: null # optional: lets the user turn thinking off
61
+ low: low
62
+ high: high
63
+ max: max
64
+
65
+ The exact fields follow `dsh-llm-pi-ai`'s model schema (see that package's
66
+ docs); `reasoningEfforts` maps each offered level to its wire spelling, and you
67
+ may set a provider-level default with the `reasoning:` key. Because this lives
68
+ in your DSH settings file, an agent — or you — can edit it with the file tools
69
+ and DSH reloads it on save; the browser never sees your API key or relay URL.
70
+
37
71
  ## Behavior and limitations
38
72
 
39
- - The selector submits the exact provider, model, and catalog-declared reasoning effort returned by the DSH model directory.
73
+ The selector submits the exact provider, model, and reasoning effort returned by the DSH model directory (configured efforts first, built-in official table as fallback).
74
+ - A picked reasoning effort that the model does not support is silently dropped so the request falls back to the model's default (no `UNSUPPORTED_REASONING_EFFORT` error). A relay/gateway model configured without an effort level keeps the model but does not force an unsupported level.
40
75
  - A selection can still be rejected by the DSH host when the session contains images and the selected model does not declare image input. That is a host/model-capability rule, not a UI override.
41
- - The optional `识图镜像(带图会话)` section is shown when provider names identify vision-router mirror routes. It is kept collapsed to avoid duplicating every provider in the main list.
42
- - The plugin currently uses English fallback status text and a Chinese label for the vision-mirror section; full locale integration is planned for a later release.
76
+ - The model search box filters provider lists by name, id, or description while keeping the accordion grouping; a provider group auto-expands while a query is active.
77
+ - The built-in official-effort table keys on the model's name/id and is cross-checked against the installed pi-ai catalog and each vendor's API docs. It covers only models whose vendors expose a real effort ladder: GPT-5.6 (full ladder incl. `max`), gpt-5.2+ (`xhigh`), gpt-5.1, gpt-5/mini/nano, o1/o3/o4, gpt-oss, Grok 4.x, GLM-5.3 (`low/high/max`) and GLM-5.2+ (`xhigh/max`), DeepSeek-V4 (`low/high/max`), Kimi K3, Qwen3.8, Gemini 3+ (`low/high`), Claude 4.6+ adaptive, Gemma 4. Families whose native control is not an effort ladder (Gemini 2.5 / Claude ≤4.5 token budgets, GLM ≤5.1 / Kimi K2 / MiniMax / Mistral thinking switches, Qwen open-source, DeepSeek ≤V3) are deliberately excluded. Unmatched models offer only the efforts their configured catalog declares — see "Customizing reasoning effort for a model" to add your own.
78
+ - The plugin uses English status and search text; full locale integration is planned for a later release.
43
79
 
44
80
  ## Development checks
45
81
 
package/lib/client.js CHANGED
@@ -25,7 +25,6 @@ window.__ModuleLoader__.load({
25
25
  .dshma-status,.dshma-empty{color:var(--dsw-alias-label-tertiary);padding:10px;font-size:13px;line-height:20px}
26
26
  .dshma-error{background:var(--dsw-alias-interactive-bg-hover-danger);color:var(--dsw-alias-state-error-primary);border-radius:8px;margin-bottom:4px;padding:7px 8px;font-size:12px;line-height:18px}
27
27
  .dshma-provider{border-radius:10px;overflow:hidden;flex:none}
28
- .dshma-vision{margin-top:4px;border-top:1px solid var(--dsw-alias-border-l3);padding-top:4px}
29
28
  .dshma-provider-head{box-sizing:border-box;width:100%;display:flex;align-items:center;gap:7px;padding:8px;border:none;border-radius:10px;background:transparent;color:var(--dsw-alias-label-secondary);font-size:12px;font-weight:600;line-height:18px;text-align:left;cursor:pointer;font-family:inherit}
30
29
  .dshma-provider-head:hover{background:var(--dsw-alias-interactive-bg-hover)}
31
30
  .dshma-provider-name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
@@ -46,6 +45,11 @@ window.__ModuleLoader__.load({
46
45
  .dshma-effort:hover{color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-interactive-bg-hover)}
47
46
  .dshma-effort-selected{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-secondary)}
48
47
  .dshma-failure{color:var(--dsw-alias-label-tertiary);border-radius:8px;padding:7px 8px;font-size:12px;line-height:18px}
48
+ .dshma-search-wrap{flex:none;padding:4px;position:relative}
49
+ .dshma-search{box-sizing:border-box;width:100%;height:28px;padding:0 28px 0 10px;border:1px solid var(--dsw-alias-border-l3);border-radius:8px;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-primary);font-size:13px;line-height:20px;outline:none;font-family:inherit}
50
+ .dshma-search:focus-visible{border-color:var(--dsw-alias-border-l3);box-shadow:0 0 0 2px var(--dsw-alias-border-l3)}
51
+ .dshma-search-clear{position:absolute;top:50%;right:7px;transform:translateY(-50%);width:18px;height:18px;border:none;border-radius:50%;background:transparent;color:var(--dsw-alias-label-tertiary);cursor:pointer;display:flex;align-items:center;justify-content:center;padding:0;font-size:13px;line-height:1}
52
+ .dshma-search-clear:hover{color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-interactive-bg-hover)}
49
53
  `;
50
54
 
51
55
  function Chevron(props) {
@@ -62,9 +66,108 @@ window.__ModuleLoader__.load({
62
66
  );
63
67
  }
64
68
 
69
+ //#region Official reasoning-effort divisions
70
+ // Built-in, provider-independent reasoning-effort divisions keyed on the
71
+ // model's name/id. This is what lets the selector offer sensible effort
72
+ // choices even when a model's configured catalog (or a third-party relay /
73
+ // gateway) declares no `reasoningEfforts` — the common npm-install case.
74
+ // The divisions below are sourced from each vendor's public API docs, cross-
75
+ // checked against the installed pi-ai catalog (providers/data/*.json) and the
76
+ // vendor docs listed in the plugin changelog. Levels are the canonical pi-ai
77
+ // ids (off/minimal/low/medium/high/xhigh/max — see THINKING_LEVELS in
78
+ // dsh-llm-pi-ai); `default` is the official default and is used only when the
79
+ // backend leaves it unset.
80
+ //
81
+ // IMPORTANT — only models whose vendors expose a real reasoning-effort ladder
82
+ // are listed. Families whose native control is NOT an effort ladder (Claude
83
+ // ≤4.5 & Gemini 2.5 token budgets, GLM ≤5.1 / Kimi K2 / MiniMax / Mistral
84
+ // two-state thinking, Qwen open-source enable_thinking, DeepSeek ≤V3 / R1) are
85
+ // deliberately excluded so we never surface a ladder the API would reject.
86
+ // Official-family models are normally covered by the installed pi-ai catalog
87
+ // (backend first layer) anyway; this table only backs up manually-declared /
88
+ // relay models the backend leaves unconfigured.
89
+ //
90
+ // Entries are checked top-down; the first match wins.
91
+ // gpt-5.6 (Sol/Terra/Luna): full ladder incl. max (default high ⚠ TBD)
92
+ // gpt-5.2+ / 5.3-codex: low/medium/high/xhigh (default high ⚠ TBD)
93
+ // gpt-5.1: minimal/low/medium/high (default low)
94
+ // gpt-5 / mini / nano: minimal/low/medium/high (default medium)
95
+ // o1/o3/o4 families: low/medium/high (default medium)
96
+ // gpt-oss: low/medium/high (default medium)
97
+ // grok 4.x (responses): low/medium/high (default high)
98
+ // glm-5.3 / 5.3-FLASH: low/high/max (thinking un-offable)(default max)
99
+ // glm-5.2+: low/medium/high/xhigh/max (default max)
100
+ // deepseek-v4: low/high/max (default high)
101
+ // kimi-k3: low/high/max (default max)
102
+ // qwen3.8: xhigh/medium/low (default xhigh)
103
+ // gemini-3+ (Pro/Flash): low/high (always on) (default high)
104
+ // claude 4.6+ adaptive: low/medium/high (default high)
105
+ // gemma-4: minimal/high (default high)
106
+ const OFFICIAL_EFFORTS = [
107
+ { test: /gpt-5\.6/i, efforts: ["minimal", "low", "medium", "high", "xhigh", "max"], def: "high" },
108
+ { test: /gpt-5\.2[^0-9]|gpt-5\.[3-9]|gpt-5\.2\b/i, efforts: ["low", "medium", "high", "xhigh"], def: "high" },
109
+ { test: /gpt-5\.1/i, efforts: ["minimal", "low", "medium", "high"], def: "low" },
110
+ { test: /gpt-5(?:-mini|-nano)?\b/i, efforts: ["minimal", "low", "medium", "high"], def: "medium" },
111
+ { test: /o[134](-mini|-pro)?\b/i, efforts: ["low", "medium", "high"], def: "medium" },
112
+ { test: /gpt-oss/i, efforts: ["low", "medium", "high"], def: "medium" },
113
+ { test: /grok/i, efforts: ["low", "medium", "high"], def: "high" },
114
+ { test: /glm-5\.3/i, efforts: ["low", "high", "max"], def: "max" },
115
+ { test: /glm-5(?:\.[2-9]|\.\d+)/i, efforts: ["low", "medium", "high", "xhigh", "max"], def: "max" },
116
+ { test: /deepseek-v4/i, efforts: ["low", "high", "max"], def: "high" },
117
+ { test: /kimi-k3/i, efforts: ["low", "high", "max"], def: "max" },
118
+ { test: /qwen3\.8/i, efforts: ["xhigh", "medium", "low"], def: "xhigh" },
119
+ { test: /gemini-3/i, efforts: ["low", "high"], def: "high" },
120
+ { test: /claude-(opus|sonnet|haiku|fable)-(4\.[6-9]|5)\b/i, efforts: ["low", "medium", "high"], def: "high" },
121
+ { test: /gemma-4/i, efforts: ["minimal", "high"], def: "high" }
122
+ ];
123
+
124
+ function officialEffortsFor(model) {
125
+ if (!model) return void 0;
126
+ const hay = `${model.name || ""} ${model.id || ""}`;
127
+ for (const entry of OFFICIAL_EFFORTS) {
128
+ if (entry.test.test(hay)) return entry;
129
+ }
130
+ return void 0;
131
+ }
132
+
133
+ // Reason offered for a model: the backend/configured-catalog efforts when the
134
+ // model has them, otherwise the built-in official table as a fallback. This
135
+ // single "auto" rule is all there is — a user who configured their own levels
136
+ // keeps them, and an unconfigured model still gets sensible official choices.
137
+ function resolveEfforts(model) {
138
+ const backend = model && model.reasoning && Array.isArray(model.reasoning.efforts)
139
+ ? model.reasoning.efforts.filter((e) => e && typeof e.id === "string")
140
+ : [];
141
+ if (backend.length > 0) return backend;
142
+ const official = officialEffortsFor(model);
143
+ if (!official) return [];
144
+ return official.efforts.map((id) => ({ id, name: `${id.charAt(0).toUpperCase()}${id.slice(1)}` }));
145
+ }
146
+ // The default effort id to surface for a model (trigger caption + default
147
+ // chip highlight): the backend-declared default wins, and the official table's
148
+ // default is used only when the backend left it unset.
149
+ function resolveDefaultEffort(model) {
150
+ const backendDefault = model && model.reasoning && model.reasoning.defaultEffort != null
151
+ ? model.reasoning.defaultEffort
152
+ : void 0;
153
+ if (backendDefault !== void 0) return backendDefault;
154
+ const official = officialEffortsFor(model);
155
+ return official ? official.def : void 0;
156
+ }
157
+ // Whether an effort id is genuinely supported by the model — used by the
158
+ // submit path to decide whether to send reasoningEffort.
159
+ function effortSupported(model, id) {
160
+ return resolveEfforts(model).some((e) => e.id === id);
161
+ }
162
+
65
163
  function effortOptions(model) {
66
- const es = model && model.reasoning && Array.isArray(model.reasoning.efforts) ? model.reasoning.efforts : [];
67
- return es.filter((e) => e && typeof e.id === "string");
164
+ return resolveEfforts(model);
165
+ }
166
+
167
+ function effortName(model, id) {
168
+ const es = resolveEfforts(model);
169
+ const hit = es.find((e) => e.id === id);
170
+ return hit && hit.name ? hit.name : id;
68
171
  }
69
172
 
70
173
  function Accordion(props) {
@@ -75,14 +178,26 @@ window.__ModuleLoader__.load({
75
178
  const [failure, setFailure] = React.useState(null);
76
179
  const groups = Array.isArray(state.groups) ? state.groups : [];
77
180
  const rootRef = React.useRef(null);
78
- const [visionOpen, setVisionOpen] = React.useState(false);
79
- // Vision-router twin detection: only treat a group as the auto-vision
80
- // wrapper when its display name carries the 自动识图 token or its provider
81
- // id ends in "-vision" (official vision-router uses route id
82
- // "deepseek-vision" + display "DeepSeek + 自动识图").
83
- const isTwin = (g) => /自动识图/.test(g.name || "") || /(?:-|\b)vision$/i.test((g.id || "").trim());
84
- const mainGroups = groups.filter((g) => !isTwin(g));
85
- const visionGroups = groups.filter(isTwin);
181
+ const [query, setQuery] = React.useState("");
182
+ // Roving focus registry: every visible model-row button registers itself here
183
+ // (keyed by group+model), so arrow/Home/End navigate in accordion order.
184
+ const itemKeys = React.useRef({});
185
+ const registerItem = (key, el) => { if (el) itemKeys.current[key] = el; else delete itemKeys.current[key]; };
186
+ const searchRef = React.useRef(null);
187
+
188
+ // When the menu opens with NO provider expanded yet, surface the provider
189
+ // holding the current model so a fresh open is not a blank, fully-collapsed
190
+ // list. Crucially this only fires while `expanded` is empty: re-running it on
191
+ // every open (selecting a model closes the menu, so compare-testing reopens
192
+ // repeatedly) would otherwise accumulate every tested provider into
193
+ // `expanded`, leaving them all open once the search filter is cleared. When
194
+ // the user has already opened providers (browse state) we leave them alone.
195
+ React.useEffect(() => {
196
+ if (!open) return;
197
+ if (Object.values(expanded).some(Boolean)) return;
198
+ const g = groups.find((gg) => gg.id === state.current?.provider);
199
+ if (g) setExpanded((old) => ({ ...old, [g.id]: true }));
200
+ }, [open]);
86
201
 
87
202
  React.useEffect(() => {
88
203
  if (!open) return;
@@ -93,6 +208,11 @@ window.__ModuleLoader__.load({
93
208
  return () => { document.removeEventListener("mousedown", onDown); document.removeEventListener("keydown", onKey); };
94
209
  }, [open]);
95
210
 
211
+ // Populate store.current as soon as the model slot becomes available
212
+ // (mount/startup), so the trigger shows the last-used model instead of
213
+ // falling back to "Select model" until the user opens the menu.
214
+ React.useEffect(() => { if (props.available) props.load(); }, [props.available, props.load]);
215
+ // Refresh the store whenever the menu is opened (mirrors official reload).
96
216
  React.useEffect(() => { if (open && props.available) props.load(); }, [open, props.available]);
97
217
 
98
218
  const select = (group, model, effort) => {
@@ -100,7 +220,14 @@ window.__ModuleLoader__.load({
100
220
  setBusy(true);
101
221
  setFailure(null);
102
222
  const sel = { provider: group.id, model: model.id };
103
- if (effort !== void 0) sel.reasoningEffort = effort;
223
+ // Submit-degradation: only send reasoningEffort when the picked level is
224
+ // one the model genuinely supports. When the backend catalog declares
225
+ // efforts, an "official" level it does not list is dropped so the request
226
+ // falls back to the model's default instead of failing with
227
+ // UNSUPPORTED_REASONING_EFFORT. NOTE: when the backend declares no efforts
228
+ // at all the official table is the sole authority, so any official level is
229
+ // sent as-is — an under-configured relay may still reject it.
230
+ if (effort !== void 0 && effortSupported(model, effort)) sel.reasoningEffort = effort;
104
231
  props.select(sel).then((ok) => {
105
232
  if (!ok) throw new Error("model selection failed");
106
233
  setOpen(false);
@@ -113,12 +240,41 @@ window.__ModuleLoader__.load({
113
240
  const curGroup = groups.find((g) => g.id === current?.provider);
114
241
  const curModel = curGroup ? curGroup.models.find((m) => m.id === current?.model) : void 0;
115
242
  const label = curModel ? curModel.name : (current ? `${current.provider}/${current.model}` : "Select model");
116
- const curEffort = curModel && current && current.provider === curGroup.id && current.model === curModel.id
117
- ? (current.reasoningEffort || (curModel.reasoning && curModel.reasoning.defaultEffort))
243
+ // Show a effort` caption ONLY when a reasoning effort is actually pinned
244
+ // on the current selection (persisted in `current.reasoningEffort`). The
245
+ // official default is deliberately NOT surfaced here: showing it before the
246
+ // user pins anything reads as "already set", which is misleading.
247
+ const curEffort = curModel && current && current.provider === curGroup.id && current.model === curModel.id && current.reasoningEffort
248
+ ? effortName(curModel, current.reasoningEffort)
118
249
  : void 0;
119
250
 
251
+ // Model search: filter each provider's models by the query while keeping
252
+ // the accordion grouping structure. Matches on name, id, and description.
253
+ const q = query.trim().toLowerCase();
254
+ // When the filter is cleared (✕ / Escape / manual delete), collapse the
255
+ // accordion to the provider holding the CURRENT model. Selecting a model
256
+ // never writes `expanded`, so without this the menu would fall back to
257
+ // whatever was expanded before the search — not the model you just picked.
258
+ // Because selection does not accumulate into `expanded`, this stays focused
259
+ // on one group and never re-introduces the "everything open" problem.
260
+ const prevQuery = React.useRef("");
261
+ React.useEffect(() => {
262
+ const prev = prevQuery.current;
263
+ prevQuery.current = q;
264
+ if (q === "" && prev !== "") {
265
+ const cur = state.current;
266
+ setExpanded(cur && groups.some((g) => g.id === cur.provider) ? { [cur.provider]: true } : {});
267
+ }
268
+ }, [q]);
269
+ const matches = (m) => q === "" || [m.name, m.id, m.description].some((s) => typeof s === "string" && s.toLowerCase().includes(q));
270
+ const visibleGroups = q === ""
271
+ ? groups
272
+ : groups.map((g) => ({ ...g, models: g.models.filter(matches) })).filter((g) => g.models.length > 0);
273
+ const hasAnyModel = visibleGroups.some((g) => g.models.length > 0);
274
+
120
275
  const renderGroup = (group) => {
121
- const isOpen = expanded[group.id] === true;
276
+ const isOpen = expanded[group.id] === true || q !== "";
277
+ if (!group.models.length) return null;
122
278
  return React.createElement("div", { className: "dshma-provider", key: group.id },
123
279
  React.createElement("button", { type: "button", className: "dshma-provider-head", onClick: () => setExpanded((old) => ({ ...old, [group.id]: !isOpen })), "aria-expanded": isOpen },
124
280
  React.createElement(Chevron, { className: `dshma-provider-chevron${isOpen ? " dshma-provider-chevron-open" : ""}` }),
@@ -128,17 +284,16 @@ window.__ModuleLoader__.load({
128
284
  isOpen ? React.createElement("div", { className: "dshma-models" }, group.models.map((model) => {
129
285
  const selected = current && current.provider === group.id && current.model === model.id;
130
286
  const efforts = effortOptions(model);
131
- const defEffort = model.reasoning && model.reasoning.defaultEffort;
132
- // A "provider default" chip clears a pinned effort so the provider's
133
- // built-in default applies again (mirrors the official provider-default
134
- // entry in dsh-client-ui-model-selection @315-324).
287
+ const defEffort = resolveDefaultEffort(model);
288
+ // A "default" chip clears a pinned effort so the model's effective
289
+ // default applies again (mirrors the official provider-default entry
290
+ // in dsh-client-ui-model-selection @315-324). It appears only when a
291
+ // default effort is known (backend-declared or official-table).
135
292
  const defaultChip = defEffort !== void 0 ? [{ id: "__provider_default__", name: "默认" }] : [];
136
293
  const chips = defaultChip.concat(efforts);
137
- // reason about the currently active effort: a pinned explicit effort
138
- // wins over the provider's default; otherwise provider default applies.
139
294
  const pinnedEffort = selected ? current.reasoningEffort : void 0;
140
295
  return React.createElement("div", { className: `dshma-option${selected ? " dshma-option-selected" : ""}`, key: model.id },
141
- React.createElement("button", { type: "button", className: "dshma-option-row", onClick: () => {
296
+ React.createElement("button", { type: "button", className: "dshma-option-row", ref: (el) => registerItem(`${group.id}\u0000${model.id}`, el), onClick: () => {
142
297
  // Re-clicking the already-selected model must NOT re-submit and
143
298
  // thereby wipe a custom (pinned) effort — just close the menu.
144
299
  // Mirrors dsh-client-ui-model-selection @397-400 / @718.
@@ -157,28 +312,48 @@ window.__ModuleLoader__.load({
157
312
  );
158
313
  };
159
314
 
315
+ // Roving arrow-key navigation across the rendered model rows (mirrors the
316
+ // official ModelSelect moveFocus). Enter/Space activate the focused row
317
+ // natively; Arrow keys / Home / End move between rows. ArrowDown from the
318
+ // search field lands on the first row.
319
+ const onMenuKey = (e) => {
320
+ // Escape: first press with text clears the search (keeping focus), second
321
+ // press closes. stopPropagation prevents the document-level handler from
322
+ // closing the menu while we are only clearing.
323
+ if (e.key === "Escape" && query !== "") {
324
+ e.preventDefault(); e.stopPropagation(); setQuery(""); searchRef.current && searchRef.current.focus(); return;
325
+ }
326
+ const els = Object.values(itemKeys.current)
327
+ .filter((el) => el && el.offsetParent !== null);
328
+ if (!els.length) return;
329
+ const idx = els.indexOf(document.activeElement);
330
+ let next = null;
331
+ if (e.key === "ArrowDown") { e.preventDefault(); next = idx === -1 ? els[0] : els[(idx + 1) % els.length]; }
332
+ else if (e.key === "ArrowUp") { e.preventDefault(); next = idx === -1 ? els[els.length - 1] : els[(idx - 1 + els.length) % els.length]; }
333
+ else if (e.key === "Home") { e.preventDefault(); next = els[0]; }
334
+ else if (e.key === "End") { e.preventDefault(); next = els[els.length - 1]; }
335
+ if (next) next.focus();
336
+ };
337
+
160
338
  return React.createElement("div", { className: "dshma-root", ref: rootRef },
161
339
  React.createElement("button", { type: "button", className: "dshma-trigger", disabled: props.locked || !props.available || busy, onClick: () => setOpen((v) => !v), "aria-expanded": open, "aria-haspopup": "menu" },
162
340
  React.createElement("span", { className: "dshma-trigger-label" }, label),
163
341
  curEffort ? React.createElement("span", { className: "dshma-trigger-effort" }, `· ${curEffort}`) : null,
164
342
  React.createElement(Chevron, { className: `dshma-chevron${open ? " dshma-chevron-open" : ""}` })
165
343
  ),
166
- open ? React.createElement("div", { className: "dshma-menu", role: "menu" },
344
+ open ? React.createElement("div", { className: "dshma-menu", role: "menu", "aria-label": "Model selector", onKeyDown: onMenuKey },
345
+ React.createElement("div", { className: "dshma-search-wrap" },
346
+ React.createElement("input", { type: "text", className: "dshma-search", placeholder: "Search models…", value: query, onChange: (e) => setQuery(e.target.value), "aria-label": "Search models", autoFocus: true, ref: searchRef }),
347
+ query !== "" ? React.createElement("button", { type: "button", className: "dshma-search-clear", onClick: () => { setQuery(""); searchRef.current && searchRef.current.focus(); }, "aria-label": "Clear search", title: "Clear search" }, "✕") : null
348
+ ),
167
349
  React.createElement("div", { className: "dshma-menu-scroll" },
168
350
  failure ? React.createElement("div", { className: "dshma-error" }, failure) : null,
169
351
  state.error ? React.createElement("div", { className: "dshma-error" }, state.error) : null,
170
352
  state.status === "loading" ? React.createElement("div", { className: "dshma-status" }, "Loading models…") : null,
171
353
  (state.status === "selecting" || busy) ? React.createElement("div", { className: "dshma-status" }, "Selecting…") : null,
172
- state.status !== "loading" && mainGroups.length === 0 && visionGroups.length === 0 ? React.createElement("div", { className: "dshma-empty" }, "No models available") : null,
173
- mainGroups.map(renderGroup),
174
- visionGroups.length ? React.createElement("div", { className: "dshma-vision", key: "__vision__" },
175
- React.createElement("button", { type: "button", className: "dshma-provider-head", onClick: () => setVisionOpen((v) => !v), "aria-expanded": visionOpen },
176
- React.createElement(Chevron, { className: `dshma-provider-chevron${visionOpen ? " dshma-provider-chevron-open" : ""}` }),
177
- React.createElement("span", { className: "dshma-provider-name" }, "识图镜像(带图会话)"),
178
- React.createElement("span", { className: "dshma-count" }, `${visionGroups.length}`)
179
- ),
180
- visionOpen ? React.createElement("div", { className: "dshma-models" }, visionGroups.map(renderGroup)) : null
181
- ) : null
354
+ state.status !== "loading" && !hasAnyModel && q === "" ? React.createElement("div", { className: "dshma-empty" }, "No models available") : null,
355
+ q !== "" && !hasAnyModel ? React.createElement("div", { className: "dshma-empty" }, "No matches") : null,
356
+ visibleGroups.map(renderGroup)
182
357
  )
183
358
  ) : null
184
359
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-model-accordion",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Provider-folded model selector for the DSH Web composer with catalog-driven reasoning effort choices.",
5
5
  "keywords": [
6
6
  "deepseek-harness",
@@ -46,7 +46,7 @@
46
46
  },
47
47
  "repository": {
48
48
  "type": "git",
49
- "url": "https://github.com/lakabuji-arch/dsh-model-accordion.git"
49
+ "url": "git+https://github.com/lakabuji-arch/dsh-model-accordion.git"
50
50
  },
51
51
  "homepage": "https://github.com/lakabuji-arch/dsh-model-accordion#readme",
52
52
  "bugs": {