refactor(desktop): give reasoning effort one owner

The seven effort levels were enumerated four times in four shapes: a
labels map, a radio-option list, a settings value array, and an enum
list for the config field. Each carried its own hardcoded medium
fallback, which is how the picker drifted from the configured default in
the first place.

Collapse them into lib/reasoning-effort, which owns the scale, the
labels, and the resolve/fallback rules. Net -80/+26 in source. Dropping
the effortLabelKey cast also means the i18n keys are now type-checked
against the canonical list instead of asserted into place.
This commit is contained in:
Brooklyn Nicholson 2026-07-25 20:51:18 -05:00
parent be1edef5ce
commit 43d1088ba2
9 changed files with 135 additions and 86 deletions

View file

@ -11,6 +11,7 @@ import {
Sun,
Wrench
} from '@/lib/icons'
import { REASONING_EFFORTS } from '@/lib/reasoning-effort'
import type { ThemeMode } from '@/themes/context'
import { defineFieldCopy } from './field-copy'
@ -246,7 +247,8 @@ export const ENUM_OPTIONS: Record<string, string[]> = {
'approvals.mode': ['manual', 'smart', 'off'],
'code_execution.mode': ['project', 'strict'],
'context.engine': ['compressor', 'default', 'custom'],
'delegation.reasoning_effort': ['', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'],
// '' = inherit the agent's own effort; the rest is the shared scale.
'delegation.reasoning_effort': ['', ...REASONING_EFFORTS],
// NOTE: memory.provider is intentionally NOT listed here. Its options are
// discovery-driven and served by the backend config schema (merged
// per-request in web_server._schema_with_dynamic_provider_options), so

View file

@ -25,6 +25,7 @@ import type {
} from '@/hermes'
import { useI18n } from '@/i18n'
import { AlertTriangle, Cpu, Loader2 } from '@/lib/icons'
import { DEFAULT_REASONING_EFFORT, REASONING_EFFORT_VALUES } from '@/lib/reasoning-effort'
import { cn } from '@/lib/utils'
import { notifyError } from '@/store/notifications'
import { startManualLocalEndpoint, startManualOnboarding, startManualProviderOAuth } from '@/store/onboarding'
@ -81,10 +82,6 @@ export function ModelSettingsSkeleton() {
)
}
// Hermes' reasoning levels (VALID_REASONING_EFFORTS); `none` = thinking off.
// Empty config = Hermes default (medium), shown as Medium.
const EFFORT_VALUES = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'] as const
// agent.service_tier stores "fast"/"priority"/"on" for fast; anything else is
// normal (mirrors tui_gateway _load_service_tier).
const isFastTier = (tier: unknown): boolean =>
@ -94,9 +91,6 @@ const isFastTier = (tier: unknown): boolean =>
.toLowerCase()
)
// Reuse the composer's effort labels.
const effortLabelKey = (v: string) => v as 'high' | 'low' | 'max' | 'medium' | 'minimal' | 'ultra' | 'xhigh'
// A provider row is "ready" to pick a model from when it reports models. The
// backend now surfaces the full `hermes model` universe (every canonical
// provider), so unconfigured providers come back with `authenticated:false`
@ -515,7 +509,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
.trim()
.toLowerCase()
const effortValue = rawEffort === 'false' || rawEffort === 'disabled' ? 'none' : rawEffort || 'medium'
const effortValue = rawEffort === 'false' || rawEffort === 'disabled' ? 'none' : rawEffort || DEFAULT_REASONING_EFFORT
const fastOn = isFastTier(getNested(config ?? {}, 'agent.service_tier'))
@ -822,9 +816,9 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
<SelectValue />
</SelectTrigger>
<SelectContent>
{EFFORT_VALUES.map(value => (
{REASONING_EFFORT_VALUES.map(value => (
<SelectItem key={value} value={value}>
{value === 'none' ? m.reasoningOff : t.shell.modelOptions[effortLabelKey(value)]}
{value === 'none' ? m.reasoningOff : t.shell.modelOptions[value]}
</SelectItem>
))}
</SelectContent>

View file

@ -13,29 +13,24 @@ import {
} from '@/components/ui/dropdown-menu'
import { Switch } from '@/components/ui/switch'
import { useI18n } from '@/i18n'
import { normalize } from '@/lib/text'
import {
DEFAULT_REASONING_EFFORT,
isThinkingEnabled,
REASONING_EFFORTS,
resolveReasoningEffort
} from '@/lib/reasoning-effort'
import { setModelPreset } from '@/store/model-presets'
import { notifyError } from '@/store/notifications'
import {
$defaultReasoningEffort,
DEFAULT_REASONING_EFFORT,
markComposerSelectionManual,
setCurrentFastMode,
setCurrentReasoningEffort
} from '@/store/session'
import { sessionTileDelegate } from '@/store/session-states'
// Hermes' real reasoning levels (see VALID_REASONING_EFFORTS); `none` is owned
// Hermes' real reasoning levels live in lib/reasoning-effort; `none` is owned
// by the Thinking toggle, not the radio.
const EFFORT_OPTIONS = [
{ value: 'minimal', labelKey: 'minimal' },
{ value: 'low', labelKey: 'low' },
{ value: 'medium', labelKey: 'medium' },
{ value: 'high', labelKey: 'high' },
{ value: 'xhigh', labelKey: 'xhigh' },
{ value: 'max', labelKey: 'max' },
{ value: 'ultra', labelKey: 'ultra' }
] as const
/** How "fast" is achieved for a given model two different mechanisms:
* - `param`: the Anthropic/OpenAI `speed=fast` request parameter.
@ -118,7 +113,7 @@ export function ModelEditSubmenu({
const touchesPrimary = view.kind === 'primary'
const defaultEffort = useStore($defaultReasoningEffort) || DEFAULT_REASONING_EFFORT
const effortValue = normalizeEffort(effort, defaultEffort)
const effortValue = resolveReasoningEffort(effort, defaultEffort)
const thinkingOn = isThinkingEnabled(effort, defaultEffort)
// Editing always records the model's global preset (keyed by provider::model,
@ -247,14 +242,14 @@ export function ModelEditSubmenu({
<DropdownMenuSeparator className="mx-0" />
<DropdownMenuLabel className={dropdownMenuSectionLabel}>{copy.effort}</DropdownMenuLabel>
<DropdownMenuRadioGroup onValueChange={value => void patchReasoning(value)} value={effortValue}>
{EFFORT_OPTIONS.map(option => (
{REASONING_EFFORTS.map(value => (
<DropdownMenuRadioItem
className={dropdownMenuRow}
key={option.value}
key={value}
onSelect={event => event.preventDefault()}
value={option.value}
value={value}
>
{copy[option.labelKey]}
{copy[value]}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
@ -265,19 +260,3 @@ export function ModelEditSubmenu({
</DropdownMenuSubContent>
)
}
function isThinkingEnabled(effort: string, fallback: string): boolean {
// Empty = the profile default = on; only an explicit "none" is off.
return normalize(effort || fallback) !== 'none'
}
function normalizeEffort(effort: string, fallback: string): string {
const value = normalize(effort || fallback)
// Thinking off → no effort selected in the radio group.
if (value === 'none') {
return ''
}
return EFFORT_OPTIONS.some(option => option.value === value) ? value : DEFAULT_REASONING_EFFORT
}

View file

@ -20,12 +20,8 @@ import type { HermesGateway } from '@/hermes'
import { useI18n } from '@/i18n'
import { ChevronDown, ChevronRight } from '@/lib/icons'
import { modelOptionsQueryKey, requestModelOptions } from '@/lib/model-options'
import {
currentPickerSelection,
displayModelName,
modelDisplayParts,
reasoningEffortLabel
} from '@/lib/model-status-label'
import { currentPickerSelection, displayModelName, modelDisplayParts } from '@/lib/model-status-label'
import { DEFAULT_REASONING_EFFORT, reasoningEffortLabel } from '@/lib/reasoning-effort'
import { normalize } from '@/lib/text'
import { cn } from '@/lib/utils'
import { $modelPresets, applyModelPreset, modelPresetKey } from '@/store/model-presets'
@ -39,7 +35,7 @@ import {
setModelVisibilityOpen
} from '@/store/model-visibility'
import { $collapsedProviders, toggleCollapsedProvider } from '@/store/provider-collapse'
import { $defaultReasoningEffort, DEFAULT_REASONING_EFFORT } from '@/store/session'
import { $defaultReasoningEffort } from '@/store/session'
import type { ModelOptionProvider, ModelOptionsResponse } from '@/types/hermes'
import { ModelEditSubmenu, resolveFastControl } from './model-edit-submenu'

View file

@ -1,11 +1,7 @@
import { describe, expect, it } from 'vitest'
import {
currentPickerSelection,
displayModelName,
formatModelStatusLabel,
reasoningEffortLabel
} from './model-status-label'
import { currentPickerSelection, displayModelName, formatModelStatusLabel } from './model-status-label'
import { reasoningEffortLabel } from './reasoning-effort'
describe('model-status-label', () => {
it('formats display names consistently', () => {

View file

@ -1,25 +1,4 @@
import { normalize } from '@/lib/text'
const REASONING_LABELS: Record<string, string> = {
none: 'Off',
minimal: 'Min',
low: 'Low',
medium: 'Med',
high: 'High',
xhigh: 'XHigh',
max: 'Max',
ultra: 'Ultra'
}
export function reasoningEffortLabel(effort: string): string {
const key = normalize(effort)
if (!key) {
return ''
}
return REASONING_LABELS[key] ?? effort
}
import { DEFAULT_REASONING_EFFORT, reasoningEffortLabel } from '@/lib/reasoning-effort'
/** Which model/provider a picker should mark "current". With a live session the
* gateway's `model.options` is authoritative; pre-session there is no server
@ -122,7 +101,7 @@ export function formatModelStatusLabel(
// Always surface the effort so the current reasoning level is visible at a
// glance, not just when non-default.
parts.push(reasoningEffortLabel(options?.reasoningEffort || options?.defaultEffort || 'medium'))
parts.push(reasoningEffortLabel(options?.reasoningEffort || options?.defaultEffort || DEFAULT_REASONING_EFFORT))
return `${name} · ${parts.join(' ')}`
}

View file

@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest'
import {
DEFAULT_REASONING_EFFORT,
isReasoningEffort,
isThinkingEnabled,
REASONING_EFFORT_VALUES,
REASONING_EFFORTS,
reasoningEffortLabel,
resolveReasoningEffort
} from './reasoning-effort'
describe('reasoning-effort', () => {
it('keeps the scale ascending and `none` off it', () => {
expect(REASONING_EFFORTS).not.toContain('none')
expect(REASONING_EFFORT_VALUES[0]).toBe('none')
expect(REASONING_EFFORT_VALUES).toHaveLength(REASONING_EFFORTS.length + 1)
})
it('labels every level it claims to support', () => {
for (const effort of REASONING_EFFORT_VALUES) {
expect(reasoningEffortLabel(effort)).not.toBe('')
}
expect(reasoningEffortLabel('')).toBe('')
// Unknown values pass through rather than silently reading as a real level.
expect(reasoningEffortLabel('bogus')).toBe('bogus')
})
it('recognizes only real scale levels', () => {
expect(isReasoningEffort(DEFAULT_REASONING_EFFORT)).toBe(true)
expect(isReasoningEffort('HIGH')).toBe(true)
expect(isReasoningEffort('none')).toBe(false)
expect(isReasoningEffort('bogus')).toBe(false)
})
it('treats empty as inherit and only `none` as off', () => {
expect(isThinkingEnabled('none')).toBe(false)
expect(isThinkingEnabled('high')).toBe(true)
// Empty inherits the fallback, so an off fallback reads as off.
expect(isThinkingEnabled('', 'none')).toBe(false)
expect(isThinkingEnabled('', 'high')).toBe(true)
})
it('resolves a scale value: inherit, off, or clamp', () => {
expect(resolveReasoningEffort('high')).toBe('high')
// Empty inherits the profile default rather than snapping to medium.
expect(resolveReasoningEffort('', 'ultra')).toBe('ultra')
// Off selects nothing on the scale.
expect(resolveReasoningEffort('none')).toBe('')
expect(resolveReasoningEffort('bogus')).toBe(DEFAULT_REASONING_EFFORT)
})
})

View file

@ -0,0 +1,54 @@
import { normalize } from '@/lib/text'
/** Hermes' reasoning levels, in ascending order mirrors the backend's
* VALID_REASONING_EFFORTS (hermes_constants.py). `none` is not a level: it's
* thinking disabled, owned by the Thinking toggle rather than the scale. */
export const REASONING_EFFORTS = ['minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'] as const
export type ReasoningEffort = (typeof REASONING_EFFORTS)[number]
/** The scale plus the off state — the full set a config value may hold. */
export const REASONING_EFFORT_VALUES = ['none', ...REASONING_EFFORTS] as const
/** Hermes' built-in level when neither the surface nor the profile config
* specifies one (mirrors the backend's own fallback). */
export const DEFAULT_REASONING_EFFORT: ReasoningEffort = 'medium'
/** Compact labels for chrome where space is tight (pill, picker rows). Menus
* and settings use the translated `shell.modelOptions` strings instead. */
const SHORT_LABELS: Record<string, string> = {
none: 'Off',
minimal: 'Min',
low: 'Low',
medium: 'Med',
high: 'High',
xhigh: 'XHigh',
max: 'Max',
ultra: 'Ultra'
}
export function reasoningEffortLabel(effort: string): string {
const key = normalize(effort)
return key ? (SHORT_LABELS[key] ?? effort) : ''
}
export const isReasoningEffort = (value: string): value is ReasoningEffort =>
REASONING_EFFORTS.includes(normalize(value) as ReasoningEffort)
/** Thinking is on unless a level explicitly says otherwise; an empty value
* means "inherit", so it resolves through `fallback` first. */
export const isThinkingEnabled = (effort: string, fallback: string = DEFAULT_REASONING_EFFORT): boolean =>
normalize(effort || fallback) !== 'none'
/** The level a scale control should show. Empty inherits `fallback`; `none`
* (thinking off) selects nothing; anything unrecognized clamps to the default. */
export function resolveReasoningEffort(effort: string, fallback: string = DEFAULT_REASONING_EFFORT): string {
const value = normalize(effort || fallback)
if (value === 'none') {
return ''
}
return isReasoningEffort(value) ? value : DEFAULT_REASONING_EFFORT
}

View file

@ -454,14 +454,10 @@ export const setCurrentReasoningEffort = (next: Updater<string>) => {
persistString(COMPOSER_EFFORT_KEY, $currentReasoningEffort.get() || null)
}
// Hermes' built-in effort when neither the surface nor the profile config
// specifies one (see VALID_REASONING_EFFORTS / parse_reasoning_effort).
export const DEFAULT_REASONING_EFFORT = 'medium'
// The profile's `agent.reasoning_effort`, mirrored from config so surfaces that
// need to render or apply "the default" resolve the user's configured level
// instead of assuming DEFAULT_REASONING_EFFORT. Empty until config loads, and
// re-seeded on every profile switch by useHermesConfig.
// instead of assuming DEFAULT_REASONING_EFFORT (lib/reasoning-effort). Empty
// until config loads, and re-seeded on every profile switch by useHermesConfig.
export const $defaultReasoningEffort = atom('')
export const setDefaultReasoningEffort = (next: string) => updateAtom($defaultReasoningEffort, next)