@dungsil/dsh-plugin-ui-model-picker 0.2.3 → 0.2.5

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 +5 -5
  2. package/dist/client.js +163 -91
  3. package/package.json +4 -2
package/README.md CHANGED
@@ -2,22 +2,22 @@
2
2
 
3
3
  ![모델 선택 팝오버의 라이트 모드와 다크 모드를 꼭짓점 대각선으로 나눈 미리보기](./assets/light-dark-preview.webp)
4
4
 
5
- DSH Web 입력창에서 모델과 추론 강도를 선택합니다. 하나의 선택 버튼을 누르면 추론 강도 팝오버가 먼저 열리며, 팝오버 안의 모델명을 눌러 모델 목록으로 이동합니다.
5
+ DSH Web 입력창에서 모델과 추론 강도를 선택합니다. 하나의 선택 버튼을 누르면 추론 강도 팝오버가 먼저 열리며, 팝오버 안의 모델명을 눌러 모델 목록으로 이동합니다. 모델을 고르면 실제 모델은 바로 바뀌지 않고 추론 강도 슬라이더가 이어서 표시되며, 적용 버튼을 누르면 모델과 강도가 함께 적용됩니다.
6
6
 
7
7
  위 이미지는 플러그인 컴포넌트에 예시 모델 목록과 테마 색상을 제공한 검증 화면을 각각 캡처해 합성했습니다. 표시된 모델명과 추론 단계는 예시이며, 실제 목록은 연결한 제공자가 결정합니다.
8
8
 
9
9
  ## 사용 방법
10
10
 
11
11
  1. DSH 플러그인 설치 화면에서 `@dungsil/dsh-plugin-ui-model-picker`를 설치하고 활성화합니다.
12
- 2. 입력창 오른쪽 선택 버튼을 눌러 추론 강도 슬라이더를 엽니다. 방향키와 Home/End 키도 사용할 수 있습니다.
13
- 3. 모델을 바꾸려면 팝오버 상단의 모델명 부분을 누르고 제공자별 모델을 선택합니다. 오른쪽 위 재설정 아이콘은 기본 추론 강도를 복원합니다.
12
+ 2. 입력창 오른쪽 선택 버튼을 눌러 추론 강도 슬라이더를 엽니다. 방향키와 Home/End 키도 사용할 수 있습니다. 원하는 강도를 고른 뒤 적용 버튼을 누릅니다.
13
+ 3. 모델을 바꾸려면 팝오버 상단의 모델명 부분을 누르고 제공자별 모델을 선택합니다. 추론 강도를 지원하는 모델을 고르면 슬라이더가 이어서 나타나며, 적용 버튼을 눌러야 모델이 적용됩니다. 오른쪽 위 재설정 아이콘은 기본 추론 강도를 복원합니다.
14
14
 
15
15
  ## 동작과 호환성
16
16
 
17
17
  - DSH `0.2.1-rc.5` 이상 `0.3.0` 미만의 Web UI를 지원합니다. 기본 `ui-model-selection` 기능이 활성화되어 있어야 합니다.
18
18
  - 공용 모델 디렉터리와 선택 API를 그대로 사용하므로 `/model` 명령과 선택 상태를 공유합니다. 별도 모델 설정을 저장하거나 API 키를 다루지 않습니다.
19
- - 실제 제공자 목록과 모델별 추론 메타데이터만 사용합니다. 별도 모델 목록, Fast 모드, 고정 추론 단계는 추가하지 않습니다. 추론을 지원하지 않는 모델에는 슬라이더를 표시하지 않으며, 팝오버 안에서 모델은 계속 바꿀 수 있습니다.
20
- - 다른 모델로 바꾸면 새 모델의 기본 추론 강도를 사용합니다. 기본값 재설정은 별도 강도 지정을 해제합니다.
19
+ - 실제 제공자 목록과 모델별 추론 메타데이터만 사용합니다. 별도 모델 목록, Fast 모드, 고정 추론 단계는 추가하지 않습니다. 추론을 지원하지 않는 모델에는 슬라이더를 표시하지 않으며, 그런 모델은 목록에서 고르는 즉시 적용됩니다.
20
+ - 다른 모델로 바꾸면 그 모델이 지원하는 추론 강도에서 슬라이더가 시작되고, 적용 버튼을 눌러야 모델 선택이 적용됩니다. 적용하지 않고 팝오버를 닫으면 모델은 바뀌지 않습니다. 기본값 재설정은 별도 강도 지정을 해제합니다.
21
21
  - Escape, 바깥 클릭, 스크롤, 창 크기 변경으로 메뉴를 닫습니다. 메뉴의 방향키와 Tab 탐색을 지원합니다. 한국어·영어·중국어(간체)와 DSH 테마 색상을 따릅니다.
22
22
  - 기본 모델 슬롯만 우선순위로 대체합니다. 제거하면 기본 선택 UI가 돌아오며 모델 선택은 유지됩니다. 같은 슬롯을 대체하는 다른 플러그인과는 함께 사용하지 마세요.
23
23
 
package/dist/client.js CHANGED
@@ -29,7 +29,7 @@ function IconResetOutlineRegular() {
29
29
  }
30
30
  // ../../tools/ui/src/composer.tsx
31
31
  const composerStyles = `
32
- :where(.dsh-plugin-composer-trigger) { display: flex; align-items: center; gap: 4px; min-width: 0; height: 28px; padding: 0 4px 0 8px; border: 0; border-radius: var(--dsw-radius-sm); background: transparent; color: var(--dsw-alias-label-secondary); font: inherit; font-size: 13px; line-height: 20px; cursor: pointer; }
32
+ :where(.dsh-plugin-composer-trigger) { display: flex; align-items: center; gap: 4px; min-width: 0; height: 28px; padding: 0 4px 0 8px; border: 0; border-radius: var(--dsw-radius-sm); background: transparent; color: var(--dsw-alias-label-secondary); font: inherit; font-size: 13px; line-height: 1.5385; cursor: pointer; }
33
33
  :where(.dsh-plugin-composer-trigger):disabled { color: var(--dsw-alias-label-dimmed); cursor: default; }
34
34
  .dsh-plugin-composer-icon, .dsh-plugin-composer-chevron { display: inline-flex; flex: none; }
35
35
  .dsh-plugin-composer-label, .dsh-plugin-composer-detail { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -68,36 +68,39 @@ const ComposerTrigger = /* @__PURE__ */ (0, react.forwardRef)(function ComposerT
68
68
  const styles = `${composerStyles}
69
69
  .dsh-codex-picker { display: flex; align-items: center; min-width: 0; }
70
70
  .dsh-codex-trigger { max-width: min(360px, 45cqw); font-weight: 400; outline: none; }
71
- .dsh-codex-trigger:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover); }
72
71
  .dsh-codex-trigger:focus-visible:not([data-dsh-automatic-focus]) { box-shadow: 0 0 0 2px var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); }
73
72
  .dsh-codex-trigger .dsh-plugin-composer-icon { display: var(--dsh-composer-model-icon-display, none); }
74
73
  .dsh-codex-trigger .dsh-plugin-composer-chevron { transition: transform .12s; }
75
74
  .dsh-codex-trigger .dsh-plugin-composer-label, .dsh-codex-trigger .dsh-plugin-composer-detail { display: var(--dsh-composer-model-text-display, block); }
76
75
  .dsh-codex-trigger .dsh-plugin-composer-detail { flex-shrink: 1000; }
77
- .dsh-codex-popover { position: fixed; z-index: 1100; display: flex; flex-direction: column; inset: auto; margin: 0; box-sizing: border-box; width: 256px; max-width: calc(100vw - 32px); max-height: min(360px, calc(100dvh - 96px)); overflow: hidden; padding: 4px; border: 0; --dsw-elevation-stroke-color: var(--dsw-alias-border-l1); --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); background: transparent; color: var(--dsw-alias-label-primary); box-shadow: var(--dsw-elevation-prominent); font: 13px/20px var(--dsw-font-family, system-ui); }
76
+ .dsh-codex-popover { position: fixed; z-index: 1100; display: flex; flex-direction: column; inset: auto; margin: 0; box-sizing: border-box; width: 256px; max-width: calc(100vw - 32px); max-height: min(360px, calc(100dvh - 96px)); overflow: hidden; padding: 4px; border: 0; --dsw-elevation-stroke-color: var(--dsw-alias-border-l1); --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); background: transparent; color: var(--dsw-alias-label-primary); box-shadow: var(--dsw-elevation-prominent); font: 13px/1.5385 var(--dsw-font-family, system-ui); }
78
77
  @media (prefers-reduced-motion: reduce) {
79
78
  .dsh-codex-trigger .dsh-plugin-composer-chevron { transition: none; }
80
79
  }
81
- .dsh-codex-popover > :not([role=radiogroup]) { flex-shrink: 0; }
82
- .dsh-codex-popover [role=radiogroup] { min-height: 0; overflow-y: auto; }
83
- .dsh-codex-header { display: grid; grid-template-columns: 24px minmax(0, 1fr) 24px; align-items: start; gap: 4px; }
84
- .dsh-codex-model, .dsh-codex-reset, .dsh-codex-back { border: 0; background: transparent; color: inherit; font: inherit; cursor: pointer; border-radius: var(--dsw-radius-md); }
80
+ .dsh-codex-popover > :not(.dsh-codex-models) { flex-shrink: 0; }
81
+ .dsh-codex-models { min-height: 0; overflow-y: auto; }
82
+ .dsh-codex-header { display: grid; grid-template-columns: 24px minmax(0, 1fr) auto; align-items: start; gap: 4px; }
83
+ .dsh-codex-model, .dsh-codex-reset, .dsh-codex-back, .dsh-codex-apply { border: 0; background: transparent; color: inherit; font: inherit; cursor: pointer; border-radius: var(--dsw-radius-md); }
85
84
  .dsh-codex-popover svg { flex: none; vertical-align: middle; }
86
- .dsh-codex-model { grid-column: 2; justify-self: center; min-width: 0; max-width: 100%; padding: 2px 10px 3px; text-align: center; }
87
- .dsh-codex-model strong { display: block; color: var(--dsw-alias-state-business-primary); font-size: 14px; line-height: 20px; font-weight: 600; }
88
- .dsh-codex-model > span { display: block; color: var(--dsw-alias-label-tertiary, #777); font-size: 12px; line-height: 17px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
89
- .dsh-codex-model:hover, .dsh-codex-reset:hover, .dsh-codex-back:hover { background: var(--dsw-alias-interactive-bg-hover, #f2f2f2); }
85
+ .dsh-codex-model { justify-self: center; min-width: 0; max-width: 100%; padding: 2px 2px 3px; text-align: center; }
86
+ .dsh-codex-model strong { display: block; color: var(--dsw-alias-state-business-primary); font-size: 14px; line-height: 1.4286; font-weight: 600; }
87
+ .dsh-codex-model > span { display: block; color: var(--dsw-alias-label-tertiary, #777); font-size: 12px; line-height: 1.4167; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
90
88
  .dsh-codex-reset { display: grid; place-items: center; width: 24px; height: 24px; color: var(--dsw-alias-label-tertiary, #999); }
91
89
  .dsh-codex-reset svg { width: 16px; height: 16px; }
92
90
  .dsh-codex-back { padding: 4px 8px; color: var(--dsw-alias-label-tertiary, #777); }
93
91
  .dsh-codex-popover h2, .dsh-codex-provider { margin: 0; padding: 6px 10px; font-size: 12px; font-weight: 400; color: var(--dsw-alias-label-tertiary, #777); }
94
92
  .dsh-codex-provider { font-size: 11px; padding-top: 10px; }
95
93
  .dsh-codex-option { box-sizing: border-box; display: flex; align-items: center; justify-content: space-between; gap: 6px; width: 100%; min-height: 34px; padding: 5px 7px; border: 0; border-radius: var(--dsw-radius-md); color: inherit; background: transparent; text-align: left; font: inherit; cursor: pointer; }
96
- .dsh-codex-option:hover:not(:disabled), .dsh-codex-option:focus-visible { background: var(--dsw-alias-interactive-bg-hover); }
94
+ .dsh-codex-option:focus-visible { background: var(--dsw-alias-interactive-bg-hover); }
95
+ @media (hover: hover) {
96
+ .dsh-codex-trigger:hover:not(:disabled), .dsh-codex-option:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover); }
97
+ .dsh-codex-model:hover, .dsh-codex-reset:hover, .dsh-codex-back:hover, .dsh-codex-apply:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover, #f2f2f2); }
98
+ }
97
99
  .dsh-codex-option span:first-child { min-width: 0; overflow-wrap: anywhere; }
98
100
  .dsh-codex-option small { display: block; color: var(--dsw-alias-label-tertiary, #777); font-size: 11px; }
99
101
  .dsh-codex-option .dsh-codex-check { display: grid; place-items: center; flex: 0 0 14px; }
100
102
  .dsh-codex-slider { padding: 10px 2px 3px; }
103
+ .dsh-codex-apply { padding: 3px 8px; color: var(--dsw-alias-label-tertiary, #777); font-size: 12px; line-height: 1.5; }
101
104
  .dsh-codex-rail { position: relative; height: 28px; }
102
105
  .dsh-codex-track { position: absolute; inset: 3px 0; border-radius: 20px; background: linear-gradient(to right, var(--dsw-alias-state-business-primary) var(--dsh-codex-progress), var(--dsw-alias-interactive-bg-hover) var(--dsh-codex-progress)); display: flex; align-items: center; justify-content: space-between; padding: 0 12px; }
103
106
  .dsh-codex-track span { width: 4px; height: 4px; border-radius: 50%; background: #b8b8b8; }
@@ -108,6 +111,7 @@ const styles = `${composerStyles}
108
111
  .dsh-codex-slider input::-moz-range-track { height: 28px; background: transparent; }
109
112
  .dsh-codex-slider input::-moz-range-thumb { width: 26px; height: 26px; border: 1px solid #0000000a; border-radius: 50%; background: #fff; box-shadow: 0 1px 3px #0002; }
110
113
  .dsh-codex-notice { padding: 6px 10px; margin: 0; font-size: 12px; overflow-wrap: anywhere; }
114
+ .dsh-codex-status { position: absolute; width: 1px; height: 1px; padding: 0; margin: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
111
115
  .dsh-codex-popover [role=alert] { color: var(--dsw-alias-state-error-primary, #b54424); }
112
116
  .dsh-codex-popover :focus-visible { outline: 2px solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); outline-offset: -2px; }
113
117
  .dsh-codex-popover :disabled { color: var(--dsw-alias-label-dimmed); cursor: default; }
@@ -127,7 +131,6 @@ const copy = {
127
131
  model: "모델 선택",
128
132
  effort: "추론 강도",
129
133
  choose: "추론 수준 선택",
130
- default: "제공자 기본값",
131
134
  reset: "기본값으로 재설정",
132
135
  loading: "모델을 불러오는 중입니다…",
133
136
  empty: "사용할 수 있는 모델이 없습니다.",
@@ -135,13 +138,13 @@ const copy = {
135
138
  missing: "현재 모델을 사용할 수 없습니다.",
136
139
  error: "변경하지 못했습니다. 현재 선택을 확인한 뒤 다시 시도하세요.",
137
140
  retry: "모델 목록 새로고침",
138
- busy: "변경 중…"
141
+ busy: "변경 중…",
142
+ apply: "적용"
139
143
  },
140
144
  en: {
141
145
  model: "Select model",
142
146
  effort: "Reasoning effort",
143
147
  choose: "Select reasoning effort",
144
- default: "Provider default",
145
148
  reset: "Reset to default",
146
149
  loading: "Loading models…",
147
150
  empty: "No models available.",
@@ -149,13 +152,13 @@ const copy = {
149
152
  missing: "The current model is unavailable.",
150
153
  error: "Could not change the selection. Check the current selection before retrying.",
151
154
  retry: "Reload models",
152
- busy: "Changing…"
155
+ busy: "Changing…",
156
+ apply: "Apply"
153
157
  },
154
158
  zh: {
155
159
  model: "选择模型",
156
160
  effort: "推理强度",
157
161
  choose: "选择推理级别",
158
- default: "提供商默认值",
159
162
  reset: "重置为默认值",
160
163
  loading: "正在加载模型…",
161
164
  empty: "没有可用的模型。",
@@ -163,7 +166,8 @@ const copy = {
163
166
  missing: "当前模型不可用。",
164
167
  error: "更改失败。请确认当前选择后重试。",
165
168
  retry: "刷新模型列表",
166
- busy: "正在更改…"
169
+ busy: "正在更改…",
170
+ apply: "应用"
167
171
  }
168
172
  };
169
173
  function apply(ctx) {
@@ -174,25 +178,28 @@ function apply(ctx) {
174
178
  const { active: locale } = useLocaleSnapshot(ctx.locale);
175
179
  const text = copy[locale.startsWith("ko") ? "ko" : locale.startsWith("zh") ? "zh" : "en"];
176
180
  const [requestedPanel, setPanel] = (0, react.useState)(null);
181
+ const [staged, setStaged] = (0, react.useState)(null);
177
182
  const [draft, setDraft] = (0, react.useState)(0);
178
183
  const [failed, setFailed] = (0, react.useState)(false);
179
184
  const trigger = (0, react.useRef)(null);
180
185
  const anchor = (0, react.useRef)(null);
181
186
  const popup = (0, react.useRef)(null);
182
187
  const pending = (0, react.useRef)(false);
188
+ const sliderSelection = (0, react.useRef)(null);
183
189
  const mounted = (0, react.useRef)(true);
184
190
  const id = (0, react.useId)();
185
191
  const current = state.current;
186
192
  const model = state.groups.find((item) => item.id === current?.provider)?.models.find((item) => item.id === current?.model);
187
193
  const reasoning = model?.reasoning;
188
- const efforts = reasoning?.efforts.length ? [...reasoning.defaultEffort === void 0 ? [{
189
- id: void 0,
190
- name: text.default
191
- }] : [], ...reasoning.efforts] : [];
192
- const panel = requestedPanel === "effort" && !efforts.length ? "model" : requestedPanel;
194
+ const efforts = reasoning?.efforts ?? [];
195
+ const stagedGroup = staged ? state.groups.find((item) => item.id === staged.provider) : void 0;
196
+ const stagedModel = staged ? stagedGroup?.models.find((item) => item.id === staged.model) : model;
197
+ const stagedEfforts = (stagedModel?.reasoning)?.efforts ?? [];
198
+ const panel = requestedPanel === "effort" && !stagedEfforts.length ? "model" : requestedPanel;
193
199
  const effective = current?.reasoningEffort ?? reasoning?.defaultEffort;
194
200
  const effortIndex = efforts.findIndex((item) => item.id === effective);
195
201
  const effortLabel = efforts.length ? efforts[effortIndex]?.name ?? state.retainedEffort : void 0;
202
+ const stagedName = stagedModel?.name ?? (staged ? staged.model : current?.model) ?? text.model;
196
203
  const busy = state.status === "selecting";
197
204
  const disabled = locked || busy;
198
205
  (0, react.useEffect)(() => {
@@ -203,10 +210,21 @@ function apply(ctx) {
203
210
  };
204
211
  }, [directory]);
205
212
  const close = () => {
213
+ sliderSelection.current = null;
206
214
  setPanel(null);
215
+ setStaged(null);
207
216
  if (trigger.current) (0, _deepseek_ai_dsh_client_ui_primitives.focusWithoutRing)(trigger.current);
208
217
  };
209
- (0, _deepseek_ai_dsh_client_ui_primitives.useDismissOnOutsidePointer)(anchor, panel !== null, () => setPanel(null), popup);
218
+ const restorePopupFocus = () => {
219
+ if (!popup.current?.contains(document.activeElement)) return;
220
+ const target = trigger.current?.disabled ? anchor.current?.parentElement?.querySelector("button:not(:disabled), input:not(:disabled), textarea:not(:disabled), [tabindex=\"0\"]") : trigger.current;
221
+ if (target) (0, _deepseek_ai_dsh_client_ui_primitives.focusWithoutRing)(target);
222
+ };
223
+ (0, _deepseek_ai_dsh_client_ui_primitives.useDismissOnOutsidePointer)(anchor, panel !== null, () => {
224
+ sliderSelection.current = null;
225
+ setPanel(null);
226
+ setStaged(null);
227
+ }, popup);
210
228
  (0, react.useLayoutEffect)(() => {
211
229
  if (!panel || !popup.current || !anchor.current) return;
212
230
  const element = popup.current;
@@ -243,7 +261,7 @@ function apply(ctx) {
243
261
  buttons[event.key === "Home" ? 0 : event.key === "End" ? buttons.length - 1 : (index + (event.key === "ArrowUp" ? -1 : 1) + buttons.length) % buttons.length]?.focus();
244
262
  };
245
263
  element.addEventListener("keydown", keydown);
246
- (element.querySelector("[aria-checked=true]") ?? element.querySelector("input:not(:disabled)") ?? element.querySelector("button:not(:disabled)"))?.focus();
264
+ (element.querySelector("[aria-current=true]") ?? element.querySelector("input:not(:disabled)") ?? element.querySelector("button:not(:disabled)"))?.focus();
247
265
  return () => {
248
266
  observer.disconnect();
249
267
  window.removeEventListener("resize", close);
@@ -252,25 +270,44 @@ function apply(ctx) {
252
270
  };
253
271
  }, [panel]);
254
272
  (0, react.useEffect)(() => {
255
- if (locked) setPanel(null);
273
+ if (locked) {
274
+ restorePopupFocus();
275
+ sliderSelection.current = null;
276
+ setPanel(null);
277
+ setStaged(null);
278
+ }
256
279
  }, [locked]);
257
280
  (0, react.useEffect)(() => {
281
+ const selection = sliderSelection.current;
282
+ sliderSelection.current = null;
283
+ if (selection && current?.provider === selection.provider && current?.model === selection.model && current?.reasoningEffort === selection.reasoningEffort) {
284
+ setStaged(null);
285
+ return;
286
+ }
287
+ restorePopupFocus();
258
288
  setPanel(null);
289
+ setStaged(null);
259
290
  }, [
260
291
  current?.provider,
261
292
  current?.model,
262
293
  current?.reasoningEffort
263
294
  ]);
264
- const select = async (selection) => {
295
+ const select = async (selection, keepSlider = false) => {
265
296
  if (disabled || pending.current) return;
266
297
  pending.current = true;
298
+ sliderSelection.current = keepSlider ? selection : null;
267
299
  setFailed(false);
268
300
  try {
269
301
  const result = await directory.select(selection);
270
302
  if (!mounted.current) return;
271
- if (result.ok) close();
272
- else setFailed(true);
303
+ if (result.ok) {
304
+ if (!keepSlider) close();
305
+ } else {
306
+ sliderSelection.current = null;
307
+ setFailed(true);
308
+ }
273
309
  } catch {
310
+ sliderSelection.current = null;
274
311
  if (mounted.current) setFailed(true);
275
312
  } finally {
276
313
  pending.current = false;
@@ -282,19 +319,50 @@ function apply(ctx) {
282
319
  close();
283
320
  return;
284
321
  }
322
+ setStaged(null);
285
323
  setDraft(Math.max(0, effortIndex));
286
324
  setFailed(false);
287
325
  setPanel("effort");
288
326
  directory.load().catch(() => {});
289
327
  };
290
328
  const selectEffort = (index) => {
291
- if (!current || !efforts[index]) return;
292
- const effort = efforts[index].id;
293
- select({
329
+ const target = staged ?? (current ? {
294
330
  provider: current.provider,
295
- model: current.model,
296
- ...effort === void 0 ? {} : { reasoningEffort: effort }
331
+ model: current.model
332
+ } : null);
333
+ const effort = stagedEfforts[index];
334
+ if (!target || !effort) return;
335
+ select({
336
+ provider: target.provider,
337
+ model: target.model,
338
+ reasoningEffort: effort.id
339
+ }, true);
340
+ };
341
+ const stageModel = (provider, option) => {
342
+ const same = provider === current?.provider && option.id === current?.model;
343
+ const optionReasoning = option.reasoning;
344
+ const optionEfforts = optionReasoning?.efforts ?? [];
345
+ if (optionEfforts.length < 2) {
346
+ if (same) {
347
+ close();
348
+ return;
349
+ }
350
+ const effort = optionReasoning?.defaultEffort;
351
+ select({
352
+ provider,
353
+ model: option.id,
354
+ ...effort === void 0 ? {} : { reasoningEffort: effort }
355
+ });
356
+ return;
357
+ }
358
+ const preferredEffort = same ? current?.reasoningEffort ?? optionReasoning?.defaultEffort : optionReasoning?.defaultEffort ?? current?.reasoningEffort;
359
+ setStaged({
360
+ provider,
361
+ model: option.id
297
362
  });
363
+ setDraft(Math.max(0, optionEfforts.findIndex((item) => item.id === preferredEffort)));
364
+ setFailed(false);
365
+ setPanel("effort");
298
366
  };
299
367
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
300
368
  className: "dsh-codex-picker",
@@ -305,13 +373,16 @@ function apply(ctx) {
305
373
  icon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconDataOutlineRegular, { size: 16 }),
306
374
  label: model?.name ?? current?.model ?? text.model,
307
375
  detail: effortLabel,
308
- disabled,
376
+ disabled: locked,
377
+ "aria-disabled": disabled,
309
378
  "aria-label": `${efforts.length ? text.choose : text.model}: ${model?.name ?? current?.model ?? text.model}${effortLabel ? `, ${effortLabel}` : ""}`,
310
379
  title: `${model?.name ?? current?.model ?? text.model}${effortLabel ? ` ${effortLabel}` : ""}`,
311
380
  "aria-haspopup": "dialog",
312
381
  "aria-expanded": panel !== null,
313
382
  "aria-controls": panel ? id : void 0,
314
- onClick: (event) => show(event.currentTarget)
383
+ onClick: (event) => {
384
+ if (!disabled) show(event.currentTarget);
385
+ }
315
386
  })
316
387
  }), panel && (0, react_dom.createPortal)(/* @__PURE__ */ (0, react_jsx_runtime.jsxs)(_deepseek_ai_dsh_client_ui_primitives.MenuSurface, {
317
388
  id,
@@ -320,7 +391,7 @@ function apply(ctx) {
320
391
  "aria-label": panel === "model" ? text.model : text.effort,
321
392
  className: "dsh-codex-popover",
322
393
  children: [
323
- panel === "model" ? efforts.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
394
+ panel === "model" ? stagedEfforts.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
324
395
  type: "button",
325
396
  className: "dsh-codex-back",
326
397
  onClick: () => setPanel("effort"),
@@ -331,31 +402,47 @@ function apply(ctx) {
331
402
  ]
332
403
  }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("header", {
333
404
  className: "dsh-codex-header",
334
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
335
- type: "button",
336
- className: "dsh-codex-model",
337
- disabled,
338
- "aria-label": `${text.model}: ${model?.name ?? current?.model ?? text.model}`,
339
- onClick: () => setPanel("model"),
340
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("strong", { children: efforts[draft]?.name ?? effortLabel ?? text.default }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: [
341
- model?.name ?? current?.model ?? text.model,
342
- " ",
343
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconChevronRightOutlineRegular, { size: 14 })
344
- ] })]
345
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
346
- type: "button",
347
- className: "dsh-codex-reset",
348
- disabled: disabled || !current,
349
- "aria-label": text.reset,
350
- title: text.reset,
351
- onClick: () => {
352
- if (current) select({
353
- provider: current.provider,
354
- model: current.model
355
- });
356
- },
357
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconResetOutlineRegular, {})
358
- })]
405
+ children: [
406
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
407
+ type: "button",
408
+ className: "dsh-codex-reset",
409
+ disabled: locked || !(staged ?? current),
410
+ "aria-disabled": disabled || !(staged ?? current),
411
+ "aria-label": text.reset,
412
+ title: text.reset,
413
+ onClick: () => {
414
+ const target = staged ?? (current ? {
415
+ provider: current.provider,
416
+ model: current.model
417
+ } : null);
418
+ if (target) select(target);
419
+ },
420
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconResetOutlineRegular, {})
421
+ }),
422
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
423
+ type: "button",
424
+ className: "dsh-codex-model",
425
+ disabled: locked,
426
+ "aria-disabled": disabled,
427
+ title: stagedName,
428
+ onClick: () => {
429
+ if (!disabled) setPanel("model");
430
+ },
431
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("strong", { children: stagedEfforts[draft]?.name }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: [
432
+ stagedName,
433
+ " ",
434
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconChevronRightOutlineRegular, { size: 14 })
435
+ ] })]
436
+ }),
437
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
438
+ type: "button",
439
+ className: "dsh-codex-apply",
440
+ disabled: locked,
441
+ "aria-disabled": disabled,
442
+ onClick: () => selectEffort(draft),
443
+ children: text.apply
444
+ })
445
+ ]
359
446
  }),
360
447
  state.status === "loading" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
361
448
  className: "dsh-codex-notice",
@@ -363,7 +450,7 @@ function apply(ctx) {
363
450
  children: text.loading
364
451
  }),
365
452
  busy && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
366
- className: "dsh-codex-notice",
453
+ className: panel === "effort" ? "dsh-codex-status" : "dsh-codex-notice",
367
454
  role: "status",
368
455
  children: text.busy
369
456
  }),
@@ -384,7 +471,8 @@ function apply(ctx) {
384
471
  }),
385
472
  panel === "model" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
386
473
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
387
- role: "radiogroup",
474
+ className: "dsh-codex-models",
475
+ role: "group",
388
476
  "aria-label": text.model,
389
477
  children: state.groups.map((provider) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
390
478
  "aria-label": provider.name,
@@ -393,18 +481,10 @@ function apply(ctx) {
393
481
  children: provider.name
394
482
  }), provider.models.map((option) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
395
483
  type: "button",
396
- role: "radio",
397
- "aria-checked": provider.id === current?.provider && option.id === current?.model,
484
+ "aria-current": provider.id === current?.provider && option.id === current?.model ? "true" : void 0,
398
485
  className: "dsh-codex-option",
399
486
  disabled,
400
- onClick: () => {
401
- const effort = provider.id === current?.provider && option.id === current?.model ? current.reasoningEffort : option.reasoning?.defaultEffort;
402
- select({
403
- provider: provider.id,
404
- model: option.id,
405
- ...effort === void 0 ? {} : { reasoningEffort: effort }
406
- });
407
- },
487
+ onClick: () => stageModel(provider.id, option),
408
488
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: [option.name, option.description && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("small", { children: option.description })] }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
409
489
  className: "dsh-codex-check",
410
490
  "aria-hidden": "true",
@@ -428,36 +508,28 @@ function apply(ctx) {
428
508
  },
429
509
  children: text.retry
430
510
  })
431
- ] }) : efforts.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
511
+ ] }) : stagedEfforts.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
432
512
  className: "dsh-codex-slider",
433
513
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
434
514
  className: "dsh-codex-rail",
435
- style: { "--dsh-codex-progress": `${efforts.length > 1 ? draft / (efforts.length - 1) * 100 : 0}%` },
515
+ style: { "--dsh-codex-progress": `${stagedEfforts.length > 1 ? draft / (stagedEfforts.length - 1) * 100 : 0}%` },
436
516
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
437
517
  className: "dsh-codex-track",
438
518
  "aria-hidden": "true",
439
- children: efforts.map((effort, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { "data-filled": index <= draft }, effort.id ?? "default"))
519
+ children: stagedEfforts.map((effort, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { "data-filled": index <= draft }, effort.id))
440
520
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
441
521
  id: `${id}-effort`,
442
522
  type: "range",
443
523
  min: 0,
444
- max: efforts.length - 1,
524
+ max: stagedEfforts.length - 1,
445
525
  step: 1,
446
526
  value: draft,
447
- disabled: disabled || efforts.length < 2,
527
+ disabled: locked || stagedEfforts.length < 2,
528
+ "aria-disabled": disabled || stagedEfforts.length < 2,
448
529
  "aria-label": text.effort,
449
- "aria-valuetext": efforts[draft]?.name,
450
- onChange: (event) => setDraft(Number(event.target.value)),
451
- onPointerUp: (event) => selectEffort(Number(event.currentTarget.value)),
452
- onKeyUp: (event) => {
453
- if ([
454
- "ArrowLeft",
455
- "ArrowRight",
456
- "ArrowUp",
457
- "ArrowDown",
458
- "Home",
459
- "End"
460
- ].includes(event.key)) selectEffort(Number(event.currentTarget.value));
530
+ "aria-valuetext": stagedEfforts[draft]?.name,
531
+ onChange: (event) => {
532
+ if (!disabled) setDraft(Number(event.target.value));
461
533
  }
462
534
  })]
463
535
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dungsil/dsh-plugin-ui-model-picker",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "description": "Model and reasoning effort picker for the DeepSeek Harness composer.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -40,6 +40,7 @@
40
40
  "@dungsil/dsh-ui": "0.0.0",
41
41
  "@types/react": "18.3.31",
42
42
  "@types/react-dom": "18.3.7",
43
+ "happy-dom": "21.0.4",
43
44
  "react": "18.3.1",
44
45
  "react-dom": "18.3.1",
45
46
  "tsdown": "0.23.4"
@@ -88,6 +89,7 @@
88
89
  },
89
90
  "scripts": {
90
91
  "build": "tsdown",
91
- "typecheck": "tsc --noEmit"
92
+ "typecheck": "tsc --noEmit",
93
+ "test": "node --test test/*.test.mjs"
92
94
  }
93
95
  }