feat(desktop): surface every provider + models from hermes model in the GUI menus (#40563)

* feat(desktop): surface every provider + models from `hermes model` in the GUI

The desktop GUI's model/provider choices were starved relative to the
`hermes model` CLI. Onboarding listed ~8 providers, Settings → Model only
showed authenticated ones, because the global `/api/model/options` endpoint
called build_models_payload() without the full-universe flags the TUI's
model.options JSON-RPC already used.

- web_server.py: `/api/model/options` now passes include_unconfigured +
  picker_hints + canonical_order (matching the TUI handler), so every GUI
  surface fed by it sees all 37 canonical providers with auth hints.
- Settings → Model: provider dropdown lists every provider; picking an
  unconfigured api_key provider shows an inline 'paste key → Activate' flow
  (auto-selects the recommended default); OAuth/external route to onboarding.
- Onboarding: the API-key form is now driven by the full provider catalog
  (curated five first, then the rest), not a hand-maintained list of five.
- types/hermes.ts: ModelOptionProvider gains authenticated/auth_type/key_env.
- Tests: model-settings covers the full-universe list + inline activation;
  fixed a pre-existing stale assertion (nous / hermes-4 was never rendered).

* feat(desktop): /model in GUI chat opens the model picker instead of a dead-end notice

Typing /model in a desktop chat session printed "/model uses the desktop
model picker instead of a slash command" and did nothing — it never opened
the picker. (The slash worker can't render the prompt_toolkit modal /model
opens in the CLI, so the desktop just showed the unavailable-notice.)

- use-prompt-actions.ts: intercept /model client-side. No args → open the
  desktop model picker overlay (setModelPickerOpen) — the same full
  provider+model picker as the status-bar button. With args (/model <name>
  [--provider ...]) → run the switch directly via slash.exec so power users
  can still type it.
- desktop-slash-commands.ts: export isModelPickerCommand() so the hook can
  detect picker-owned commands without duplicating the PICKER_OWNED_COMMANDS set.
- Test: covers isModelPickerCommand for /model (+ args) vs non-picker commands.

* fix(desktop): make onboarding provider lists scrollable + clean up card styling

The full-catalog onboarding picker could overflow the modal with no way to
scroll — the OAuth provider list and the api-key grid both grew past the
viewport, hiding the key input and the bottom action row (overflow-hidden card,
no scroll container).

- Scope a `max-h-[60dvh] overflow-y-auto` region to just the provider list /
  api-key card grid; the "other providers" disclosure, key input, and action
  row stay pinned and reachable.
- Inner `p-1` so card borders / focus rings aren't clipped by the scroll viewport.
- Flatter card styling: drop the persistent border, the redundant selected-state
  checkmark, and the modal shadow — selection now reads from the ring alone (the
  muted "already configured" check stays).
- Remove the " — set up" suffix from the Settings → Model provider dropdown; the
  inline setup flow already signals unconfigured providers.

* fix(desktop): identify api-key onboarding cards by env var, not id

Selecting "Google Gemini" also highlighted "Google AI Studio": the curated
catalog and the backend-derived providers can collide on `id` (a provider slug
can equal a curated id like `gemini`), so `option.id === o.id` matched two
cards at once. Key selection (and the React key + snap-back effect) on `envKey`
instead, which the catalog dedups and is therefore unique per card.

---------

Co-authored-by: Brooklyn Nicholson <brooklyn.bb.nicholson@gmail.com>
This commit is contained in:
Teknium 2026-06-06 09:31:34 -07:00 committed by GitHub
parent 3606307339
commit e8c837c921
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 411 additions and 74 deletions

View file

@ -2,8 +2,8 @@ import type { AppendMessage, ThreadMessage } from '@assistant-ui/react'
import { type MutableRefObject, useCallback } from 'react'
import { getProfiles, transcribeAudio } from '@/hermes'
import { type Translations, translateNow, useI18n } from '@/i18n'
import { appendTextPart, branchGroupForUser, type ChatMessage, chatMessageText, textPart } from '@/lib/chat-messages'
import { translateNow, type Translations, useI18n } from '@/i18n'
import { branchGroupForUser, type ChatMessage, chatMessageText, textPart } from '@/lib/chat-messages'
import {
attachmentDisplayText,
parseCommandDispatch,
@ -15,7 +15,8 @@ import {
type CommandsCatalogLike,
desktopSlashUnavailableMessage,
filterDesktopCommandsCatalog,
isDesktopSlashCommand
isDesktopSlashCommand,
isModelPickerCommand
} from '@/lib/desktop-slash-commands'
import { triggerHaptic } from '@/lib/haptics'
import { setMutableRef } from '@/lib/mutable-ref'
@ -38,6 +39,7 @@ import {
setAwaitingResponse,
setBusy,
setMessages,
setModelPickerOpen,
setSessions,
setYoloActive
} from '@/store/session'
@ -159,6 +161,7 @@ export function usePromptActions({
}: PromptActionsOptions) {
const { t } = useI18n()
const copy = t.desktop
const appendSessionTextMessage = useCallback(
(sessionId: string, role: ChatMessage['role'], text: string) => {
const body = text.trim()
@ -454,6 +457,49 @@ export function usePromptActions({
return
}
// /model opens the desktop model picker overlay — the same full
// provider+model picker reachable from the status-bar model button —
// instead of the headless prompt_toolkit modal the slash worker can't
// render. With explicit args (`/model <name> [--provider ...]`) run the
// switch directly through slash.exec so power users can still type it.
if (isModelPickerCommand(`/${normalizedName}`)) {
if (!arg.trim()) {
setModelPickerOpen(true)
return
}
const sid = sessionHint || activeSessionIdRef.current || (await createBackendSessionForSend())
if (!sid) {
notify({ kind: 'error', title: 'Session unavailable', message: 'Could not create a new session' })
return
}
try {
const result = await requestGateway<SlashExecResponse>('slash.exec', {
session_id: sid,
command: command.replace(/^\/+/, '')
})
const body = result?.output || `/${name}: model switched`
appendSessionTextMessage(
sid,
'system',
recordInput ? slashStatusText(command, body) : body
)
} catch (err) {
appendSessionTextMessage(
sid,
'system',
`error: ${err instanceof Error ? err.message : String(err)}`
)
}
return
}
if (normalizedName === 'skin' && !sessionHint && !activeSessionIdRef.current) {
notify({ kind: 'success', message: handleSkinCommand(arg) })
@ -547,6 +593,7 @@ export function usePromptActions({
session_id: sessionId,
title: arg
})
const finalTitle = (result?.title || arg).trim()
const queued = result?.pending === true

View file

@ -1,28 +1,52 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
// Radix Select calls scrollIntoView on its items when the content opens; jsdom
// doesn't implement it (nor hasPointerCapture / releasePointerCapture), so stub
// them to let the dropdown open in tests.
beforeAll(() => {
Element.prototype.scrollIntoView = vi.fn()
Element.prototype.hasPointerCapture = vi.fn(() => false)
Element.prototype.releasePointerCapture = vi.fn()
})
const getGlobalModelInfo = vi.fn()
const getGlobalModelOptions = vi.fn()
const getAuxiliaryModels = vi.fn()
const setModelAssignment = vi.fn()
const getRecommendedDefaultModel = vi.fn()
const setEnvVar = vi.fn()
const startManualProviderOAuth = vi.fn()
vi.mock('@/hermes', () => ({
getGlobalModelInfo: () => getGlobalModelInfo(),
getGlobalModelOptions: () => getGlobalModelOptions(),
getAuxiliaryModels: () => getAuxiliaryModels(),
setModelAssignment: (body: unknown) => setModelAssignment(body)
setModelAssignment: (body: unknown) => setModelAssignment(body),
getRecommendedDefaultModel: (slug: string) => getRecommendedDefaultModel(slug),
setEnvVar: (key: string, value: string) => setEnvVar(key, value)
}))
vi.mock('@/store/onboarding', () => ({
startManualProviderOAuth: (slug: string) => startManualProviderOAuth(slug)
}))
beforeEach(() => {
getGlobalModelInfo.mockResolvedValue({ provider: 'nous', model: 'hermes-4' })
getGlobalModelOptions.mockResolvedValue({
providers: [{ name: 'Nous', slug: 'nous', models: ['hermes-4', 'hermes-4-mini'] }]
providers: [
{ name: 'Nous', slug: 'nous', models: ['hermes-4', 'hermes-4-mini'], authenticated: true },
// An unconfigured api_key provider — surfaced by the full-universe payload.
{ name: 'DeepSeek', slug: 'deepseek', models: [], authenticated: false, auth_type: 'api_key', key_env: 'DEEPSEEK_API_KEY' }
]
})
getAuxiliaryModels.mockResolvedValue({
main: { provider: 'nous', model: 'hermes-4' },
tasks: [{ task: 'vision', provider: 'auto', model: '', base_url: '' }]
})
setModelAssignment.mockResolvedValue({ provider: 'nous', model: 'hermes-4', gateway_tools: [] })
getRecommendedDefaultModel.mockResolvedValue({ provider: 'deepseek', model: 'deepseek-chat', free_tier: null })
setEnvVar.mockResolvedValue({ ok: true })
})
afterEach(() => {
@ -37,14 +61,43 @@ async function renderModelSettings() {
}
describe('ModelSettings', () => {
it('loads and shows the current main model', async () => {
it('loads the current main model and lists the full provider universe', async () => {
await renderModelSettings()
await waitFor(() => expect(getGlobalModelInfo).toHaveBeenCalled())
// The current model is loaded into the main-slot selectors (provider name
// + model id), not a standalone label.
expect(await screen.findByText('Nous')).toBeTruthy()
expect(screen.getByText('hermes-4')).toBeTruthy()
await waitFor(() => expect(getGlobalModelOptions).toHaveBeenCalled())
// Open the provider Select — every provider from the full payload should be
// listed, including the unconfigured one with its "set up" hint.
const triggers = await screen.findAllByRole('combobox')
fireEvent.click(triggers[0])
// "Nous" shows in both the trigger and the open list; the unconfigured
// provider + its setup hint are the unique signal of the full universe.
expect((await screen.findAllByText('Nous')).length).toBeGreaterThan(0)
expect(await screen.findByText(/DeepSeek/)).toBeTruthy()
expect(await screen.findByText(/set up/)).toBeTruthy()
})
it('activates an unconfigured api_key provider inline by saving its key', async () => {
await renderModelSettings()
await waitFor(() => expect(getGlobalModelOptions).toHaveBeenCalled())
// Open the provider Select and pick the unconfigured provider.
const triggers = screen.getAllByRole('combobox')
fireEvent.click(triggers[0])
const deepseekOption = await screen.findByText(/DeepSeek/)
fireEvent.click(deepseekOption)
// The inline key input appears for an api_key provider that needs setup.
const keyInput = await screen.findByPlaceholderText(/Paste DEEPSEEK_API_KEY/)
fireEvent.change(keyInput, { target: { value: 'sk-test-123' } })
const activate = await screen.findByRole('button', { name: /Activate/ })
fireEvent.click(activate)
await waitFor(() => expect(setEnvVar).toHaveBeenCalledWith('DEEPSEEK_API_KEY', 'sk-test-123'))
})
it('renders the auxiliary task rows', async () => {

View file

@ -1,16 +1,33 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { getAuxiliaryModels, getGlobalModelInfo, getGlobalModelOptions, setModelAssignment } from '@/hermes'
import {
getAuxiliaryModels,
getGlobalModelInfo,
getGlobalModelOptions,
getRecommendedDefaultModel,
setEnvVar,
setModelAssignment
} from '@/hermes'
import type { AuxiliaryModelsResponse, ModelOptionProvider, StaleAuxAssignment } from '@/hermes'
import { useI18n } from '@/i18n'
import { AlertTriangle, Cpu, Loader2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { startManualProviderOAuth } from '@/store/onboarding'
import { CONTROL_TEXT } from './constants'
import { ListRow, LoadingState, Pill, SectionHeading } from './primitives'
// 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`
// and an empty `models` list — those need a setup step before a model exists.
function isProviderReady(p?: ModelOptionProvider): boolean {
return !!p && (p.authenticated !== false || (p.models?.length ?? 0) > 0)
}
// Mirrors `_AUX_TASK_SLOTS` in hermes_cli/web_server.py. Friendly labels and
// hints make the assignments readable; raw task keys (vision, mcp, …) are
// opaque to most users.
@ -86,6 +103,10 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
// Aux slots reported stale by the backend immediately after a main-model
// switch (provider differs from the new main). Cleared on next switch/reset.
const [switchStaleAux, setSwitchStaleAux] = useState<StaleAuxAssignment[]>([])
// Inline API-key entry for picking an unconfigured `api_key` provider in
// place — mirrors the onboarding ApiKeyForm but scoped to the model picker.
const [apiKeyDraft, setApiKeyDraft] = useState('')
const [activating, setActivating] = useState(false)
const refresh = useCallback(async () => {
setLoading(true)
@ -116,11 +137,24 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
const providerOptions = providers.length ? providers : NO_PROVIDERS
const selectedProviderModels = useMemo(
() => providers.find(provider => provider.slug === selectedProvider)?.models ?? [],
const selectedProviderRow = useMemo(
() => providers.find(provider => provider.slug === selectedProvider),
[providers, selectedProvider]
)
const selectedProviderModels = selectedProviderRow?.models ?? []
// An unconfigured provider was picked: no credentials yet, so there are no
// models to choose. `api_key` providers can be activated inline (paste key);
// OAuth / external flows hand off to the onboarding sign-in.
const needsSetup = !!selectedProvider && !isProviderReady(selectedProviderRow)
const setupIsApiKey = needsSetup && selectedProviderRow?.auth_type === 'api_key' && !!selectedProviderRow?.key_env
// Clear any half-typed key when switching provider so it can't leak across.
useEffect(() => {
setApiKeyDraft('')
}, [selectedProvider])
const auxDraftProviderModels = useMemo(
() => providers.find(provider => provider.slug === auxDraft.provider)?.models ?? [],
[auxDraft.provider, providers]
@ -133,17 +167,70 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
// "I pinned aux months ago and forgot, now it bills a dead provider" case.
const persistentStaleAux = useMemo<StaleAuxAssignment[]>(() => {
const mainProvider = (mainModel?.provider ?? '').toLowerCase()
if (!mainProvider || !auxiliary) {
return []
}
return auxiliary.tasks
.filter(entry => {
const p = (entry.provider ?? '').toLowerCase()
return p && p !== 'auto' && p !== mainProvider
})
.map(entry => ({ task: entry.task, provider: entry.provider, model: entry.model }))
}, [auxiliary, mainModel])
// Paste an API key for the selected `api_key` provider, persist it, then
// refresh so the now-authenticated provider's models populate. Auto-selects
// the recommended default model so the user can Apply in one more click.
const activateApiKeyProvider = useCallback(async () => {
const keyEnv = selectedProviderRow?.key_env
const slug = selectedProviderRow?.slug
if (!keyEnv || !slug || !apiKeyDraft.trim()) {
return
}
setActivating(true)
setError('')
try {
await setEnvVar(keyEnv, apiKeyDraft.trim())
setApiKeyDraft('')
// Pick a sensible default for the freshly-activated provider (mirrors
// `hermes model` curation). Best-effort — fall through to the refreshed
// model list if it fails.
let nextModel = ''
try {
const rec = await getRecommendedDefaultModel(slug)
nextModel = rec.model || ''
} catch {
nextModel = ''
}
const options = await getGlobalModelOptions()
setProviders(options.providers || [])
const refreshedRow = options.providers?.find(p => p.slug === slug)
const fallbackModel = refreshedRow?.models?.[0] ?? ''
setSelectedModel(nextModel || fallbackModel)
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setActivating(false)
}
}, [apiKeyDraft, selectedProviderRow])
// OAuth / external providers can't be activated with a pasted key — hand off
// to the shared onboarding flow scoped to this provider's real sign-in.
const startProviderSetup = useCallback(() => {
if (selectedProviderRow?.slug) {
startManualProviderOAuth(selectedProviderRow.slug)
}
}, [selectedProviderRow])
const applyMainModel = useCallback(async () => {
if (!selectedProvider || !selectedModel) {
return
@ -271,27 +358,68 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
))}
</SelectContent>
</Select>
<Select onValueChange={setSelectedModel} value={selectedModel}>
<SelectTrigger className={cn('min-w-60', CONTROL_TEXT)}>
<SelectValue placeholder={m.model} />
</SelectTrigger>
<SelectContent>
{(selectedProviderModels.length ? selectedProviderModels : []).map(model => (
<SelectItem key={model} value={model}>
{model}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
disabled={!selectedProvider || !selectedModel || applying}
onClick={() => void applyMainModel()}
size="sm"
>
{applying && <Loader2 className="size-3.5 animate-spin" />}
{applying ? m.applying : t.common.apply}
</Button>
{needsSetup ? (
setupIsApiKey ? (
<>
<Input
autoComplete="off"
className={cn('min-w-60 flex-1', CONTROL_TEXT)}
onChange={event => setApiKeyDraft(event.target.value)}
onKeyDown={event => {
if (event.key === 'Enter') {
void activateApiKeyProvider()
}
}}
placeholder={`Paste ${selectedProviderRow?.key_env ?? 'API key'}`}
type="password"
value={apiKeyDraft}
/>
<Button
disabled={!apiKeyDraft.trim() || activating}
onClick={() => void activateApiKeyProvider()}
size="sm"
>
{activating && <Loader2 className="size-3.5 animate-spin" />}
{activating ? 'Activating...' : 'Activate'}
</Button>
</>
) : (
<Button onClick={startProviderSetup} size="sm" variant="textStrong">
Set up {selectedProviderRow?.name ?? 'provider'}
</Button>
)
) : (
<>
<Select onValueChange={setSelectedModel} value={selectedModel}>
<SelectTrigger className={cn('min-w-60', CONTROL_TEXT)}>
<SelectValue placeholder={m.model} />
</SelectTrigger>
<SelectContent>
{(selectedProviderModels.length ? selectedProviderModels : []).map(model => (
<SelectItem key={model} value={model}>
{model}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
disabled={!selectedProvider || !selectedModel || applying}
onClick={() => void applyMainModel()}
size="sm"
>
{applying && <Loader2 className="size-3.5 animate-spin" />}
{applying ? m.applying : t.common.apply}
</Button>
</>
)}
</div>
{needsSetup && !setupIsApiKey && (
<p className="mt-2 text-xs text-muted-foreground">
{selectedProviderRow?.auth_type === 'api_key'
? `${selectedProviderRow?.name} needs an API key — set it up to choose a model.`
: `${selectedProviderRow?.name} signs in through your browser — Hermes runs the flow for you.`}
</p>
)}
{error && <div className="mt-2 text-xs text-destructive">{error}</div>}
{switchStaleAux.length > 0 && (
<div className="mt-2">

View file

@ -43,7 +43,7 @@ import {
startProviderOAuth,
submitOnboardingCode
} from '@/store/onboarding'
import type { OAuthProvider } from '@/types/hermes'
import type { ModelOptionProvider, OAuthProvider } from '@/types/hermes'
interface DesktopOnboardingOverlayProps {
enabled: boolean
@ -95,6 +95,74 @@ const API_KEY_OPTIONS: ApiKeyOption[] = [
}
]
// Build the FULL API-key provider catalog from the backend model options so the
// onboarding / Providers key form lists every `api_key` provider `hermes model`
// knows about — not just the hand-curated five. Curated entries keep their
// richer copy + placeholders and float to the top (recommended defaults); every
// other api_key provider is appended with a generic "paste {KEY}" affordance.
// OAuth / external providers are intentionally excluded here — they go through
// the OAuth picker / sign-in flow, not a pasted key.
function useApiKeyCatalog(): ApiKeyOption[] {
const [rows, setRows] = useState<ModelOptionProvider[]>([])
useEffect(() => {
let cancelled = false
// Best-effort — on failure the curated defaults still render. Wrapped in
// Promise.resolve().then so a synchronous throw (e.g. no desktop bridge in
// tests) is funneled into the same .catch instead of escaping.
void Promise.resolve()
.then(() => getGlobalModelOptions())
.then(res => {
if (!cancelled) {
setRows(res.providers ?? [])
}
})
.catch(() => {
// Ignore — fall back to the curated API_KEY_OPTIONS only.
})
return () => {
cancelled = true
}
}, [])
return useMemo(() => {
const curatedByEnv = new Map(API_KEY_OPTIONS.map(o => [o.envKey, o]))
const derived: ApiKeyOption[] = []
const seenEnv = new Set<string>(API_KEY_OPTIONS.map(o => o.envKey))
for (const row of rows) {
// Only api_key providers can be activated with a pasted key. Skip OAuth /
// external / managed flows and anything missing an env var to write to.
if (row.auth_type && row.auth_type !== 'api_key') {
continue
}
const envKey = row.key_env
if (!envKey || seenEnv.has(envKey)) {
continue
}
seenEnv.add(envKey)
derived.push({
id: row.slug,
name: row.name,
envKey,
description: `Direct API access to ${row.name}.`,
docsUrl: ''
})
}
// Curated first (recommended order), then the rest alphabetically so the
// long tail is scannable.
derived.sort((a, b) => a.name.localeCompare(b.name))
return [...API_KEY_OPTIONS.filter(o => curatedByEnv.has(o.envKey)), ...derived]
}, [rows])
}
const PROVIDER_DISPLAY: Record<string, { order: number; title: string }> = {
nous: { order: 0, title: 'Nous Portal' },
'openai-codex': { order: 1, title: 'OpenAI OAuth (ChatGPT)' },
@ -193,7 +261,7 @@ export function DesktopOnboardingOverlay({ enabled, onCompleted, requestGateway
return (
<div className="fixed inset-0 z-1300 flex items-center justify-center bg-(--ui-chat-surface-background) p-6">
<div className="relative w-full max-w-[45rem] overflow-hidden rounded-xl border border-(--ui-stroke-secondary) bg-(--ui-chat-bubble-background) shadow-sm">
<div className="relative w-full max-w-[45rem] overflow-hidden rounded-xl border border-(--ui-stroke-secondary) bg-(--ui-chat-bubble-background)">
<Header />
{onboarding.manual ? (
<Button
@ -235,9 +303,7 @@ function Preparing({ boot }: { boot: DesktopBootState }) {
return (
<div className="grid gap-3" role="status">
<p className="text-sm text-muted-foreground">
{installing
? t.onboarding.preparingInstall
: t.onboarding.starting}
{installing ? t.onboarding.preparingInstall : t.onboarding.starting}
</p>
<div className="h-2 overflow-hidden rounded-full bg-muted">
<div
@ -304,6 +370,7 @@ export function Picker({ ctx }: { ctx: OnboardingContext }) {
const [showAll, setShowAll] = useState(readShowAll)
const ordered = useMemo(() => (providers ? sortProviders(providers) : []), [providers])
const hasOauth = ordered.length > 0
const apiKeyOptions = useApiKeyCatalog()
if (mode === 'apikey' || !hasOauth) {
return (
@ -312,6 +379,7 @@ export function Picker({ ctx }: { ctx: OnboardingContext }) {
canGoBack={hasOauth}
onBack={() => setOnboardingMode('oauth')}
onSave={(envKey, value, name) => saveOnboardingApiKey(envKey, value, name, ctx)}
options={apiKeyOptions}
/>
{manual ? null : (
<div className="flex justify-center border-t border-(--ui-stroke-tertiary) pt-3">
@ -336,15 +404,17 @@ export function Picker({ ctx }: { ctx: OnboardingContext }) {
return (
<div className="grid gap-2">
{featured ? <FeaturedProviderRow onSelect={select} provider={featured} /> : null}
{showRest ? (
<>
{rest.map(p => (
<ProviderRow key={p.id} onSelect={select} provider={p} />
))}
<KeyProviderRow onClick={() => setOnboardingMode('apikey')} />
</>
) : null}
<div className="grid max-h-[60dvh] gap-2 overflow-y-auto p-1">
{featured ? <FeaturedProviderRow onSelect={select} provider={featured} /> : null}
{showRest ? (
<>
{rest.map(p => (
<ProviderRow key={p.id} onSelect={select} provider={p} />
))}
<KeyProviderRow onClick={() => setOnboardingMode('apikey')} />
</>
) : null}
</div>
{collapsible ? (
<button
className="flex items-center justify-center gap-1.5 pt-1 text-xs font-medium text-muted-foreground transition hover:text-foreground"
@ -481,9 +551,7 @@ export function ProviderRow({
</span>
{loggedIn ? <ConnectedTag /> : null}
</div>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
{t.onboarding.flowSubtitles[provider.flow]}
</p>
<p className="mt-1 text-xs leading-5 text-muted-foreground">{t.onboarding.flowSubtitles[provider.flow]}</p>
</div>
<Trail className="size-4 text-muted-foreground transition group-hover:text-foreground" />
</button>
@ -521,12 +589,12 @@ export function ApiKeyForm({
// Providers page wiring its search into this grid). Keep the selection valid
// by snapping back to the first remaining option when the current one drops.
useEffect(() => {
if (options.length > 0 && !options.some(o => o.id === option.id)) {
if (options.length > 0 && !options.some(o => o.envKey === option.envKey)) {
setOption(options[0])
setValue('')
setError(null)
}
}, [option.id, options])
}, [option.envKey, options])
// The catalog grid can be tall, leaving the entry field far below the fold.
// On selection we scroll the field into view and focus it so it's always
// obvious where to paste next.
@ -584,29 +652,23 @@ export function ApiKeyForm({
</button>
) : null}
<div className="grid gap-2 sm:grid-cols-2">
<div className="grid max-h-[60dvh] gap-2 overflow-y-auto p-1 sm:grid-cols-2">
{options.map(o => (
<button
className={cn(
'rounded-2xl border bg-background/60 p-3 text-left transition hover:bg-accent/50',
option.id === o.id ? 'border-primary ring-2 ring-primary/20' : 'border-border'
option.envKey === o.envKey ? 'border-primary ring-2 ring-primary/20' : 'border-transparent'
)}
key={o.id}
key={o.envKey}
onClick={() => pick(o)}
type="button"
>
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium">{o.name}</span>
{option.id === o.id ? (
<Check className="size-4 text-primary" />
) : isSet?.(o.envKey) ? (
<Check className="size-3.5 text-muted-foreground" />
) : null}
{isSet?.(o.envKey) ? <Check className="size-3.5 text-muted-foreground" /> : null}
</div>
{(t.onboarding.apiKeyOptions[o.id]?.short ?? o.short) ? (
<p className="mt-1 text-xs text-muted-foreground">
{t.onboarding.apiKeyOptions[o.id]?.short ?? o.short}
</p>
<p className="mt-1 text-xs text-muted-foreground">{t.onboarding.apiKeyOptions[o.id]?.short ?? o.short}</p>
) : null}
</button>
))}
@ -624,7 +686,8 @@ export function ApiKeyForm({
onChange={e => setValue(e.target.value)}
onKeyDown={e => e.key === 'Enter' && void submit()}
placeholder={
currentRedacted ?? (alreadySet ? t.onboarding.replaceCurrent : option.placeholder || t.onboarding.pasteApiKey)
currentRedacted ??
(alreadySet ? t.onboarding.replaceCurrent : option.placeholder || t.onboarding.pasteApiKey)
}
type={isLocal ? 'text' : 'password'}
value={value}
@ -717,9 +780,7 @@ function FlowPanel({ ctx, flow }: { ctx: OnboardingContext; flow: OnboardingFlow
if (flow.status === 'awaiting_browser') {
return (
<Step title={t.onboarding.signInWith(title)}>
<p className="text-sm text-muted-foreground">
{t.onboarding.autoBrowser(title)}
</p>
<p className="text-sm text-muted-foreground">{t.onboarding.autoBrowser(title)}</p>
<FlowFooter left={<DocsLink href={flow.start.auth_url}>{t.onboarding.reopenSignInPage}</DocsLink>}>
<span className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="size-3 animate-spin" />
@ -734,12 +795,14 @@ function FlowPanel({ ctx, flow }: { ctx: OnboardingContext; flow: OnboardingFlow
if (flow.status === 'external_pending') {
return (
<Step title={t.onboarding.signInWith(title)}>
<p className="text-sm text-muted-foreground">
{t.onboarding.externalPending(title)}
</p>
<p className="text-sm text-muted-foreground">{t.onboarding.externalPending(title)}</p>
<CodeBlock copied={flow.copied} onCopy={() => void copyExternalCommand()} text={flow.provider.cli_command} />
<FlowFooter
left={flow.provider.docs_url ? <DocsLink href={flow.provider.docs_url}>{t.onboarding.docs(title)}</DocsLink> : null}
left={
flow.provider.docs_url ? (
<DocsLink href={flow.provider.docs_url}>{t.onboarding.docs(title)}</DocsLink>
) : null
}
>
<CancelBtn />
<Button onClick={() => void recheckExternalSignin(ctx)}>

View file

@ -6,7 +6,8 @@ import {
desktopSlashUnavailableMessage,
filterDesktopCommandsCatalog,
isDesktopSlashCommand,
isDesktopSlashSuggestion
isDesktopSlashSuggestion,
isModelPickerCommand
} from './desktop-slash-commands'
describe('desktop slash command curation', () => {
@ -115,4 +116,11 @@ describe('desktop slash command curation', () => {
expect(desktopSlashUnavailableMessage('/skills')).toContain('desktop sidebar')
expect(desktopSlashUnavailableMessage('/clear')).toContain('terminal interface')
})
it('flags /model as a picker-owned command so the desktop opens the overlay', () => {
expect(isModelPickerCommand('/model')).toBe(true)
expect(isModelPickerCommand('/model sonnet')).toBe(true)
expect(isModelPickerCommand('/new')).toBe(false)
expect(isModelPickerCommand('/skills')).toBe(false)
})
})

View file

@ -183,6 +183,18 @@ export function isDesktopSlashSuggestion(command: string): boolean {
return DESKTOP_COMMANDS.has(canonical) && !DESKTOP_ALIASES.has(normalized)
}
/**
* True for commands the desktop fulfils by opening the model picker overlay
* (e.g. `/model`) rather than executing a slash command. The caller opens the
* picker UI instead of printing the "uses the desktop model picker" notice.
*/
export function isModelPickerCommand(command: string): boolean {
const normalized = normalizeCommand(command)
const canonical = canonicalDesktopSlashCommand(normalized)
return PICKER_OWNED_COMMANDS.has(canonical)
}
export function desktopSlashUnavailableMessage(command: string): string | null {
const normalized = normalizeCommand(command)
const canonical = canonicalDesktopSlashCommand(normalized)

View file

@ -213,6 +213,18 @@ export interface ModelOptionProvider {
slug: string
total_models?: number
warning?: string
/** True when the provider has usable credentials. False for canonical
* providers surfaced by `include_unconfigured` that the user hasn't set up
* yet render these with a setup affordance instead of hiding them. */
authenticated?: boolean
/** Auth flow for an unconfigured provider: "api_key" can be activated inline
* by pasting `key_env`; anything else (oauth_*, external, aws_sdk, ) needs
* the `hermes model` CLI / onboarding OAuth flow. */
auth_type?: string
/** Env var to paste an API key into, for unconfigured `api_key` providers. */
key_env?: string
/** True for providers defined via the user's `providers:` config block. */
is_user_defined?: boolean
/** Per-model pricing keyed by model id (present when the picker requested
* pricing and the provider supports live pricing). */
pricing?: Record<string, ModelPricing>

View file

@ -2067,8 +2067,22 @@ def get_model_options():
try:
from hermes_cli.inventory import build_models_payload, load_picker_context
# include_unconfigured + picker_hints + canonical_order mirror the
# tui_gateway `model.options` JSON-RPC handler exactly, so every GUI
# surface fed by this endpoint (Settings → Model, the first-run
# onboarding picker) sees the SAME full provider universe `hermes model`
# exposes — not just the authenticated subset. Unconfigured providers
# come back as skeleton rows carrying `authenticated=False` +
# `auth_type`/`key_env`/`warning` so the GUI can render a setup
# affordance instead of hiding the provider entirely.
return build_models_payload(
load_picker_context(), max_models=50, pricing=True, capabilities=True
load_picker_context(),
max_models=50,
include_unconfigured=True,
picker_hints=True,
canonical_order=True,
pricing=True,
capabilities=True,
)
except Exception:
_log.exception("GET /api/model/options failed")