+ )
+}
+
function DropdownMenuContent({
className,
+ collisionPadding = 8,
sideOffset = 4,
...props
}: React.ComponentProps) {
@@ -31,6 +83,9 @@ function DropdownMenuContent({
'dt-portal-scrollbar z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-36 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg border border-(--ui-stroke-secondary) bg-[color-mix(in_srgb,var(--ui-bg-elevated)_96%,transparent)] p-1 text-[length:var(--conversation-text-font-size)] text-popover-foreground shadow-md backdrop-blur-md data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
className
)}
+ // Keep the menu inside the viewport: Radix flips/shifts away from edges
+ // (avoidCollisions defaults on); the padding stops it kissing the edge.
+ collisionPadding={collisionPadding}
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
{...props}
@@ -76,18 +131,16 @@ function DropdownMenuCheckboxItem({
-
-
-
-
-
{children}
+
+
+
)
}
@@ -104,18 +157,16 @@ function DropdownMenuRadioItem({
return (
-
-
-
-
-
{children}
+
+
+
)
}
@@ -164,10 +215,13 @@ function DropdownMenuSub({ ...props }: React.ComponentProps & {
inset?: boolean
+ /** Suppress the trailing caret — for triggers that own their right-side affordance. */
+ hideChevron?: boolean
}) {
return (
{children}
-
+ {!hideChevron && }
)
}
function DropdownMenuSubContent({
className,
+ collisionPadding = 8,
...props
}: React.ComponentProps) {
return (
-
+ // Portal the submenu out of the parent Content so it escapes that Content's
+ // `overflow` clip. Without this, a submenu opening from a scrollable menu
+ // gets visually cut off at the parent's edges. Radix Popper still anchors
+ // it to the SubTrigger and handles collision/flip, so portaling is safe.
+
+
+
)
}
@@ -216,6 +281,7 @@ export {
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
+ DropdownMenuSearch,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
diff --git a/apps/desktop/src/lib/model-status-label.test.ts b/apps/desktop/src/lib/model-status-label.test.ts
new file mode 100644
index 000000000..6c0bac912
--- /dev/null
+++ b/apps/desktop/src/lib/model-status-label.test.ts
@@ -0,0 +1,31 @@
+import { describe, expect, it } from 'vitest'
+
+import { displayModelName, formatModelStatusLabel, reasoningEffortLabel } from './model-status-label'
+
+describe('model-status-label', () => {
+ it('formats display names consistently', () => {
+ expect(displayModelName('anthropic/claude-opus-4.8-fast')).toBe('Opus 4.8')
+ expect(displayModelName('openai/gpt-5.5')).toBe('GPT-5.5')
+ })
+
+ it('maps reasoning effort to compact labels', () => {
+ expect(reasoningEffortLabel('high')).toBe('High')
+ expect(reasoningEffortLabel('xhigh')).toBe('Max')
+ expect(reasoningEffortLabel('')).toBe('')
+ })
+
+ it('appends fast + effort session state to the status label', () => {
+ expect(formatModelStatusLabel('openai/gpt-5.5', { fastMode: true, reasoningEffort: 'high' })).toBe(
+ 'GPT-5.5 · Fast High'
+ )
+ })
+
+ it('always surfaces the effort (default medium) so the level is visible', () => {
+ expect(formatModelStatusLabel('openai/gpt-5.5', { reasoningEffort: 'medium' })).toBe('GPT-5.5 · Med')
+ expect(formatModelStatusLabel('openai/gpt-5.5')).toBe('GPT-5.5 · Med')
+ })
+
+ it('returns just the placeholder name when there is no model', () => {
+ expect(formatModelStatusLabel('')).toBe('No model')
+ })
+})
diff --git a/apps/desktop/src/lib/model-status-label.ts b/apps/desktop/src/lib/model-status-label.ts
new file mode 100644
index 000000000..3a7d065cf
--- /dev/null
+++ b/apps/desktop/src/lib/model-status-label.ts
@@ -0,0 +1,103 @@
+const REASONING_LABELS: Record = {
+ 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
+}
+
+/** 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 = [
+ [/-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
+ }
+ }
+
+ 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(' ')}`
+}
diff --git a/apps/desktop/src/store/model-visibility.ts b/apps/desktop/src/store/model-visibility.ts
new file mode 100644
index 000000000..b6d3d51dc
--- /dev/null
+++ b/apps/desktop/src/store/model-visibility.ts
@@ -0,0 +1,108 @@
+import { atom } from 'nanostores'
+
+import { persistString, storedString } from '@/lib/storage'
+import type { ModelOptionProvider } from '@/types/hermes'
+
+const STORAGE_KEY = 'hermes.desktop.visible-models'
+
+/** Models shown per provider in the status-bar dropdown before the user has
+ * customized the list. Backend `models` are already relevance-ordered. */
+export const DEFAULT_VISIBLE_PER_PROVIDER = 5
+
+/** Stable key for a provider/model pair (`::` avoids colliding with model ids
+ * that contain a single colon, e.g. `model:tag`). */
+export const modelVisibilityKey = (provider: string, model: string): string => `${provider}::${model}`
+
+/** A model and its optional `…-fast` sibling, collapsed into one logical row.
+ * `id` is the canonical (base) model; `fastId` is the fast variant if present. */
+export interface ModelFamily {
+ fastId: string | null
+ id: string
+}
+
+/** Collapse a provider's model list so a base model and its `…-fast` variant
+ * become a single family (one row, one toggle). Order is preserved by the
+ * base model's position. A `…-fast` model with no base stands on its own. */
+export function collapseModelFamilies(models: readonly string[]): ModelFamily[] {
+ const present = new Set(models)
+ const families: ModelFamily[] = []
+ const consumed = new Set()
+
+ for (const model of models) {
+ if (consumed.has(model)) {
+ continue
+ }
+
+ if (/-fast$/i.test(model) && present.has(model.replace(/-fast$/i, ''))) {
+ // Represented by its base entry — the base attaches it as `fastId`.
+ continue
+ }
+
+ const fastId = `${model}-fast`
+ const hasFast = present.has(fastId)
+ families.push({ fastId: hasFast ? fastId : null, id: model })
+ consumed.add(model)
+
+ if (hasFast) {
+ consumed.add(fastId)
+ }
+ }
+
+ return families
+}
+
+function loadVisible(): Set | null {
+ const raw = storedString(STORAGE_KEY)
+
+ if (!raw) {
+ return null
+ }
+
+ try {
+ const parsed = JSON.parse(raw)
+
+ return Array.isArray(parsed) ? new Set(parsed.filter((x): x is string => typeof x === 'string')) : null
+ } catch {
+ return null
+ }
+}
+
+/** Explicit set of visible `provider::model` keys, or null when the user
+ * hasn't customized — in which case the curated default applies. */
+export const $visibleModels = atom | null>(loadVisible())
+
+export const $modelVisibilityOpen = atom(false)
+
+export function setVisibleModels(keys: Set): void {
+ $visibleModels.set(new Set(keys))
+ persistString(STORAGE_KEY, JSON.stringify([...keys]))
+}
+
+export function setModelVisibilityOpen(open: boolean): void {
+ $modelVisibilityOpen.set(open)
+}
+
+/** The default-visible key set: the curated top-N per provider. Used both as
+ * the dropdown fallback and to seed the Edit Models dialog. */
+export function defaultVisibleKeys(providers: readonly ModelOptionProvider[]): Set {
+ const keys = new Set()
+
+ for (const provider of providers) {
+ const families = collapseModelFamilies(provider.models ?? [])
+
+ for (const family of families.slice(0, DEFAULT_VISIBLE_PER_PROVIDER)) {
+ keys.add(modelVisibilityKey(provider.slug, family.id))
+ }
+ }
+
+ return keys
+}
+
+/** Resolve which keys are currently visible: the user's explicit set when
+ * configured, otherwise the curated default for the given providers. */
+export function effectiveVisibleKeys(
+ stored: Set | null,
+ providers: readonly ModelOptionProvider[]
+): Set {
+ return stored ?? defaultVisibleKeys(providers)
+}
diff --git a/apps/desktop/src/types/hermes.ts b/apps/desktop/src/types/hermes.ts
index 618ae2d81..56d28c0c5 100644
--- a/apps/desktop/src/types/hermes.ts
+++ b/apps/desktop/src/types/hermes.ts
@@ -216,6 +216,14 @@ export interface ModelOptionProvider {
free_tier?: boolean
/** Nous only: paid models a free-tier user cannot select (shown disabled). */
unavailable_models?: string[]
+ /** Per-model option support, keyed by model id (present when the picker
+ * requested capabilities). Lets the UI gate fast/reasoning controls. */
+ capabilities?: Record
+}
+
+export interface ModelCapabilities {
+ fast: boolean
+ reasoning: boolean
}
export interface ModelOptionsResponse {
diff --git a/hermes_cli/inventory.py b/hermes_cli/inventory.py
index 1e7fc8620..89e3bd702 100644
--- a/hermes_cli/inventory.py
+++ b/hermes_cli/inventory.py
@@ -115,6 +115,7 @@ def build_models_payload(
picker_hints: bool = False,
canonical_order: bool = False,
pricing: bool = False,
+ capabilities: bool = False,
max_models: int = 50,
) -> dict:
"""Build the ``{providers, model, provider}`` shape every consumer
@@ -134,6 +135,10 @@ def build_models_payload(
show $/Mtok columns and gate paid models on free accounts —
mirroring the ``hermes model`` CLI picker. Adds network calls
(pricing fetch + Nous tier check); only set for interactive pickers.
+ - ``capabilities``: add a per-row ``capabilities`` map
+ ``{model: {fast, reasoning}}`` so pickers can gate the model-options
+ controls (fast toggle / reasoning) to what each model actually
+ supports, instead of offering knobs the backend would reject.
"""
from hermes_cli.model_switch import list_authenticated_providers
@@ -154,6 +159,8 @@ def build_models_payload(
rows = _reorder_canonical(rows)
if pricing:
_apply_pricing(rows)
+ if capabilities:
+ _apply_capabilities(rows)
return {
"providers": rows,
@@ -162,6 +169,44 @@ def build_models_payload(
}
+def _apply_capabilities(rows: list[dict]) -> None:
+ """Attach a ``{model: {fast, reasoning}}`` map to each provider row.
+
+ `fast` mirrors ``model_supports_fast_mode`` (the same gate the runtime
+ enforces). `reasoning` comes from the models.dev catalog when known and
+ defaults to True otherwise — the effort dial is broadly accepted and a
+ no-op on models that ignore it, whereas hiding it from a capable-but-
+ uncatalogued model is the worse failure.
+ """
+ from hermes_cli.models import model_supports_fast_mode
+
+ try:
+ from agent.models_dev import get_model_capabilities
+ except Exception:
+ get_model_capabilities = None # type: ignore[assignment]
+
+ for row in rows:
+ slug = row.get("slug") or ""
+ caps: dict[str, dict[str, bool]] = {}
+
+ for model in row.get("models") or []:
+ reasoning = True
+ if get_model_capabilities is not None and slug:
+ try:
+ meta = get_model_capabilities(slug, model)
+ if meta is not None:
+ reasoning = bool(meta.supports_reasoning)
+ except Exception:
+ reasoning = True
+
+ caps[model] = {
+ "fast": bool(model_supports_fast_mode(model)),
+ "reasoning": reasoning,
+ }
+
+ row["capabilities"] = caps
+
+
# ─── Internal: row post-processing ──────────────────────────────────────
diff --git a/hermes_cli/models.py b/hermes_cli/models.py
index f8f541c89..558eb008a 100644
--- a/hermes_cli/models.py
+++ b/hermes_cli/models.py
@@ -1868,19 +1868,21 @@ def model_supports_fast_mode(model_id: Optional[str]) -> bool:
def _is_anthropic_fast_model(model_id: Optional[str]) -> bool:
- """Return True if the model is a Claude model eligible for Anthropic Fast Mode.
+ """Return True if the model accepts the Anthropic Fast Mode ``speed`` param.
- Fast mode is currently supported on Claude Opus 4.6 only. Per Anthropic's
- docs (https://platform.claude.com/docs/en/build-with-claude/fast-mode):
- "Fast mode is currently supported on Opus 4.6 only. Sending speed: fast
- with an unsupported model returns an error." Opus 4.7 explicitly rejects
- the ``speed`` parameter with HTTP 400.
+ This gates the *speed=fast request parameter*, which Anthropic supports on
+ Opus 4.6 only (Opus 4.7 explicitly 400s). It is deliberately NOT a general
+ "is this a fast model" check: for Opus 4.8 the fast offering is a SEPARATE
+ model id (``…-opus-4.8-fast``) selected via the model field, not the speed
+ parameter — see ``agent.anthropic_adapter._supports_fast_mode`` and its
+ test. Keep this in lock-step with that adapter gate so the UI never shows a
+ Fast toggle that the runtime would silently drop.
"""
raw = _strip_vendor_prefix(str(model_id or ""))
base = raw.split(":")[0]
if not base.startswith("claude-"):
return False
- # Only Opus 4.6 supports fast mode at present.
+ # Only Opus 4.6 supports the speed=fast parameter at present.
return "opus-4-6" in base or "opus-4.6" in base
diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py
index 7842465a5..11ceefc9d 100644
--- a/hermes_cli/web_server.py
+++ b/hermes_cli/web_server.py
@@ -1665,7 +1665,9 @@ def get_model_options():
try:
from hermes_cli.inventory import build_models_payload, load_picker_context
- return build_models_payload(load_picker_context(), max_models=50, pricing=True)
+ return build_models_payload(
+ load_picker_context(), max_models=50, pricing=True, capabilities=True
+ )
except Exception:
_log.exception("GET /api/model/options failed")
raise HTTPException(status_code=500, detail="Failed to list model options")
diff --git a/tests/cli/test_fast_command.py b/tests/cli/test_fast_command.py
index a98ae7544..7745737c4 100644
--- a/tests/cli/test_fast_command.py
+++ b/tests/cli/test_fast_command.py
@@ -128,11 +128,11 @@ class TestPriorityProcessingModels(unittest.TestCase):
assert model_supports_fast_mode(model), f"{model} should support fast mode"
def test_all_anthropic_models_supported(self):
- """Per Anthropic docs, fast mode is currently Opus 4.6 only.
+ """The speed=fast parameter is gated to Opus 4.6.
Sending speed=fast to Opus 4.7, Sonnet, or Haiku returns HTTP 400.
- Pre-fix this test asserted all Claude variants supported fast mode,
- which mirrored the bug rather than the API contract.
+ (Opus 4.8's fast offering is a separate ``…-fast`` model id selected
+ via the model field, not this parameter — see the adapter test.)
"""
from hermes_cli.models import model_supports_fast_mode
@@ -144,16 +144,15 @@ class TestPriorityProcessingModels(unittest.TestCase):
for model in supported:
assert model_supports_fast_mode(model), f"{model} should support fast mode"
- # Unsupported per Anthropic API: Opus 4.7, Sonnet, Haiku
+ # Unsupported per Anthropic API: Opus 4.7/4.8, Sonnet, Haiku
unsupported = [
- "claude-opus-4-7",
+ "claude-opus-4-7", "claude-opus-4-8", "claude-opus-4.8",
"claude-sonnet-4-6", "claude-sonnet-4.6", "claude-sonnet-4",
"claude-haiku-4-5", "claude-3-5-haiku",
]
for model in unsupported:
assert not model_supports_fast_mode(model), (
- f"{model} should NOT support fast mode — Anthropic restricts "
- f"speed=fast to Opus 4.6"
+ f"{model} should NOT support the speed=fast parameter"
)
def test_codex_models_excluded(self):
@@ -275,10 +274,11 @@ class TestAnthropicFastMode(unittest.TestCase):
assert model_supports_fast_mode("anthropic/claude-opus-4.6") is True
def test_anthropic_non_opus46_models_excluded(self):
- """Anthropic restricts fast mode to Opus 4.6 — others must be excluded.
+ """The speed=fast parameter is gated to Opus 4.6 — others excluded.
Per https://platform.claude.com/docs/en/build-with-claude/fast-mode,
sending speed=fast to Opus 4.7, Sonnet, or Haiku returns HTTP 400.
+ Opus 4.8 uses a separate ``…-fast`` model id, not this parameter.
"""
from hermes_cli.models import model_supports_fast_mode
@@ -286,6 +286,7 @@ class TestAnthropicFastMode(unittest.TestCase):
assert model_supports_fast_mode("claude-sonnet-4.6") is False
assert model_supports_fast_mode("claude-haiku-4-5") is False
assert model_supports_fast_mode("claude-opus-4-7") is False
+ assert model_supports_fast_mode("claude-opus-4-8") is False
assert model_supports_fast_mode("anthropic/claude-sonnet-4.6") is False
assert model_supports_fast_mode("anthropic/claude-opus-4-7") is False
@@ -314,13 +315,15 @@ class TestAnthropicFastMode(unittest.TestCase):
assert result == {"speed": "fast"}
def test_resolve_overrides_returns_none_for_unsupported_claude(self):
- """Opus 4.7 and other Claude models don't support fast mode (API 400s).
+ """Opus 4.7/4.8 and other Claude models don't take the speed param.
- Per Anthropic docs, fast mode is currently Opus 4.6 only.
+ The speed=fast parameter is Opus 4.6 only (Opus 4.8 uses a separate
+ ``…-fast`` model id instead).
"""
from hermes_cli.models import resolve_fast_mode_overrides
assert resolve_fast_mode_overrides("claude-opus-4-7") is None
+ assert resolve_fast_mode_overrides("claude-opus-4-8") is None
assert resolve_fast_mode_overrides("claude-sonnet-4-6") is None
assert resolve_fast_mode_overrides("claude-haiku-4-5") is None
@@ -332,7 +335,7 @@ class TestAnthropicFastMode(unittest.TestCase):
assert result == {"service_tier": "priority"}
def test_is_anthropic_fast_model(self):
- """Fast mode is currently Opus 4.6 only — other Claude variants must be excluded."""
+ """The speed=fast parameter is Opus 4.6 only — other Claude excluded."""
from hermes_cli.models import _is_anthropic_fast_model
# Supported: Opus 4.6 in any form
@@ -341,8 +344,9 @@ class TestAnthropicFastMode(unittest.TestCase):
assert _is_anthropic_fast_model("anthropic/claude-opus-4-6") is True
assert _is_anthropic_fast_model("claude-opus-4.6:fast") is True
- # Unsupported per Anthropic API contract — would 400 if we sent speed=fast
+ # Unsupported — would 400 (4.7) or uses a separate model id (4.8)
assert _is_anthropic_fast_model("claude-opus-4-7") is False
+ assert _is_anthropic_fast_model("claude-opus-4-8") is False
assert _is_anthropic_fast_model("claude-sonnet-4-6") is False
assert _is_anthropic_fast_model("claude-haiku-4-5") is False
@@ -368,7 +372,7 @@ class TestAnthropicFastMode(unittest.TestCase):
assert cli_mod.HermesCLI._fast_command_available(stub) is False
def test_fast_command_hidden_for_anthropic_opus_47(self):
- """Opus 4.7 doesn't support fast mode — /fast must be hidden."""
+ """Opus 4.7 doesn't take the speed=fast parameter — /fast must hide."""
cli_mod = _import_cli()
stub = SimpleNamespace(
provider="anthropic", requested_provider="anthropic",
diff --git a/tui_gateway/server.py b/tui_gateway/server.py
index 7ef2a4cd1..a70dd3efe 100644
--- a/tui_gateway/server.py
+++ b/tui_gateway/server.py
@@ -6659,6 +6659,7 @@ def _(rid, params: dict) -> dict:
picker_hints=True,
canonical_order=True,
pricing=True,
+ capabilities=True,
max_models=50,
)
return _ok(rid, payload)