hermes-agent/apps/desktop/src/lib/model-status-label.ts
brooklyn! b7f0c9cd52
fix(desktop): honor pre-session model pick + restore global reasoning/speed defaults (#47447)
* fix(desktop): keep the pre-session model pick selected in the picker

The composer picker derived its "current" row from `model.options ?? store`,
so model.options always won. Pre-session that query returns the PROFILE
DEFAULT, not the sticky composer pick — so selecting a model before a session
exists left the checkmark (and the picker's "current" line) on the default,
making the pick look ignored even though the pill updated.

Add `currentPickerSelection()`: with a live session the gateway's model.options
is authoritative; pre-session the sticky `$currentModel`/`$currentProvider`
wins, falling back to options. Wire it into ModelMenuPanel and ModelPickerDialog.

* feat(desktop): global reasoning/speed defaults in Settings → Model

The composer picker is now sticky-UI/per-session only and never writes the
profile default (#46959), but Settings → Model had no reasoning/speed control
and `agent.reasoning_effort` wasn't in the curated config surface at all
(`service_tier` was buried in Advanced) — so there was nowhere to set the
profile default that crons/subagents/messaging resolve from.

Add capability-gated Reasoning (effort) + Fast controls beside the main model,
gated by the applied model's reported capabilities (reasoning defaults on, fast
off when unreported — same as the composer). They read/write `agent.reasoning_effort`
and `agent.service_tier` by round-tripping the config record, matching the
gateway's value semantics (service_tier "fast"/"priority"/"on" ⇒ fast).

* refactor(desktop): don't open the reasoning select from its row label

A <label> wrapping the Select forwarded text clicks to the trigger, opening
the dropdown unexpectedly. Plain row for reasoning; Fast stays a <label> so
clicking its text toggles the switch (expected for a checkbox-like control).
2026-06-16 16:22:09 -05:00

122 lines
3.7 KiB
TypeScript

const REASONING_LABELS: Record<string, string> = {
none: 'Off',
minimal: 'Min',
low: 'Low',
medium: 'Med',
high: 'High',
xhigh: 'Max'
}
export function reasoningEffortLabel(effort: string): string {
const key = effort.trim().toLowerCase()
if (!key) {
return ''
}
return REASONING_LABELS[key] ?? 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
* "current", so the sticky composer pick wins over the profile default the
* global options query returns — else the checkmark snaps back to the default
* and the pick looks ignored. */
export function currentPickerSelection(
hasSession: boolean,
store: { model: string; provider: string },
options?: { model?: string; provider?: string }
): { model: string; provider: string } {
return {
model: String((hasSession && options?.model) || store.model || options?.model || ''),
provider: String((hasSession && options?.provider) || store.provider || options?.provider || '')
}
}
/** Strip provider prefix and normalize for display. */
export function modelBaseId(model: string): string {
const trimmed = model.trim()
const slash = trimmed.lastIndexOf('/')
return slash >= 0 ? trimmed.slice(slash + 1) : trimmed
}
// Trailing model-id variants that should render as a grayed tag beside the
// name (e.g. "Opus 4.8" + "Fast") rather than collapsing two distinct ids to
// the same display name.
const VARIANT_TAGS: ReadonlyArray<readonly [RegExp, string]> = [
[/-fast$/i, 'Fast'],
[/-thinking$/i, 'Thinking'],
[/-preview$/i, 'Preview'],
[/-latest$/i, 'Latest']
]
const titleCase = (text: string): string => text.replace(/\b\w/g, char => char.toUpperCase()).trim()
function prettifyBase(base: string): string {
if (/^claude-/i.test(base)) {
return titleCase(base.replace(/^claude-/i, '').replace(/-/g, ' '))
}
if (/^gpt-/i.test(base)) {
return base.replace(/^gpt-/i, 'GPT-')
}
if (/^gemini-/i.test(base)) {
return base.replace(/^gemini-/i, 'Gemini ').replace(/-/g, ' ')
}
return titleCase(base.replace(/-/g, ' '))
}
/** Split a model id into a clean display name plus an optional grayed variant
* tag, so distinct ids (e.g. `…-4.8` vs `…-4.8-fast`) don't collapse. */
export function modelDisplayParts(model: string): { name: string; tag: string } {
let base = modelBaseId(model)
let tag = ''
for (const [pattern, label] of VARIANT_TAGS) {
if (pattern.test(base)) {
tag = label
base = base.replace(pattern, '')
break
}
}
// Drop a trailing date-pin (`…-20251101`) — snapshot noise, not a name.
base = base.replace(/-\d{8}$/, '')
return { name: prettifyBase(base) || model.trim() || 'No model', tag }
}
/** Friendly one-line model name for menus and the status bar. */
export function displayModelName(model: string): string {
return modelDisplayParts(model).name
}
/** Status bar trigger label — model name plus the live session state (effort/fast). */
export function formatModelStatusLabel(
model: string,
options?: { fastMode?: boolean; reasoningEffort?: string }
): string {
const name = displayModelName(model)
if (!model.trim()) {
return name
}
const parts: string[] = []
// Fast is shown when the speed=fast param is on (options.fastMode) OR the
// active model is a `…-fast` variant (fast via a separate model id).
if (options?.fastMode || /-fast$/i.test(modelBaseId(model))) {
parts.push('Fast')
}
// Always surface the effort (empty = Hermes default of medium) so the
// current reasoning level is visible at a glance, not just when non-default.
parts.push(reasoningEffortLabel(options?.reasoningEffort ?? '') || 'Med')
return `${name} · ${parts.join(' ')}`
}