@lynn123411/dsh-model-submenu 0.3.5 → 0.3.10

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.
@@ -1,51 +1,44 @@
1
1
  /**
2
- * One pickable model, as both surfaces render it.
2
+ * One pickable model in the picker.
3
3
  *
4
- * The menu row is one button that spans the whole width — name, favorite mark,
5
- * and the check for the model in use all live inside it, so hovering any part of
6
- * the row (including the star) highlights the row and clicking any part picks
7
- * the model. The picker row keeps the same shape but lifts the star out into its
8
- * own control, because favouriting there must not switch the model.
4
+ * The star is its own control, because favouriting there must not switch the
5
+ * model: the row button picks, the star toggles, and the check marks the model
6
+ * in use.
9
7
  */
10
8
 
11
- import type { ReactNode } from 'react'
12
- import { IconCheckOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
13
- import { StarIcon } from './icons.tsx'
14
- import css from './ModelSubmenu.module.css'
9
+ import type { ReactNode } from 'react';
10
+ import { IconCheckOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives';
11
+ import { StarIcon } from './icons.tsx';
12
+ import css from './ModelOptions.module.css';
15
13
 
16
- /** How one row shows its favorite mark: part of the row, or a control of its own. */
17
- export type StarMode = 'static' | 'interactive'
18
-
19
- /** Props shared by both row shapes. */
14
+ /** Props of one row. */
20
15
  export interface ModelRowProps {
21
16
  /** The model's catalog name. */
22
- readonly name: string
17
+ readonly name: string;
23
18
  /** Optional catalog badge shown beside the name. */
24
- readonly badge?: ReactNode | undefined
19
+ readonly badge?: ReactNode | undefined;
25
20
  /** Whether this row is the model in use. */
26
- readonly selected: boolean
21
+ readonly selected: boolean;
27
22
  /** Whether a selection is in flight, which locks the row. */
28
- readonly busy: boolean
29
- /** How the favorite mark behaves on this row. */
30
- readonly star: StarMode
23
+ readonly busy: boolean;
31
24
  /** Whether the model is favorited right now. */
32
- readonly favorited: boolean
33
- /** The metadata line's text; undefined in the menu. */
34
- readonly meta?: string | undefined
25
+ readonly favorited: boolean;
26
+ /** The metadata line's text. */
27
+ readonly meta?: string | undefined;
35
28
  /** Localized accessible name for the star control. */
36
- readonly starLabel?: string | undefined
29
+ readonly starLabel?: string | undefined;
37
30
  /** Whether the star is a Tab stop right now (the picker's roving highlight). */
38
- readonly starTabStop?: boolean
31
+ readonly starTabStop?: boolean;
39
32
  /** Tab order of the row itself; the picker keeps rows out of it so ↑/↓ own the walk. */
40
- readonly rowTabIndex?: number
33
+ readonly rowTabIndex?: number;
41
34
  /** Pick this row. */
42
- readonly onPick: () => void
35
+ readonly onPick: () => void;
43
36
  /** The row took the keyboard, so its star becomes reachable. */
44
- readonly onFocusRow?: (() => void) | undefined
45
- /** Toggle this row's favorite; absent when the star is part of the row. */
46
- readonly onToggleFavorite?: (() => void) | undefined
37
+ readonly onFocusRow?: (() => void) | undefined;
38
+ /** Toggle this row's favorite. */
39
+ readonly onToggleFavorite: () => void;
47
40
  /** Extra row content, such as the picker's disabled reason. */
48
- readonly children?: ReactNode
41
+ readonly children?: ReactNode;
49
42
  }
50
43
 
51
44
  /**
@@ -54,77 +47,43 @@ export interface ModelRowProps {
54
47
  * @returns the row element.
55
48
  */
56
49
  export function ModelRow({
57
- name, badge, selected, busy, star, favorited, meta, starLabel,
50
+ name, badge, selected, busy, favorited, meta, starLabel,
58
51
  starTabStop = false, rowTabIndex, onPick, onFocusRow, onToggleFavorite, children,
59
52
  }: ModelRowProps) {
60
- const copy = (
61
- <>
53
+ return <div
54
+ className={css.row}
55
+ data-selected={selected ? '' : undefined}
56
+ data-favorited={favorited ? '' : undefined}
57
+ >
58
+ <button
59
+ type="button"
60
+ role="menuitemradio"
61
+ aria-checked={selected}
62
+ tabIndex={rowTabIndex}
63
+ className={css.rowPick}
64
+ title={name}
65
+ disabled={busy}
66
+ onClick={onPick}
67
+ onFocus={onFocusRow}
68
+ >
62
69
  <span className={css.rowCopy}>
63
70
  <span className={css.rowName}>{name}</span>
64
71
  {badge}
65
72
  {meta !== undefined && meta !== '' && <span className={css.rowMeta}>{meta}</span>}
66
73
  </span>
67
- </>
68
- )
69
- const mark = favorited ? <StarIcon filled className={css.star} /> : null
70
-
71
- // The menu's row is one target: the star and the check sit inside the button,
72
- // so they inherit its hover fill and its click.
73
- if (star === 'static') {
74
- return (
75
- <button
76
- type="button"
77
- role="menuitemradio"
78
- aria-checked={selected}
79
- tabIndex={rowTabIndex}
80
- className={css.rowPick}
81
- title={name}
82
- disabled={busy}
83
- onClick={onPick}
84
- onFocus={onFocusRow}
85
- data-selected={selected ? '' : undefined}
86
- data-favorited={favorited ? '' : undefined}
87
- >
88
- {copy}
89
- {mark !== null && <span className={css.starStatic} aria-hidden="true">{mark}</span>}
90
- <span className={css.check} aria-hidden={!selected}>{selected ? <IconCheckOutlineRegular /> : null}</span>
91
- {children}
92
- </button>
93
- )
94
- }
95
-
96
- return (
97
- <div
98
- className={css.row}
99
- data-selected={selected ? '' : undefined}
100
- data-favorited={favorited ? '' : undefined}
74
+ </button>
75
+ <button
76
+ type="button"
77
+ className={css.starButton}
78
+ aria-pressed={favorited}
79
+ aria-label={starLabel}
80
+ tabIndex={starTabStop ? 0 : -1}
81
+ disabled={busy}
82
+ onClick={onToggleFavorite}
101
83
  >
102
- <button
103
- type="button"
104
- role="menuitemradio"
105
- aria-checked={selected}
106
- tabIndex={rowTabIndex}
107
- className={css.rowPick}
108
- title={name}
109
- disabled={busy}
110
- onClick={onPick}
111
- onFocus={onFocusRow}
112
- >
113
- {copy}
114
- </button>
115
- <button
116
- type="button"
117
- className={css.starButton}
118
- aria-pressed={favorited}
119
- aria-label={starLabel}
120
- tabIndex={starTabStop ? 0 : -1}
121
- disabled={busy}
122
- onClick={onToggleFavorite}
123
- >
124
- <StarIcon filled={favorited} className={css.star} />
125
- </button>
126
- <span className={css.check} aria-hidden={!selected}>{selected ? <IconCheckOutlineRegular /> : null}</span>
127
- {children}
128
- </div>
129
- )
84
+ <StarIcon filled={favorited} className={css.star} />
85
+ </button>
86
+ <span className={css.check} aria-hidden={!selected}>{selected ? <IconCheckOutlineRegular /> : null}</span>
87
+ {children}
88
+ </div>;
130
89
  }
@@ -1,6 +1,6 @@
1
1
  import type { Context as ClientContext } from '@deepseek-ai/cordis'
2
2
  // Type-only: `slots` and its LocaleNamespaceMap merge come from the renderer and
3
- // the slot package; the composer seat's SlotMap entry and owner props from
3
+ // the slot package; the model menu's options slot and the composer overlay from
4
4
  // ui-conversation; the `modelDirectories` service and the seat's injected face
5
5
  // from ui-model-selection's browser half; `configForms` from the settings
6
6
  // provider, whose shared mirror answers the metadata line.
@@ -10,16 +10,22 @@ import type {} from '@deepseek-ai/dsh-api-remotes/client'
10
10
  import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
11
11
  import type {} from '@deepseek-ai/dsh-client-ui-model-selection/client'
12
12
  import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
13
- import { ModelSubmenu } from './ModelSubmenu.tsx'
13
+ import { EffortTint } from './EffortTint.tsx'
14
+ import { ModelMenuBridge } from './ModelMenuBridge.tsx'
15
+ import { ModelPickerHost } from './ModelPickerHost.tsx'
16
+ import { createPickerStore } from './picker-state.ts'
14
17
  import { createFactsSource, type DescribeFace } from './facts-source.ts'
15
18
  import { NS, en, zh } from './locales.ts'
16
19
 
17
- export { ModelSubmenu } from './ModelSubmenu.tsx'
20
+ export { EffortTint } from './EffortTint.tsx'
21
+ export { ModelMenuBridge } from './ModelMenuBridge.tsx'
22
+ export { ModelPickerHost } from './ModelPickerHost.tsx'
23
+ export { createPickerStore } from './picker-state.ts'
18
24
 
19
25
  /**
20
26
  * Cordis services required by the browser half. `slots` is the contribution
21
27
  * registry, `sessions` tells whether a session may select a model,
22
- * `modelDirectories` owns the per-session catalog snapshot this seat renders
28
+ * `modelDirectories` owns the per-session catalog snapshot both entries render
23
29
  * from, and `configForms` carries the settings provider's shared describe
24
30
  * mirror, which answers each model's context window and accepted modalities.
25
31
  * `remote`, `remote.llm`, and `remote.session` are declared because a service
@@ -39,9 +45,14 @@ export const inject = [
39
45
  ]
40
46
 
41
47
  /**
42
- * Client plugin body: register this plugin's dictionaries, then take over the
43
- * composer model seat below the built-in occupant's priority so this menu
44
- * renders in its place.
48
+ * Client plugin body: register this plugin's dictionaries, then contribute the
49
+ * bridge that turns the Host's model cell into the picker's door and its effort
50
+ * cell into the reasoning-level slider, the overlay that renders the picker, and
51
+ * the tint that keeps the collapsed trigger's effort caption on the same color.
52
+ *
53
+ * The two are separate entries because the Host unmounts the model menu the
54
+ * moment a row is clicked; the overlay slot stays mounted, so the dialog
55
+ * survives the menu's teardown.
45
56
  * @param ctx - client root context.
46
57
  */
47
58
  export function apply(ctx: ClientContext): void {
@@ -49,33 +60,59 @@ export function apply(ctx: ClientContext): void {
49
60
  ctx.inject(['slots', 'modelDirectories', 'configForms', 'remote'], (scope) => {
50
61
  const models = scope.modelDirectories
51
62
  const sessions = scope.sessions
63
+ const picker = createPickerStore()
52
64
  const facts = createFactsSource({
53
65
  describe: scope.configForms.describe() as DescribeFace,
54
66
  listProviders: () => scope.remote.llm.listConfigurableProviders(),
55
67
  })
56
68
  ctx.effect(() => facts.start(), 'dsh-model-submenu: model metadata')
57
- scope.slots.inject('conversation.input.model', () => scope.slots.register({
58
- name: 'conversation.input.model',
59
- priority: -100,
69
+ /**
70
+ * Build one entry's business face for a session.
71
+ * @param sessionId - the slot's session scope.
72
+ * @returns the catalog face plus the session and store both entries need.
73
+ */
74
+ const faceOf = (sessionId: string) => {
75
+ // The slot's inject parameter is typed as a bare string; the service
76
+ // faces carry the branded SessionId of that same runtime value.
77
+ const key = sessionId as Parameters<typeof models.directoryFor>[0]
78
+ const directory = models.directoryFor(key)
79
+ const available = sessions.subagentAddress(key) === undefined
80
+ return {
81
+ sessionId,
82
+ picker,
83
+ available,
84
+ directory: directory.store,
85
+ factsSource: facts.source,
86
+ load: () => {
87
+ if (available) directory.load().catch(() => {})
88
+ },
89
+ resolveText: (text: Parameters<typeof ctx.locale.resolveText>[0]) => ctx.locale.resolveText(text),
90
+ select: (selection: Parameters<typeof directory.select>[0]) => available
91
+ ? directory.select(selection)
92
+ : Promise.resolve(undefined),
93
+ }
94
+ }
95
+ scope.slots.inject('conversation.input.model.options', () => scope.slots.register({
96
+ name: 'conversation.input.model.options',
97
+ id: 'model-menu-bridge',
60
98
  registrant: '@lynn123411/dsh-model-submenu',
61
99
  locale: NS,
62
- inject: (sessionId) => {
63
- // The seat's inject parameter is typed as a bare string; the service
64
- // faces carry the branded SessionId of that same runtime value.
65
- const key = sessionId as Parameters<typeof models.directoryFor>[0]
66
- const directory = models.directoryFor(key)
67
- const available = sessions.subagentAddress(key) === undefined
68
- return {
69
- available,
70
- directory: directory.store,
71
- factsSource: facts.source,
72
- load: () => {
73
- if (available) directory.load().catch(() => {})
74
- },
75
- resolveText: (text) => ctx.locale.resolveText(text),
76
- select: (selection) => available ? directory.select(selection) : Promise.resolve(undefined),
77
- }
78
- },
79
- }, ModelSubmenu))
100
+ inject: faceOf,
101
+ }, ModelMenuBridge))
102
+ scope.slots.inject('conversation.input.overlay', () => scope.slots.register({
103
+ name: 'conversation.input.overlay',
104
+ id: 'model-picker',
105
+ registrant: '@lynn123411/dsh-model-submenu',
106
+ locale: NS,
107
+ inject: faceOf,
108
+ }, ModelPickerHost))
109
+ // The trigger's effort caption lives outside the menu, so its tint needs an
110
+ // entry that stays mounted for the whole session.
111
+ scope.slots.inject('conversation.input.overlay', () => scope.slots.register({
112
+ name: 'conversation.input.overlay',
113
+ id: 'model-effort-tint',
114
+ registrant: '@lynn123411/dsh-model-submenu',
115
+ inject: faceOf,
116
+ }, EffortTint))
80
117
  })
81
118
  }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * The accent one reasoning level carries.
3
+ *
4
+ * "Off" carries no accent at all — the fill and the caption stay neutral, so
5
+ * switching reasoning off reads as switching it off rather than as the first
6
+ * shade of a ramp. Every level above it walks a blue-to-violet ramp, and the
7
+ * highest level is the only one whose fill is a gradient.
8
+ */
9
+
10
+ /** Off: the dimmed label tone, the same neutral the Host uses for quiet text. */
11
+ const NEUTRAL = 'var(--dsw-alias-label-dimmed)';
12
+
13
+ /** Lowest lit level: the blue the composer already uses for accents. */
14
+ const LOW = [77, 147, 248] as const;
15
+
16
+ /** Highest level: violet. */
17
+ const HIGH = [147, 51, 234] as const;
18
+
19
+ /**
20
+ * @param t - a point on the ramp, 0 at the lowest lit level and 1 at the top.
21
+ * @returns the ramp color there.
22
+ */
23
+ function ramp(t: number): string {
24
+ const mix = (from: number, to: number): number => Math.round(from + (to - from) * t);
25
+ return `rgb(${mix(LOW[0], HIGH[0])}, ${mix(LOW[1], HIGH[1])}, ${mix(LOW[2], HIGH[2])})`;
26
+ }
27
+
28
+ /**
29
+ * @param index - the level's detent, 0 being "off".
30
+ * @param count - number of detents.
31
+ * @returns the level's color as a CSS color string.
32
+ */
33
+ export function levelColor(index: number, count: number): string {
34
+ if (index <= 0) return NEUTRAL;
35
+ // The top level is always the violet end, even when it is the only lit one.
36
+ if (isTopLevel(index, count)) return ramp(1);
37
+ const span = Math.max(1, count - 2);
38
+ return ramp(Math.min(Math.max((index - 1) / span, 0), 1));
39
+ }
40
+
41
+ /**
42
+ * @param index - the level's detent.
43
+ * @param count - number of detents.
44
+ * @returns whether this level is the only one painted with a gradient.
45
+ */
46
+ export function isTopLevel(index: number, count: number): boolean {
47
+ return count > 1 && index === count - 1;
48
+ }
49
+
50
+ /** The top level's fill: the whole ramp, ending on the color its text takes. */
51
+ export const LEVEL_GRADIENT = `linear-gradient(90deg, ${ramp(0)}, ${ramp(1)})`;
@@ -1,20 +1,10 @@
1
- /** Locale bundles for the composer model menu and the picker it opens. */
1
+ /** Locale bundles for the all-provider model picker. */
2
2
 
3
3
  /** Dictionary namespace owned by this plugin. */
4
4
  export const NS = 'modelSubmenu'
5
5
 
6
- /** Locale keys the menu and the picker render. */
6
+ /** Locale keys the picker renders. */
7
7
  export type ModelSubmenuLocaleKey =
8
- | 'trigger.fallback'
9
- | 'trigger.loading'
10
- | 'trigger.selectAria'
11
- | 'trigger.aria'
12
- | 'trigger.ariaEffort'
13
- | 'menu.aria'
14
- | 'menu.effort'
15
- | 'menu.more'
16
- | 'menu.hint'
17
- | 'effort.providerDefault'
18
8
  | 'status.loading'
19
9
  | 'status.empty'
20
10
  | 'status.failed'
@@ -34,26 +24,19 @@ export type ModelSubmenuLocaleKey =
34
24
  | 'picker.hintClose'
35
25
  | 'meta.effort'
36
26
  | 'meta.images'
27
+ | 'effort.label'
28
+ | 'effort.providerDefault'
29
+ | 'effort.useDefault'
37
30
 
38
31
  declare module '@deepseek-ai/dsh-client-ui-slots' {
39
32
  interface LocaleNamespaceMap {
40
- /** The composer model menu's copy. */
33
+ /** The all-provider model picker's copy. */
41
34
  modelSubmenu: ModelSubmenuLocaleKey
42
35
  }
43
36
  }
44
37
 
45
38
  /** Simplified Chinese dictionary (the key-set source of truth). */
46
39
  export const zh: Record<ModelSubmenuLocaleKey, string> = {
47
- 'trigger.fallback': '选择模型',
48
- 'trigger.loading': '正在加载模型…',
49
- 'trigger.selectAria': '选择模型',
50
- 'trigger.aria': '选择模型,当前 {model}',
51
- 'trigger.ariaEffort': '选择模型,当前 {model},思考等级 {effort}',
52
- 'menu.aria': '模型与思考等级',
53
- 'menu.effort': '思考',
54
- 'menu.more': '更多模型…',
55
- 'menu.hint': '提示:切换模型或思考程度会使既有的提示词缓存失效。建议新建会话,避免额外的 token 消耗。',
56
- 'effort.providerDefault': '默认',
57
40
  'status.loading': '正在加载模型…',
58
41
  'status.empty': '没有可用的模型。',
59
42
  'status.failed': '模型列表加载失败:{message}',
@@ -73,20 +56,13 @@ export const zh: Record<ModelSubmenuLocaleKey, string> = {
73
56
  'picker.hintClose': '关闭',
74
57
  'meta.effort': '思考',
75
58
  'meta.images': '图片输入',
59
+ 'effort.label': '推理等级',
60
+ 'effort.providerDefault': 'Default',
61
+ 'effort.useDefault': '切换为默认档位',
76
62
  }
77
63
 
78
64
  /** English dictionary, checked complete against the zh key set. */
79
65
  export const en: Record<ModelSubmenuLocaleKey, string> = {
80
- 'trigger.fallback': 'Select model',
81
- 'trigger.loading': 'Loading models…',
82
- 'trigger.selectAria': 'Select model',
83
- 'trigger.aria': 'Select model, current {model}',
84
- 'trigger.ariaEffort': 'Select model, current {model}, effort {effort}',
85
- 'menu.aria': 'Model and effort',
86
- 'menu.effort': 'Effort',
87
- 'menu.more': 'More models…',
88
- 'menu.hint': 'Changing the model or the effort invalidates the existing prompt cache. Start a new session to avoid the extra token cost.',
89
- 'effort.providerDefault': 'Default',
90
66
  'status.loading': 'Loading models…',
91
67
  'status.empty': 'No models available.',
92
68
  'status.failed': 'Model list failed to load: {message}',
@@ -106,4 +82,7 @@ export const en: Record<ModelSubmenuLocaleKey, string> = {
106
82
  'picker.hintClose': 'close',
107
83
  'meta.effort': 'Effort',
108
84
  'meta.images': 'Images',
85
+ 'effort.label': 'Reasoning effort',
86
+ 'effort.providerDefault': 'Default',
87
+ 'effort.useDefault': 'Switch to the provider default',
109
88
  }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Which session's picker is open.
3
+ *
4
+ * The row that asks for the picker lives inside the composer model menu, and the
5
+ * Host unmounts that menu the moment a row is clicked. The dialog lives in the
6
+ * composer's overlay slot, which stays mounted. One plugin-wide store carries the
7
+ * request across that unmount — the same split the Host's own command popups use
8
+ * between their controller and their overlay view.
9
+ */
10
+
11
+ /** What the store publishes. */
12
+ export interface PickerSnapshot {
13
+ /** Session whose picker is open, or null when none is. */
14
+ readonly sessionId: string | null
15
+ }
16
+
17
+ /** Open and close verbs plus the observable the overlay view subscribes to. */
18
+ export interface PickerStore {
19
+ /** @returns the current request. */
20
+ readonly getSnapshot: () => PickerSnapshot
21
+ /** @param listener - called after every change. @returns the unsubscribe. */
22
+ readonly subscribe: (listener: () => void) => () => void
23
+ /** @param sessionId - open the picker for one session, replacing any other. */
24
+ readonly open: (sessionId: string) => void
25
+ /** Close whatever is open. */
26
+ readonly close: () => void
27
+ }
28
+
29
+ /**
30
+ * @returns a store that starts closed.
31
+ */
32
+ export function createPickerStore(): PickerStore {
33
+ let snapshot: PickerSnapshot = { sessionId: null };
34
+ const listeners = new Set<() => void>();
35
+ const publish = (next: PickerSnapshot): void => {
36
+ if (next.sessionId === snapshot.sessionId) return;
37
+ snapshot = next;
38
+ for (const listener of listeners) listener();
39
+ };
40
+ return {
41
+ getSnapshot: () => snapshot,
42
+ subscribe: (listener) => {
43
+ listeners.add(listener);
44
+ return () => {
45
+ listeners.delete(listener);
46
+ };
47
+ },
48
+ open: (sessionId) => {
49
+ publish({ sessionId });
50
+ },
51
+ close: () => {
52
+ publish({ sessionId: null });
53
+ },
54
+ };
55
+ }
@@ -9,6 +9,7 @@
9
9
  */
10
10
 
11
11
  import type { ModelReasoning } from '@deepseek-ai/dsh-api-session-controller/types'
12
+ import type { ModelDirectoryState } from '@deepseek-ai/dsh-client-ui-model-selection/client'
12
13
 
13
14
  /** One complete selection, as the directory's `select` takes it. */
14
15
  export interface Selection {
@@ -25,26 +26,21 @@ export interface EffortChoice {
25
26
  }
26
27
 
27
28
  /**
28
- * Build the segments for one model's reasoning row.
29
+ * Build the slider's detents for one model.
30
+ *
31
+ * The provider default is deliberately absent: it is a state, not a level, and
32
+ * giving it a detent would read as the weakest reasoning instead of "let the
33
+ * provider decide". The label above the track switches to it.
29
34
  * @param reasoning - the catalog's reasoning metadata, undefined when the model has none.
30
- * @param providerDefaultLabel - the localized label for the "follow the provider" segment.
31
- * @returns the segments in catalog order; the provider-default one leads when the model declares no default.
35
+ * @returns one detent per advertised level, in catalog order.
32
36
  */
33
- export function effortChoices(
34
- reasoning: ModelReasoning | undefined,
35
- providerDefaultLabel: string,
36
- ): readonly EffortChoice[] {
37
+ export function effortChoices(reasoning: ModelReasoning | undefined): readonly EffortChoice[] {
37
38
  if (reasoning === undefined) return []
38
- return [
39
- ...reasoning.defaultEffort === undefined
40
- ? [{ key: 'provider-default', effort: undefined, label: providerDefaultLabel }]
41
- : [],
42
- ...reasoning.efforts.map((level) => ({
43
- key: level.id,
44
- effort: level.id as string | undefined,
45
- label: level.name,
46
- })),
47
- ]
39
+ return reasoning.efforts.map((level) => ({
40
+ key: level.id,
41
+ effort: level.id as string | undefined,
42
+ label: level.name,
43
+ }))
48
44
  }
49
45
 
50
46
  /**
@@ -92,6 +88,36 @@ export function planModelCommit(
92
88
  }
93
89
  }
94
90
 
91
+ /**
92
+ * The reasoning metadata the catalog advertises for the model in force.
93
+ * @param state - the session's directory snapshot.
94
+ * @returns the metadata, or undefined when no model is live or it advertises none.
95
+ */
96
+ export function liveReasoning(state: ModelDirectoryState): ModelReasoning | undefined {
97
+ const current = state.current
98
+ if (current === null) return undefined
99
+ return state.groups
100
+ .find((group) => group.id === current.provider)
101
+ ?.models.find((model) => model.id === current.model)
102
+ ?.reasoning
103
+ }
104
+
105
+ /**
106
+ * The selection in force, or null while the Host has not resolved one.
107
+ * @param state - the session's directory snapshot.
108
+ * @returns the live selection, with the effort only when one is set.
109
+ */
110
+ export function selectionOf(state: ModelDirectoryState): Selection | null {
111
+ if (state.current === null) return null
112
+ return state.current.reasoningEffort === undefined
113
+ ? { provider: state.current.provider, model: state.current.model }
114
+ : {
115
+ provider: state.current.provider,
116
+ model: state.current.model,
117
+ reasoningEffort: state.current.reasoningEffort,
118
+ }
119
+ }
120
+
95
121
  /**
96
122
  * @param live - one selection.
97
123
  * @param provider - the provider to compare.
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Geometry of the discrete effort slider.
3
+ *
4
+ * The track carries one detent per level and the knob's centre travels between
5
+ * two ends inset by the knob radius, so the knob never overhangs the track. A
6
+ * pointer position becomes the detent nearest it; a detent becomes a fraction
7
+ * of the track that CSS turns into the knob's centre and the filled width.
8
+ */
9
+
10
+ /**
11
+ * Knob diameter in CSS pixels, and the track's height: the slider stands as tall
12
+ * as the row's text line. The centre stops half of it from each end of the
13
+ * track, so the knob never overhangs.
14
+ */
15
+ export const KNOB_SIZE = 20;
16
+
17
+ /**
18
+ * @param index - detent index.
19
+ * @param count - number of detents.
20
+ * @returns the knob centre as a fraction of the track, 0 at the first detent and 1 at the last.
21
+ */
22
+ export function offsetOf(index: number, count: number): number {
23
+ if (count <= 1) return 0;
24
+ const clamped = Math.min(Math.max(index, 0), count - 1);
25
+ return clamped / (count - 1);
26
+ }
27
+
28
+ /**
29
+ * @param clientX - pointer position in viewport coordinates.
30
+ * @param left - the track's left edge in viewport coordinates.
31
+ * @param width - the track's width in pixels.
32
+ * @param count - number of detents.
33
+ * @returns the detent nearest the pointer, clamped to the two ends.
34
+ */
35
+ export function stepAt(clientX: number, left: number, width: number, count: number): number {
36
+ if (count <= 1) return 0;
37
+ const usable = width - KNOB_SIZE;
38
+ if (usable <= 0) return 0;
39
+ const fraction = (clientX - left - KNOB_SIZE / 2) / usable;
40
+ const clamped = Math.min(Math.max(fraction, 0), 1);
41
+ return Math.round(clamped * (count - 1));
42
+ }
43
+
44
+ /**
45
+ * @param fraction - a knob position between 0 and 1.
46
+ * @returns the CSS length that puts the knob centre there, inset at both ends.
47
+ */
48
+ export function knobOffset(fraction: number): string {
49
+ const clamped = Math.min(Math.max(fraction, 0), 1);
50
+ return `calc(${KNOB_SIZE / 2}px + ${clamped} * (100% - ${KNOB_SIZE}px))`;
51
+ }