@lsfcy/dsh-composer-predictions 0.1.0 → 0.1.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.
package/README.en.md CHANGED
@@ -15,7 +15,7 @@ The assistant finishes with "All 12 tests pass." The empty composer then shows,
15
15
 
16
16
  Press Tab and it becomes an editable draft. Press Esc, or start typing, and it disappears.
17
17
 
18
- ![Composer prediction: ghost appears, Tab accepts](demo/demo-en.gif)
18
+ ![Composer prediction: ghost appears, Tab accepts](https://raw.githubusercontent.com/lsfcy/dsh-composer-predictions/main/demo/demo-en.gif)
19
19
 
20
20
  ## Install
21
21
 
@@ -37,7 +37,7 @@ dsh plugin --profile desktop add @lsfcy/dsh-composer-predictions
37
37
  ```
38
38
 
39
39
  Restart DSH afterwards. The desktop app uses `desktop`; other entrypoints use their profile under `~/.dsh/profiles`, e.g. `web`. To pin a version without npm:
40
- `dsh plugin --profile desktop add git+https://github.com/lsfcy/dsh-composer-predictions.git#v0.1.0`.
40
+ `dsh plugin --profile desktop add git+https://github.com/lsfcy/dsh-composer-predictions.git#v0.1.1`.
41
41
 
42
42
  ## Config
43
43
 
package/README.md CHANGED
@@ -15,7 +15,7 @@
15
15
 
16
16
  按 Tab,它变成可编辑的草稿;按 Esc 或直接打字,它消失。
17
17
 
18
- ![灰字预测:出现、Tab 接受](demo/demo-zh.gif)
18
+ ![灰字预测:出现、Tab 接受](https://raw.githubusercontent.com/lsfcy/dsh-composer-predictions/main/demo/demo-zh.gif)
19
19
 
20
20
  ## 安装
21
21
 
@@ -37,7 +37,7 @@ dsh plugin --profile desktop add @lsfcy/dsh-composer-predictions
37
37
  ```
38
38
 
39
39
  装完重启 DSH。桌面端 profile 是 `desktop`;其他入口用 `~/.dsh/profiles` 里对应的名字,如 `web`。不想走 npm 时,也可以用固定版本:
40
- `dsh plugin --profile desktop add git+https://github.com/lsfcy/dsh-composer-predictions.git#v0.1.0`。
40
+ `dsh plugin --profile desktop add git+https://github.com/lsfcy/dsh-composer-predictions.git#v0.1.1`。
41
41
 
42
42
  ## 配置
43
43
 
package/client.js CHANGED
@@ -349,6 +349,7 @@ const createGhostLayer = globalThis.createComposerGhost
349
349
 
350
350
  const PREDICT_PATH = '/composer-predictions/predict'
351
351
  const CONFIG_PATH = '/composer-predictions/config'
352
+ const OPTIONS_PATH = '/composer-predictions/options'
352
353
  const CLIENT_TIMEOUT_MS = 30_000
353
354
  const ATTACH_FRAMES = 120
354
355
 
@@ -363,6 +364,8 @@ const noSession = () => undefined
363
364
  const configStore = (() => {
364
365
  let snapshot = null
365
366
  let pending = null
367
+ let options = null
368
+ let optionsPending = null
366
369
  const listeners = new Set()
367
370
  const publish = (next) => {
368
371
  snapshot = next
@@ -402,6 +405,37 @@ const configStore = (() => {
402
405
  })
403
406
  return pending
404
407
  },
408
+ getOptions: () => options,
409
+ loadOptions(force = false) {
410
+ if (optionsPending) return optionsPending
411
+ if (!force && options) return Promise.resolve(options)
412
+ optionsPending = fetch(OPTIONS_PATH, {
413
+ headers: { accept: 'application/json' },
414
+ cache: 'no-store',
415
+ credentials: 'same-origin',
416
+ })
417
+ .then((response) => (response.ok ? response.json() : Promise.reject(new Error('HTTP ' + response.status))))
418
+ .then((payload) => {
419
+ options = {
420
+ providers: Array.isArray(payload && payload.providers) ? payload.providers : [],
421
+ models: Array.isArray(payload && payload.models) ? payload.models : [],
422
+ autoProvider: payload && typeof payload.autoProvider === 'string' ? payload.autoProvider : '',
423
+ }
424
+ return options
425
+ })
426
+ .catch((error) => {
427
+ console.warn(
428
+ '[composer-predictions] options load failed:',
429
+ error && error.message ? error.message : error,
430
+ )
431
+ options = { providers: [], models: [], autoProvider: '' }
432
+ return options
433
+ })
434
+ .finally(() => {
435
+ optionsPending = null
436
+ })
437
+ return optionsPending
438
+ },
405
439
  async save(patch) {
406
440
  const response = await fetch(CONFIG_PATH, {
407
441
  method: 'POST',
@@ -579,6 +613,163 @@ function Field({ label, hint, children }) {
579
613
  return h('label', { style: LABEL_STYLE }, h('span', null, label), children, hint ? h('span', { style: HINT_STYLE }, hint) : null)
580
614
  }
581
615
 
616
+ /**
617
+ * A text input backed by a datalist: clicking shows the known providers or
618
+ * models, typing still accepts a value the catalog does not advertise.
619
+ */
620
+ const POPUP_STYLE = {
621
+ position: 'absolute',
622
+ top: 'calc(100% + 4px)',
623
+ left: 0,
624
+ right: 0,
625
+ zIndex: 40,
626
+ padding: 4,
627
+ borderRadius: 10,
628
+ border: '1px solid var(--dsw-alias-border-l2, #e5e6eb)',
629
+ background: 'var(--dsw-alias-bg-module-platform, #fff)',
630
+ boxShadow: '0 8px 24px rgba(0, 0, 0, .12)',
631
+ maxHeight: 240,
632
+ overflowY: 'auto',
633
+ }
634
+
635
+ function Chevron({ open }) {
636
+ return h(
637
+ 'svg',
638
+ {
639
+ width: 12,
640
+ height: 12,
641
+ viewBox: '0 0 16 16',
642
+ fill: 'none',
643
+ stroke: 'currentColor',
644
+ strokeWidth: 1.8,
645
+ style: { transform: open ? 'rotate(180deg)' : 'none', transition: 'transform .15s ease' },
646
+ },
647
+ h('path', { d: 'M4 6l4 4 4-4', strokeLinecap: 'round', strokeLinejoin: 'round' }),
648
+ )
649
+ }
650
+
651
+ /**
652
+ * One themed combobox: the input stays editable (when `allowCustom`), the
653
+ * popup is a token-styled list rather than the OS select/datalist chrome.
654
+ */
655
+ function Combo({ label, hint, value, placeholder, options, allowCustom = true, onChange }) {
656
+ const [open, setOpen] = react.useState(false)
657
+ const [active, setActive] = react.useState(-1)
658
+ const rootRef = react.useRef(null)
659
+
660
+ react.useEffect(() => {
661
+ if (!open) return undefined
662
+ const close = (event) => {
663
+ if (rootRef.current && !rootRef.current.contains(event.target)) setOpen(false)
664
+ }
665
+ document.addEventListener('mousedown', close)
666
+ return () => document.removeEventListener('mousedown', close)
667
+ }, [open])
668
+
669
+ const choose = (next) => {
670
+ onChange(next)
671
+ setOpen(false)
672
+ }
673
+
674
+ return h(
675
+ Field,
676
+ { label, hint },
677
+ h(
678
+ 'div',
679
+ { ref: rootRef, style: { position: 'relative' } },
680
+ h('input', {
681
+ type: 'text',
682
+ value,
683
+ placeholder,
684
+ readOnly: !allowCustom,
685
+ autoComplete: 'off',
686
+ onChange: (event) => {
687
+ if (!allowCustom) return
688
+ onChange(event.target.value)
689
+ setOpen(true)
690
+ },
691
+ onClick: () => setOpen((current) => !current),
692
+ onKeyDown: (event) => {
693
+ if (event.key === 'Escape') {
694
+ setOpen(false)
695
+ return
696
+ }
697
+ if (event.key === 'ArrowDown') {
698
+ event.preventDefault()
699
+ setOpen(true)
700
+ setActive((index) => Math.min(options.length - 1, index + 1))
701
+ } else if (event.key === 'ArrowUp') {
702
+ event.preventDefault()
703
+ setActive((index) => Math.max(0, index - 1))
704
+ } else if (event.key === 'Enter' && open && active >= 0 && options[active]) {
705
+ event.preventDefault()
706
+ choose(options[active].value)
707
+ }
708
+ },
709
+ style: {
710
+ ...FIELD_STYLE,
711
+ paddingRight: 34,
712
+ cursor: allowCustom ? 'text' : 'pointer',
713
+ },
714
+ }),
715
+ h(
716
+ 'span',
717
+ {
718
+ style: {
719
+ position: 'absolute',
720
+ right: 10,
721
+ top: '50%',
722
+ transform: 'translateY(-50%)',
723
+ display: 'flex',
724
+ pointerEvents: 'none',
725
+ color: 'var(--dsw-alias-label-tertiary, #86909c)',
726
+ },
727
+ },
728
+ h(Chevron, { open }),
729
+ ),
730
+ open && options.length > 0
731
+ ? h(
732
+ 'div',
733
+ { style: POPUP_STYLE },
734
+ options.map((option, index) =>
735
+ h(
736
+ 'button',
737
+ {
738
+ key: option.value === '' ? '__auto' : option.value,
739
+ type: 'button',
740
+ onMouseDown: (event) => event.preventDefault(),
741
+ onClick: () => choose(option.value),
742
+ onMouseEnter: () => setActive(index),
743
+ style: {
744
+ display: 'flex',
745
+ width: '100%',
746
+ border: 'none',
747
+ padding: '7px 10px',
748
+ borderRadius: 6,
749
+ font: 'inherit',
750
+ fontSize: 13,
751
+ textAlign: 'left',
752
+ cursor: 'pointer',
753
+ background:
754
+ option.value === value
755
+ ? 'color-mix(in srgb, var(--dsw-alias-state-business-primary, #2f6fed) 10%, transparent)'
756
+ : 'transparent',
757
+ color:
758
+ option.value === value
759
+ ? 'var(--dsw-alias-state-business-primary, #2f6fed)'
760
+ : 'var(--dsw-alias-label-primary, #1d2129)',
761
+ outline: index === active ? '1px solid var(--dsw-alias-border-l2, #e5e6eb)' : 'none',
762
+ },
763
+ },
764
+ option.label,
765
+ ),
766
+ ),
767
+ )
768
+ : null,
769
+ ),
770
+ )
771
+ }
772
+
582
773
  function PredictionSettings() {
583
774
  const config = useConfig()
584
775
  const locale = useLocaleId()
@@ -586,6 +777,24 @@ function PredictionSettings() {
586
777
  const [draft, setDraft] = react.useState(null)
587
778
  const [status, setStatus] = react.useState('')
588
779
  const [busy, setBusy] = react.useState(false)
780
+ const [options, setOptions] = react.useState(() => configStore.getOptions())
781
+
782
+ react.useEffect(() => {
783
+ let alive = true
784
+ const refresh = () => {
785
+ configStore.loadOptions(true).then((value) => {
786
+ if (alive) setOptions(value)
787
+ })
788
+ }
789
+ // A provider added in another settings section must be visible here on the
790
+ // next visit, and on returning to the window, not after a restart.
791
+ refresh()
792
+ window.addEventListener('focus', refresh)
793
+ return () => {
794
+ alive = false
795
+ window.removeEventListener('focus', refresh)
796
+ }
797
+ }, [])
589
798
 
590
799
  react.useEffect(() => {
591
800
  if (!config || config.unavailable) return
@@ -621,6 +830,19 @@ function PredictionSettings() {
621
830
 
622
831
  const update = (patch) => setDraft({ ...draft, ...patch })
623
832
 
833
+ const providerChoices = (options ? options.providers : []).map((provider) => ({
834
+ value: provider.id,
835
+ label: provider.name && provider.name !== provider.id ? provider.name + ' · ' + provider.id : provider.id,
836
+ }))
837
+ const effectiveProvider = draft.provider || (options ? options.autoProvider : '')
838
+ const seenModels = new Set()
839
+ const modelChoices = []
840
+ for (const model of options ? options.models : []) {
841
+ if (model.provider !== effectiveProvider || seenModels.has(model.id)) continue
842
+ seenModels.add(model.id)
843
+ modelChoices.push({ value: model.id, label: model.name && model.name !== model.id ? model.name + ' · ' + model.id : model.id })
844
+ }
845
+
624
846
  return h(
625
847
  'section',
626
848
  { style: { display: 'flex', flexDirection: 'column', gap: 16, maxWidth: 640, padding: '8px 0 24px' } },
@@ -644,42 +866,34 @@ function PredictionSettings() {
644
866
  onToggle: (enabled) => update({ enabled }),
645
867
  }),
646
868
  ),
647
- h(
648
- Field,
649
- { label: words.provider, hint: words.providerHint },
650
- h('input', {
651
- type: 'text',
652
- value: draft.provider,
653
- placeholder: words.auto,
654
- onChange: (event) => update({ provider: event.target.value }),
655
- style: FIELD_STYLE,
656
- }),
657
- ),
658
- h(
659
- Field,
660
- { label: words.model, hint: words.modelHint },
661
- h('input', {
662
- type: 'text',
663
- value: draft.model,
664
- placeholder: words.auto,
665
- onChange: (event) => update({ model: event.target.value }),
666
- style: FIELD_STYLE,
667
- }),
668
- ),
669
- h(
670
- Field,
671
- { label: words.effort, hint: words.effortHint },
672
- h(
673
- 'select',
674
- {
675
- value: draft.reasoningEffort,
676
- onChange: (event) => update({ reasoningEffort: event.target.value }),
677
- style: FIELD_STYLE,
678
- },
679
- h('option', { value: '' }, words.auto),
680
- ...[['off'], ['none'], ['minimal'], ['low'], ['high']].map(([value]) => h('option', { key: value, value }, value)),
681
- ),
682
- ),
869
+ h(Combo, {
870
+ label: words.provider,
871
+ hint: words.providerHint,
872
+ value: draft.provider,
873
+ placeholder: words.auto,
874
+ options: [{ value: '', label: words.auto }].concat(providerChoices),
875
+ onChange: (provider) => update({ provider }),
876
+ }),
877
+ h(Combo, {
878
+ label: words.model,
879
+ hint: words.modelHint,
880
+ value: draft.model,
881
+ placeholder: words.auto,
882
+ options: [{ value: '', label: words.auto }].concat(modelChoices),
883
+ onChange: (model) => update({ model }),
884
+ }),
885
+ h(Combo, {
886
+ label: words.effort,
887
+ hint: words.effortHint,
888
+ value: draft.reasoningEffort,
889
+ placeholder: words.auto,
890
+ allowCustom: false,
891
+ options: [''].concat(['off', 'none', 'minimal', 'low', 'high']).map((value) => ({
892
+ value,
893
+ label: value === '' ? words.auto : value,
894
+ })),
895
+ onChange: (reasoningEffort) => update({ reasoningEffort }),
896
+ }),
683
897
  ),
684
898
  h(
685
899
  'div',
package/index.js CHANGED
@@ -23,12 +23,13 @@
23
23
  import z from '@deepseek-ai/schemastery'
24
24
 
25
25
  export const name = 'composer-predictions'
26
- export const GENERATOR_VERSION = '0.1.0'
26
+ export const GENERATOR_VERSION = '0.1.1'
27
27
  // webServer: the route; sessions: the transcript; llm: the model call.
28
28
  export const inject = ['webServer', 'sessions', 'llm']
29
29
 
30
30
  const PREDICT_PATH = '/composer-predictions/predict'
31
31
  const CONFIG_PATH = '/composer-predictions/config'
32
+ const OPTIONS_PATH = '/composer-predictions/options'
32
33
  const BODY_LIMIT_BYTES = 8 * 1024
33
34
 
34
35
  const CHEAP_MODEL = /\b(flash|mini|small|lite|fast|haiku|nano)\b/i
@@ -737,6 +738,79 @@ export function sanitizeSettings(input) {
737
738
  return out
738
739
  }
739
740
 
741
+ /** A provider that cannot enumerate models must not hang the settings page. */
742
+ function withTimeout(promise, ms) {
743
+ return new Promise((resolve, reject) => {
744
+ const timer = setTimeout(() => reject(new Error('timeout')), ms)
745
+ Promise.resolve(promise).then(
746
+ (value) => {
747
+ clearTimeout(timer)
748
+ resolve(value)
749
+ },
750
+ (error) => {
751
+ clearTimeout(timer)
752
+ reject(error)
753
+ },
754
+ )
755
+ })
756
+ }
757
+
758
+ /**
759
+ * The provider/model catalog the settings page offers. Kept off the predict
760
+ * path: the dock asks for a suggestion on every mount and must stay fast.
761
+ */
762
+ export async function collectOptions(ctx) {
763
+ // No cache: a provider added in Settings → Models must appear on the next
764
+ // open of this page, not after a TTL.
765
+ const llm = ctx && typeof ctx.get === 'function' ? ctx.get('llm') : undefined
766
+ const listed = llm && typeof llm.listProviders === 'function' ? llm.listProviders() : []
767
+ const providers = (Array.isArray(listed) ? listed : [])
768
+ .filter((provider) => provider && typeof provider.id === 'string')
769
+ .map((provider) => ({ id: provider.id, name: typeof provider.name === 'string' ? provider.name : provider.id }))
770
+ const models = []
771
+ if (llm && typeof llm.listModels === 'function') {
772
+ await Promise.all(
773
+ providers.map(async (provider) => {
774
+ try {
775
+ const list = await withTimeout(llm.listModels(provider.id), 3_000)
776
+ for (const model of Array.isArray(list) ? list : []) {
777
+ if (model && typeof model.id === 'string') {
778
+ models.push({ provider: provider.id, id: model.id, name: typeof model.name === 'string' ? model.name : model.id })
779
+ }
780
+ }
781
+ } catch {
782
+ // A provider with no reachable catalog simply contributes no suggestions.
783
+ }
784
+ }),
785
+ )
786
+ }
787
+ const selection = defaultSelection(ctx)
788
+ const value = {
789
+ providers,
790
+ models,
791
+ autoProvider: selection && typeof selection.provider === 'string' ? selection.provider : '',
792
+ }
793
+ return value
794
+ }
795
+
796
+ export function createOptionsHandler(ctx) {
797
+ return async function handle(request, response) {
798
+ if (request.method !== 'GET') {
799
+ response.writeHead(405, { allow: 'GET' }).end()
800
+ return
801
+ }
802
+ if (!requestAllowed(request)) {
803
+ send(response, 403, { error: 'forbidden' })
804
+ return
805
+ }
806
+ try {
807
+ send(response, 200, await collectOptions(ctx))
808
+ } catch (error) {
809
+ send(response, 500, { error: error && error.message ? error.message : String(error) })
810
+ }
811
+ }
812
+ }
813
+
740
814
  /**
741
815
  * Settings endpoint. Writes go through the platform's configEditor, which
742
816
  * validates against the Config schema and persists the override in the
@@ -801,6 +875,7 @@ export function apply(ctx, config) {
801
875
  state.options = next
802
876
  },
803
877
  )
878
+ const optionsHandler = createOptionsHandler(ctx)
804
879
  ctx.effect(
805
880
  () => ctx.webServer.register({ kind: 'exact', path: PREDICT_PATH, handler: predictHandler }),
806
881
  'composer-predictions: predict route',
@@ -809,4 +884,8 @@ export function apply(ctx, config) {
809
884
  () => ctx.webServer.register({ kind: 'exact', path: CONFIG_PATH, handler: configHandler }),
810
885
  'composer-predictions: config route',
811
886
  )
887
+ ctx.effect(
888
+ () => ctx.webServer.register({ kind: 'exact', path: OPTIONS_PATH, handler: optionsHandler }),
889
+ 'composer-predictions: options route',
890
+ )
812
891
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lsfcy/dsh-composer-predictions",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "Codex-style composer predictions for DeepSeek Harness: after a turn settles, the empty composer shows a dimmed suggestion of your next message; Tab accepts it.",
package/src/main.js CHANGED
@@ -25,6 +25,7 @@ const createGhostLayer = globalThis.createComposerGhost
25
25
 
26
26
  const PREDICT_PATH = '/composer-predictions/predict'
27
27
  const CONFIG_PATH = '/composer-predictions/config'
28
+ const OPTIONS_PATH = '/composer-predictions/options'
28
29
  const CLIENT_TIMEOUT_MS = 30_000
29
30
  const ATTACH_FRAMES = 120
30
31
 
@@ -39,6 +40,8 @@ const noSession = () => undefined
39
40
  const configStore = (() => {
40
41
  let snapshot = null
41
42
  let pending = null
43
+ let options = null
44
+ let optionsPending = null
42
45
  const listeners = new Set()
43
46
  const publish = (next) => {
44
47
  snapshot = next
@@ -78,6 +81,37 @@ const configStore = (() => {
78
81
  })
79
82
  return pending
80
83
  },
84
+ getOptions: () => options,
85
+ loadOptions(force = false) {
86
+ if (optionsPending) return optionsPending
87
+ if (!force && options) return Promise.resolve(options)
88
+ optionsPending = fetch(OPTIONS_PATH, {
89
+ headers: { accept: 'application/json' },
90
+ cache: 'no-store',
91
+ credentials: 'same-origin',
92
+ })
93
+ .then((response) => (response.ok ? response.json() : Promise.reject(new Error('HTTP ' + response.status))))
94
+ .then((payload) => {
95
+ options = {
96
+ providers: Array.isArray(payload && payload.providers) ? payload.providers : [],
97
+ models: Array.isArray(payload && payload.models) ? payload.models : [],
98
+ autoProvider: payload && typeof payload.autoProvider === 'string' ? payload.autoProvider : '',
99
+ }
100
+ return options
101
+ })
102
+ .catch((error) => {
103
+ console.warn(
104
+ '[composer-predictions] options load failed:',
105
+ error && error.message ? error.message : error,
106
+ )
107
+ options = { providers: [], models: [], autoProvider: '' }
108
+ return options
109
+ })
110
+ .finally(() => {
111
+ optionsPending = null
112
+ })
113
+ return optionsPending
114
+ },
81
115
  async save(patch) {
82
116
  const response = await fetch(CONFIG_PATH, {
83
117
  method: 'POST',
@@ -255,6 +289,163 @@ function Field({ label, hint, children }) {
255
289
  return h('label', { style: LABEL_STYLE }, h('span', null, label), children, hint ? h('span', { style: HINT_STYLE }, hint) : null)
256
290
  }
257
291
 
292
+ /**
293
+ * A text input backed by a datalist: clicking shows the known providers or
294
+ * models, typing still accepts a value the catalog does not advertise.
295
+ */
296
+ const POPUP_STYLE = {
297
+ position: 'absolute',
298
+ top: 'calc(100% + 4px)',
299
+ left: 0,
300
+ right: 0,
301
+ zIndex: 40,
302
+ padding: 4,
303
+ borderRadius: 10,
304
+ border: '1px solid var(--dsw-alias-border-l2, #e5e6eb)',
305
+ background: 'var(--dsw-alias-bg-module-platform, #fff)',
306
+ boxShadow: '0 8px 24px rgba(0, 0, 0, .12)',
307
+ maxHeight: 240,
308
+ overflowY: 'auto',
309
+ }
310
+
311
+ function Chevron({ open }) {
312
+ return h(
313
+ 'svg',
314
+ {
315
+ width: 12,
316
+ height: 12,
317
+ viewBox: '0 0 16 16',
318
+ fill: 'none',
319
+ stroke: 'currentColor',
320
+ strokeWidth: 1.8,
321
+ style: { transform: open ? 'rotate(180deg)' : 'none', transition: 'transform .15s ease' },
322
+ },
323
+ h('path', { d: 'M4 6l4 4 4-4', strokeLinecap: 'round', strokeLinejoin: 'round' }),
324
+ )
325
+ }
326
+
327
+ /**
328
+ * One themed combobox: the input stays editable (when `allowCustom`), the
329
+ * popup is a token-styled list rather than the OS select/datalist chrome.
330
+ */
331
+ function Combo({ label, hint, value, placeholder, options, allowCustom = true, onChange }) {
332
+ const [open, setOpen] = react.useState(false)
333
+ const [active, setActive] = react.useState(-1)
334
+ const rootRef = react.useRef(null)
335
+
336
+ react.useEffect(() => {
337
+ if (!open) return undefined
338
+ const close = (event) => {
339
+ if (rootRef.current && !rootRef.current.contains(event.target)) setOpen(false)
340
+ }
341
+ document.addEventListener('mousedown', close)
342
+ return () => document.removeEventListener('mousedown', close)
343
+ }, [open])
344
+
345
+ const choose = (next) => {
346
+ onChange(next)
347
+ setOpen(false)
348
+ }
349
+
350
+ return h(
351
+ Field,
352
+ { label, hint },
353
+ h(
354
+ 'div',
355
+ { ref: rootRef, style: { position: 'relative' } },
356
+ h('input', {
357
+ type: 'text',
358
+ value,
359
+ placeholder,
360
+ readOnly: !allowCustom,
361
+ autoComplete: 'off',
362
+ onChange: (event) => {
363
+ if (!allowCustom) return
364
+ onChange(event.target.value)
365
+ setOpen(true)
366
+ },
367
+ onClick: () => setOpen((current) => !current),
368
+ onKeyDown: (event) => {
369
+ if (event.key === 'Escape') {
370
+ setOpen(false)
371
+ return
372
+ }
373
+ if (event.key === 'ArrowDown') {
374
+ event.preventDefault()
375
+ setOpen(true)
376
+ setActive((index) => Math.min(options.length - 1, index + 1))
377
+ } else if (event.key === 'ArrowUp') {
378
+ event.preventDefault()
379
+ setActive((index) => Math.max(0, index - 1))
380
+ } else if (event.key === 'Enter' && open && active >= 0 && options[active]) {
381
+ event.preventDefault()
382
+ choose(options[active].value)
383
+ }
384
+ },
385
+ style: {
386
+ ...FIELD_STYLE,
387
+ paddingRight: 34,
388
+ cursor: allowCustom ? 'text' : 'pointer',
389
+ },
390
+ }),
391
+ h(
392
+ 'span',
393
+ {
394
+ style: {
395
+ position: 'absolute',
396
+ right: 10,
397
+ top: '50%',
398
+ transform: 'translateY(-50%)',
399
+ display: 'flex',
400
+ pointerEvents: 'none',
401
+ color: 'var(--dsw-alias-label-tertiary, #86909c)',
402
+ },
403
+ },
404
+ h(Chevron, { open }),
405
+ ),
406
+ open && options.length > 0
407
+ ? h(
408
+ 'div',
409
+ { style: POPUP_STYLE },
410
+ options.map((option, index) =>
411
+ h(
412
+ 'button',
413
+ {
414
+ key: option.value === '' ? '__auto' : option.value,
415
+ type: 'button',
416
+ onMouseDown: (event) => event.preventDefault(),
417
+ onClick: () => choose(option.value),
418
+ onMouseEnter: () => setActive(index),
419
+ style: {
420
+ display: 'flex',
421
+ width: '100%',
422
+ border: 'none',
423
+ padding: '7px 10px',
424
+ borderRadius: 6,
425
+ font: 'inherit',
426
+ fontSize: 13,
427
+ textAlign: 'left',
428
+ cursor: 'pointer',
429
+ background:
430
+ option.value === value
431
+ ? 'color-mix(in srgb, var(--dsw-alias-state-business-primary, #2f6fed) 10%, transparent)'
432
+ : 'transparent',
433
+ color:
434
+ option.value === value
435
+ ? 'var(--dsw-alias-state-business-primary, #2f6fed)'
436
+ : 'var(--dsw-alias-label-primary, #1d2129)',
437
+ outline: index === active ? '1px solid var(--dsw-alias-border-l2, #e5e6eb)' : 'none',
438
+ },
439
+ },
440
+ option.label,
441
+ ),
442
+ ),
443
+ )
444
+ : null,
445
+ ),
446
+ )
447
+ }
448
+
258
449
  function PredictionSettings() {
259
450
  const config = useConfig()
260
451
  const locale = useLocaleId()
@@ -262,6 +453,24 @@ function PredictionSettings() {
262
453
  const [draft, setDraft] = react.useState(null)
263
454
  const [status, setStatus] = react.useState('')
264
455
  const [busy, setBusy] = react.useState(false)
456
+ const [options, setOptions] = react.useState(() => configStore.getOptions())
457
+
458
+ react.useEffect(() => {
459
+ let alive = true
460
+ const refresh = () => {
461
+ configStore.loadOptions(true).then((value) => {
462
+ if (alive) setOptions(value)
463
+ })
464
+ }
465
+ // A provider added in another settings section must be visible here on the
466
+ // next visit, and on returning to the window, not after a restart.
467
+ refresh()
468
+ window.addEventListener('focus', refresh)
469
+ return () => {
470
+ alive = false
471
+ window.removeEventListener('focus', refresh)
472
+ }
473
+ }, [])
265
474
 
266
475
  react.useEffect(() => {
267
476
  if (!config || config.unavailable) return
@@ -297,6 +506,19 @@ function PredictionSettings() {
297
506
 
298
507
  const update = (patch) => setDraft({ ...draft, ...patch })
299
508
 
509
+ const providerChoices = (options ? options.providers : []).map((provider) => ({
510
+ value: provider.id,
511
+ label: provider.name && provider.name !== provider.id ? provider.name + ' · ' + provider.id : provider.id,
512
+ }))
513
+ const effectiveProvider = draft.provider || (options ? options.autoProvider : '')
514
+ const seenModels = new Set()
515
+ const modelChoices = []
516
+ for (const model of options ? options.models : []) {
517
+ if (model.provider !== effectiveProvider || seenModels.has(model.id)) continue
518
+ seenModels.add(model.id)
519
+ modelChoices.push({ value: model.id, label: model.name && model.name !== model.id ? model.name + ' · ' + model.id : model.id })
520
+ }
521
+
300
522
  return h(
301
523
  'section',
302
524
  { style: { display: 'flex', flexDirection: 'column', gap: 16, maxWidth: 640, padding: '8px 0 24px' } },
@@ -320,42 +542,34 @@ function PredictionSettings() {
320
542
  onToggle: (enabled) => update({ enabled }),
321
543
  }),
322
544
  ),
323
- h(
324
- Field,
325
- { label: words.provider, hint: words.providerHint },
326
- h('input', {
327
- type: 'text',
328
- value: draft.provider,
329
- placeholder: words.auto,
330
- onChange: (event) => update({ provider: event.target.value }),
331
- style: FIELD_STYLE,
332
- }),
333
- ),
334
- h(
335
- Field,
336
- { label: words.model, hint: words.modelHint },
337
- h('input', {
338
- type: 'text',
339
- value: draft.model,
340
- placeholder: words.auto,
341
- onChange: (event) => update({ model: event.target.value }),
342
- style: FIELD_STYLE,
343
- }),
344
- ),
345
- h(
346
- Field,
347
- { label: words.effort, hint: words.effortHint },
348
- h(
349
- 'select',
350
- {
351
- value: draft.reasoningEffort,
352
- onChange: (event) => update({ reasoningEffort: event.target.value }),
353
- style: FIELD_STYLE,
354
- },
355
- h('option', { value: '' }, words.auto),
356
- ...[['off'], ['none'], ['minimal'], ['low'], ['high']].map(([value]) => h('option', { key: value, value }, value)),
357
- ),
358
- ),
545
+ h(Combo, {
546
+ label: words.provider,
547
+ hint: words.providerHint,
548
+ value: draft.provider,
549
+ placeholder: words.auto,
550
+ options: [{ value: '', label: words.auto }].concat(providerChoices),
551
+ onChange: (provider) => update({ provider }),
552
+ }),
553
+ h(Combo, {
554
+ label: words.model,
555
+ hint: words.modelHint,
556
+ value: draft.model,
557
+ placeholder: words.auto,
558
+ options: [{ value: '', label: words.auto }].concat(modelChoices),
559
+ onChange: (model) => update({ model }),
560
+ }),
561
+ h(Combo, {
562
+ label: words.effort,
563
+ hint: words.effortHint,
564
+ value: draft.reasoningEffort,
565
+ placeholder: words.auto,
566
+ allowCustom: false,
567
+ options: [''].concat(['off', 'none', 'minimal', 'low', 'high']).map((value) => ({
568
+ value,
569
+ label: value === '' ? words.auto : value,
570
+ })),
571
+ onChange: (reasoningEffort) => update({ reasoningEffort }),
572
+ }),
359
573
  ),
360
574
  h(
361
575
  'div',