dsh-model-accordion 0.1.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +42 -6
  2. package/lib/client.js +210 -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`), DeepSeek-V4.1-Flash (`deepseek-flash` / `deepseek-v4.1-*`, `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,110 @@ 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
+ // deepseek-v4.1-flash / deepseek-flash: low/high/max (default high)
102
+ // kimi-k3: low/high/max (default max)
103
+ // qwen3.8: xhigh/medium/low (default xhigh)
104
+ // gemini-3+ (Pro/Flash): low/high (always on) (default high)
105
+ // claude 4.6+ adaptive: low/medium/high (default high)
106
+ // gemma-4: minimal/high (default high)
107
+ const OFFICIAL_EFFORTS = [
108
+ { test: /gpt-5\.6/i, efforts: ["minimal", "low", "medium", "high", "xhigh", "max"], def: "high" },
109
+ { test: /gpt-5\.2[^0-9]|gpt-5\.[3-9]|gpt-5\.2\b/i, efforts: ["low", "medium", "high", "xhigh"], def: "high" },
110
+ { test: /gpt-5\.1/i, efforts: ["minimal", "low", "medium", "high"], def: "low" },
111
+ { test: /gpt-5(?:-mini|-nano)?\b/i, efforts: ["minimal", "low", "medium", "high"], def: "medium" },
112
+ { test: /o[134](-mini|-pro)?\b/i, efforts: ["low", "medium", "high"], def: "medium" },
113
+ { test: /gpt-oss/i, efforts: ["low", "medium", "high"], def: "medium" },
114
+ { test: /grok/i, efforts: ["low", "medium", "high"], def: "high" },
115
+ { test: /glm-5\.3/i, efforts: ["low", "high", "max"], def: "max" },
116
+ { test: /glm-5(?:\.[2-9]|\.\d+)/i, efforts: ["low", "medium", "high", "xhigh", "max"], def: "max" },
117
+ { test: /deepseek-v4/i, efforts: ["low", "high", "max"], def: "high" },
118
+ { test: /deepseek-(?:v4\.1|flash)/i, efforts: ["low", "high", "max"], def: "high" },
119
+ { test: /kimi-k3/i, efforts: ["low", "high", "max"], def: "max" },
120
+ { test: /qwen3\.8/i, efforts: ["xhigh", "medium", "low"], def: "xhigh" },
121
+ { test: /gemini-3/i, efforts: ["low", "high"], def: "high" },
122
+ { test: /claude-(opus|sonnet|haiku|fable)-(4\.[6-9]|5)\b/i, efforts: ["low", "medium", "high"], def: "high" },
123
+ { test: /gemma-4/i, efforts: ["minimal", "high"], def: "high" }
124
+ ];
125
+
126
+ function officialEffortsFor(model) {
127
+ if (!model) return void 0;
128
+ const hay = `${model.name || ""} ${model.id || ""}`;
129
+ for (const entry of OFFICIAL_EFFORTS) {
130
+ if (entry.test.test(hay)) return entry;
131
+ }
132
+ return void 0;
133
+ }
134
+
135
+ // Reason offered for a model: the backend/configured-catalog efforts when the
136
+ // model has them, otherwise the built-in official table as a fallback. This
137
+ // single "auto" rule is all there is — a user who configured their own levels
138
+ // keeps them, and an unconfigured model still gets sensible official choices.
139
+ function resolveEfforts(model) {
140
+ const backend = model && model.reasoning && Array.isArray(model.reasoning.efforts)
141
+ ? model.reasoning.efforts.filter((e) => e && typeof e.id === "string")
142
+ : [];
143
+ if (backend.length > 0) return backend;
144
+ const official = officialEffortsFor(model);
145
+ if (!official) return [];
146
+ return official.efforts.map((id) => ({ id, name: `${id.charAt(0).toUpperCase()}${id.slice(1)}` }));
147
+ }
148
+ // The default effort id to surface for a model (trigger caption + default
149
+ // chip highlight): the backend-declared default wins, and the official table's
150
+ // default is used only when the backend left it unset.
151
+ function resolveDefaultEffort(model) {
152
+ const backendDefault = model && model.reasoning && model.reasoning.defaultEffort != null
153
+ ? model.reasoning.defaultEffort
154
+ : void 0;
155
+ if (backendDefault !== void 0) return backendDefault;
156
+ const official = officialEffortsFor(model);
157
+ return official ? official.def : void 0;
158
+ }
159
+ // Whether an effort id is genuinely supported by the model — used by the
160
+ // submit path to decide whether to send reasoningEffort.
161
+ function effortSupported(model, id) {
162
+ return resolveEfforts(model).some((e) => e.id === id);
163
+ }
164
+
65
165
  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");
166
+ return resolveEfforts(model);
167
+ }
168
+
169
+ function effortName(model, id) {
170
+ const es = resolveEfforts(model);
171
+ const hit = es.find((e) => e.id === id);
172
+ return hit && hit.name ? hit.name : id;
68
173
  }
69
174
 
70
175
  function Accordion(props) {
@@ -75,14 +180,26 @@ window.__ModuleLoader__.load({
75
180
  const [failure, setFailure] = React.useState(null);
76
181
  const groups = Array.isArray(state.groups) ? state.groups : [];
77
182
  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);
183
+ const [query, setQuery] = React.useState("");
184
+ // Roving focus registry: every visible model-row button registers itself here
185
+ // (keyed by group+model), so arrow/Home/End navigate in accordion order.
186
+ const itemKeys = React.useRef({});
187
+ const registerItem = (key, el) => { if (el) itemKeys.current[key] = el; else delete itemKeys.current[key]; };
188
+ const searchRef = React.useRef(null);
189
+
190
+ // When the menu opens with NO provider expanded yet, surface the provider
191
+ // holding the current model so a fresh open is not a blank, fully-collapsed
192
+ // list. Crucially this only fires while `expanded` is empty: re-running it on
193
+ // every open (selecting a model closes the menu, so compare-testing reopens
194
+ // repeatedly) would otherwise accumulate every tested provider into
195
+ // `expanded`, leaving them all open once the search filter is cleared. When
196
+ // the user has already opened providers (browse state) we leave them alone.
197
+ React.useEffect(() => {
198
+ if (!open) return;
199
+ if (Object.values(expanded).some(Boolean)) return;
200
+ const g = groups.find((gg) => gg.id === state.current?.provider);
201
+ if (g) setExpanded((old) => ({ ...old, [g.id]: true }));
202
+ }, [open]);
86
203
 
87
204
  React.useEffect(() => {
88
205
  if (!open) return;
@@ -93,6 +210,11 @@ window.__ModuleLoader__.load({
93
210
  return () => { document.removeEventListener("mousedown", onDown); document.removeEventListener("keydown", onKey); };
94
211
  }, [open]);
95
212
 
213
+ // Populate store.current as soon as the model slot becomes available
214
+ // (mount/startup), so the trigger shows the last-used model instead of
215
+ // falling back to "Select model" until the user opens the menu.
216
+ React.useEffect(() => { if (props.available) props.load(); }, [props.available, props.load]);
217
+ // Refresh the store whenever the menu is opened (mirrors official reload).
96
218
  React.useEffect(() => { if (open && props.available) props.load(); }, [open, props.available]);
97
219
 
98
220
  const select = (group, model, effort) => {
@@ -100,7 +222,14 @@ window.__ModuleLoader__.load({
100
222
  setBusy(true);
101
223
  setFailure(null);
102
224
  const sel = { provider: group.id, model: model.id };
103
- if (effort !== void 0) sel.reasoningEffort = effort;
225
+ // Submit-degradation: only send reasoningEffort when the picked level is
226
+ // one the model genuinely supports. When the backend catalog declares
227
+ // efforts, an "official" level it does not list is dropped so the request
228
+ // falls back to the model's default instead of failing with
229
+ // UNSUPPORTED_REASONING_EFFORT. NOTE: when the backend declares no efforts
230
+ // at all the official table is the sole authority, so any official level is
231
+ // sent as-is — an under-configured relay may still reject it.
232
+ if (effort !== void 0 && effortSupported(model, effort)) sel.reasoningEffort = effort;
104
233
  props.select(sel).then((ok) => {
105
234
  if (!ok) throw new Error("model selection failed");
106
235
  setOpen(false);
@@ -113,12 +242,41 @@ window.__ModuleLoader__.load({
113
242
  const curGroup = groups.find((g) => g.id === current?.provider);
114
243
  const curModel = curGroup ? curGroup.models.find((m) => m.id === current?.model) : void 0;
115
244
  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))
245
+ // Show a effort` caption ONLY when a reasoning effort is actually pinned
246
+ // on the current selection (persisted in `current.reasoningEffort`). The
247
+ // official default is deliberately NOT surfaced here: showing it before the
248
+ // user pins anything reads as "already set", which is misleading.
249
+ const curEffort = curModel && current && current.provider === curGroup.id && current.model === curModel.id && current.reasoningEffort
250
+ ? effortName(curModel, current.reasoningEffort)
118
251
  : void 0;
119
252
 
253
+ // Model search: filter each provider's models by the query while keeping
254
+ // the accordion grouping structure. Matches on name, id, and description.
255
+ const q = query.trim().toLowerCase();
256
+ // When the filter is cleared (✕ / Escape / manual delete), collapse the
257
+ // accordion to the provider holding the CURRENT model. Selecting a model
258
+ // never writes `expanded`, so without this the menu would fall back to
259
+ // whatever was expanded before the search — not the model you just picked.
260
+ // Because selection does not accumulate into `expanded`, this stays focused
261
+ // on one group and never re-introduces the "everything open" problem.
262
+ const prevQuery = React.useRef("");
263
+ React.useEffect(() => {
264
+ const prev = prevQuery.current;
265
+ prevQuery.current = q;
266
+ if (q === "" && prev !== "") {
267
+ const cur = state.current;
268
+ setExpanded(cur && groups.some((g) => g.id === cur.provider) ? { [cur.provider]: true } : {});
269
+ }
270
+ }, [q]);
271
+ const matches = (m) => q === "" || [m.name, m.id, m.description].some((s) => typeof s === "string" && s.toLowerCase().includes(q));
272
+ const visibleGroups = q === ""
273
+ ? groups
274
+ : groups.map((g) => ({ ...g, models: g.models.filter(matches) })).filter((g) => g.models.length > 0);
275
+ const hasAnyModel = visibleGroups.some((g) => g.models.length > 0);
276
+
120
277
  const renderGroup = (group) => {
121
- const isOpen = expanded[group.id] === true;
278
+ const isOpen = expanded[group.id] === true || q !== "";
279
+ if (!group.models.length) return null;
122
280
  return React.createElement("div", { className: "dshma-provider", key: group.id },
123
281
  React.createElement("button", { type: "button", className: "dshma-provider-head", onClick: () => setExpanded((old) => ({ ...old, [group.id]: !isOpen })), "aria-expanded": isOpen },
124
282
  React.createElement(Chevron, { className: `dshma-provider-chevron${isOpen ? " dshma-provider-chevron-open" : ""}` }),
@@ -128,17 +286,16 @@ window.__ModuleLoader__.load({
128
286
  isOpen ? React.createElement("div", { className: "dshma-models" }, group.models.map((model) => {
129
287
  const selected = current && current.provider === group.id && current.model === model.id;
130
288
  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).
289
+ const defEffort = resolveDefaultEffort(model);
290
+ // A "default" chip clears a pinned effort so the model's effective
291
+ // default applies again (mirrors the official provider-default entry
292
+ // in dsh-client-ui-model-selection @315-324). It appears only when a
293
+ // default effort is known (backend-declared or official-table).
135
294
  const defaultChip = defEffort !== void 0 ? [{ id: "__provider_default__", name: "默认" }] : [];
136
295
  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
296
  const pinnedEffort = selected ? current.reasoningEffort : void 0;
140
297
  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: () => {
298
+ React.createElement("button", { type: "button", className: "dshma-option-row", ref: (el) => registerItem(`${group.id}\u0000${model.id}`, el), onClick: () => {
142
299
  // Re-clicking the already-selected model must NOT re-submit and
143
300
  // thereby wipe a custom (pinned) effort — just close the menu.
144
301
  // Mirrors dsh-client-ui-model-selection @397-400 / @718.
@@ -157,28 +314,48 @@ window.__ModuleLoader__.load({
157
314
  );
158
315
  };
159
316
 
317
+ // Roving arrow-key navigation across the rendered model rows (mirrors the
318
+ // official ModelSelect moveFocus). Enter/Space activate the focused row
319
+ // natively; Arrow keys / Home / End move between rows. ArrowDown from the
320
+ // search field lands on the first row.
321
+ const onMenuKey = (e) => {
322
+ // Escape: first press with text clears the search (keeping focus), second
323
+ // press closes. stopPropagation prevents the document-level handler from
324
+ // closing the menu while we are only clearing.
325
+ if (e.key === "Escape" && query !== "") {
326
+ e.preventDefault(); e.stopPropagation(); setQuery(""); searchRef.current && searchRef.current.focus(); return;
327
+ }
328
+ const els = Object.values(itemKeys.current)
329
+ .filter((el) => el && el.offsetParent !== null);
330
+ if (!els.length) return;
331
+ const idx = els.indexOf(document.activeElement);
332
+ let next = null;
333
+ if (e.key === "ArrowDown") { e.preventDefault(); next = idx === -1 ? els[0] : els[(idx + 1) % els.length]; }
334
+ else if (e.key === "ArrowUp") { e.preventDefault(); next = idx === -1 ? els[els.length - 1] : els[(idx - 1 + els.length) % els.length]; }
335
+ else if (e.key === "Home") { e.preventDefault(); next = els[0]; }
336
+ else if (e.key === "End") { e.preventDefault(); next = els[els.length - 1]; }
337
+ if (next) next.focus();
338
+ };
339
+
160
340
  return React.createElement("div", { className: "dshma-root", ref: rootRef },
161
341
  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
342
  React.createElement("span", { className: "dshma-trigger-label" }, label),
163
343
  curEffort ? React.createElement("span", { className: "dshma-trigger-effort" }, `· ${curEffort}`) : null,
164
344
  React.createElement(Chevron, { className: `dshma-chevron${open ? " dshma-chevron-open" : ""}` })
165
345
  ),
166
- open ? React.createElement("div", { className: "dshma-menu", role: "menu" },
346
+ open ? React.createElement("div", { className: "dshma-menu", role: "menu", "aria-label": "Model selector", onKeyDown: onMenuKey },
347
+ React.createElement("div", { className: "dshma-search-wrap" },
348
+ 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 }),
349
+ 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
350
+ ),
167
351
  React.createElement("div", { className: "dshma-menu-scroll" },
168
352
  failure ? React.createElement("div", { className: "dshma-error" }, failure) : null,
169
353
  state.error ? React.createElement("div", { className: "dshma-error" }, state.error) : null,
170
354
  state.status === "loading" ? React.createElement("div", { className: "dshma-status" }, "Loading models…") : null,
171
355
  (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
356
+ state.status !== "loading" && !hasAnyModel && q === "" ? React.createElement("div", { className: "dshma-empty" }, "No models available") : null,
357
+ q !== "" && !hasAnyModel ? React.createElement("div", { className: "dshma-empty" }, "No matches") : null,
358
+ visibleGroups.map(renderGroup)
182
359
  )
183
360
  ) : null
184
361
  );
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.1",
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": {