diff --git a/apps/desktop/src/app/command-palette/index.tsx b/apps/desktop/src/app/command-palette/index.tsx
index ffabbc864be..31a034417fd 100644
--- a/apps/desktop/src/app/command-palette/index.tsx
+++ b/apps/desktop/src/app/command-palette/index.tsx
@@ -36,7 +36,8 @@ import {
Settings,
Sun,
Users,
- Wrench
+ Wrench,
+ Zap
} from '@/lib/icons'
import { cn } from '@/lib/utils'
import { $commandPaletteOpen, closeCommandPalette, setCommandPaletteOpen } from '@/store/command-palette'
@@ -98,8 +99,20 @@ const toSessionEntry = (session: SessionRow): SessionEntry => ({
})
const NON_CONFIG_SETTINGS: ReadonlyArray<{ icon: IconComponent; keywords?: string[]; label: string; tab: string }> = [
+ {
+ icon: Zap,
+ keywords: ['accounts', 'sign in', 'oauth', 'login', 'subscription', 'models', 'anthropic', 'openai'],
+ label: 'Providers',
+ tab: 'providers&pview=accounts'
+ },
+ {
+ icon: KeyRound,
+ keywords: ['providers', 'api key', 'keys', 'secrets', 'tokens'],
+ label: 'Provider API keys',
+ tab: 'providers&pview=keys'
+ },
{ icon: Globe, keywords: ['connection', 'messaging'], label: 'Gateway', tab: 'gateway' },
- { icon: KeyRound, keywords: ['api', 'secrets', 'tokens', 'credentials'], label: 'API Keys', tab: 'keys' },
+ { icon: KeyRound, keywords: ['api', 'secrets', 'tokens', 'credentials'], label: 'Tools & Keys', tab: 'keys' },
{ icon: Wrench, keywords: ['servers', 'tools'], label: 'MCP', tab: 'mcp' },
{ icon: Archive, keywords: ['history', 'archived'], label: 'Archived Chats', tab: 'sessions' },
{ icon: Info, keywords: ['version', 'about'], label: 'About', tab: 'about' }
@@ -169,7 +182,7 @@ export function CommandPalette() {
{
icon: Wrench,
id: 'nav-skills',
- keywords: ['tools', 'toolsets', 'providers'],
+ keywords: ['tools', 'toolsets'],
label: 'Skills & Tools',
run: go(SKILLS_ROUTE)
},
@@ -207,25 +220,9 @@ export function CommandPalette() {
]
},
{
- heading: 'Settings',
- items: [
- ...SECTIONS.map(section => ({
- icon: section.icon,
- id: `set-config-${section.id}`,
- keywords: ['settings', section.label],
- label: section.label,
- run: go(settingsTab(`config:${section.id}`))
- })),
- ...NON_CONFIG_SETTINGS.map(entry => ({
- icon: entry.icon,
- id: `set-${entry.tab}`,
- keywords: ['settings', ...(entry.keywords ?? [])],
- label: entry.label,
- run: go(settingsTab(entry.tab))
- }))
- ]
- },
- {
+ // Declared before Settings: cmdk keeps group order, so this keeps the
+ // theme/mode pickers on top for "theme"/"color" queries instead of
+ // buried under a fuzzy Settings match.
heading: 'Appearance',
items: [
{
@@ -243,6 +240,25 @@ export function CommandPalette() {
to: 'color-mode'
}
]
+ },
+ {
+ heading: 'Settings',
+ items: [
+ ...SECTIONS.map(section => ({
+ icon: section.icon,
+ id: `set-config-${section.id}`,
+ keywords: ['settings', section.label],
+ label: section.label,
+ run: go(settingsTab(`config:${section.id}`))
+ })),
+ ...NON_CONFIG_SETTINGS.map(entry => ({
+ icon: entry.icon,
+ id: `set-${entry.tab}`,
+ keywords: ['settings', ...(entry.keywords ?? [])],
+ label: entry.label,
+ run: go(settingsTab(entry.tab))
+ }))
+ ]
}
]
}, [go])
diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx
index 86a6cca19ea..020455b3314 100644
--- a/apps/desktop/src/app/desktop-controller.tsx
+++ b/apps/desktop/src/app/desktop-controller.tsx
@@ -316,7 +316,7 @@ export function DesktopController() {
})
const openProviderSettings = useCallback(() => {
- navigate(`${SETTINGS_ROUTE}?tab=keys`)
+ navigate(`${SETTINGS_ROUTE}?tab=providers`)
}, [navigate])
const modelMenuContent = useMemo(
diff --git a/apps/desktop/src/app/overlays/overlay-split-layout.tsx b/apps/desktop/src/app/overlays/overlay-split-layout.tsx
index a70e9fc37ff..e713e4ea49e 100644
--- a/apps/desktop/src/app/overlays/overlay-split-layout.tsx
+++ b/apps/desktop/src/app/overlays/overlay-split-layout.tsx
@@ -24,6 +24,9 @@ interface OverlayNavItemProps {
active: boolean
icon: IconComponent
label: string
+ // Renders as an indented child of another nav item: smaller icon and a
+ // lighter active state so it never competes with the boxed parent item.
+ nested?: boolean
onClick: () => void
trailing?: ReactNode
}
@@ -70,19 +73,29 @@ export function OverlayMain({ children, className }: OverlayMainProps) {
)
}
-export function OverlayNavItem({ active, icon: Icon, label, onClick, trailing }: OverlayNavItemProps) {
+export function OverlayNavItem({ active, icon: Icon, label, nested, onClick, trailing }: OverlayNavItemProps) {
return (
-
+
{label}
{trailing}
diff --git a/apps/desktop/src/app/settings/constants.ts b/apps/desktop/src/app/settings/constants.ts
index 09c75d1b17e..1a2891d7257 100644
--- a/apps/desktop/src/app/settings/constants.ts
+++ b/apps/desktop/src/app/settings/constants.ts
@@ -15,9 +15,21 @@ import type { ThemeMode } from '@/themes/context'
import type { DesktopConfigSection } from './types'
+// Provider group definitions used to fold raw env-var names like
+// ``XAI_API_KEY`` into a single "xAI" card with a friendly label, short
+// description, and signup URL. Membership is determined by longest
+// prefix match (see ``providerGroup`` in helpers.ts) so more specific
+// prefixes (``MINIMAX_CN_``) correctly beat their general parents
+// (``MINIMAX_``). New providers should be added here so they get their
+// own card in Settings → Keys instead of being lumped into "Other".
interface ProviderPrefix {
prefix: string
name: string
+ /** Optional one-line tagline shown beneath the group name. */
+ description?: string
+ /** Optional canonical signup/console URL surfaced from the card header. */
+ docsUrl?: string
+ /** Lower numbers float to the top of the providers list. */
priority: number
}
@@ -25,24 +37,180 @@ export const EMPTY_SELECT_VALUE = '__hermes_empty__'
export const CONTROL_TEXT = 'text-xs'
export const PROVIDER_GROUPS: ProviderPrefix[] = [
- { prefix: 'NOUS_', name: 'Nous Portal', priority: 0 },
- { prefix: 'ANTHROPIC_', name: 'Anthropic', priority: 1 },
- { prefix: 'DASHSCOPE_', name: 'DashScope (Qwen)', priority: 2 },
- { prefix: 'HERMES_QWEN_', name: 'DashScope (Qwen)', priority: 2 },
- { prefix: 'DEEPSEEK_', name: 'DeepSeek', priority: 3 },
- { prefix: 'GOOGLE_', name: 'Gemini', priority: 4 },
+ {
+ prefix: 'NOUS_',
+ name: 'Nous Portal',
+ description: 'Hosted Hermes & Nous-trained models',
+ docsUrl: 'https://portal.nousresearch.com',
+ priority: 0
+ },
+ {
+ prefix: 'OPENROUTER_',
+ name: 'OpenRouter',
+ description: 'Aggregator for hundreds of frontier models',
+ docsUrl: 'https://openrouter.ai/keys',
+ priority: 1
+ },
+ {
+ prefix: 'ANTHROPIC_',
+ name: 'Anthropic',
+ description: 'Claude API access (Sonnet, Opus, Haiku)',
+ docsUrl: 'https://console.anthropic.com/settings/keys',
+ priority: 2
+ },
+ {
+ prefix: 'XAI_',
+ name: 'xAI',
+ description: 'Grok models (use OAuth for SuperGrok / Premium+)',
+ docsUrl: 'https://console.x.ai/',
+ priority: 3
+ },
+ {
+ prefix: 'GOOGLE_',
+ name: 'Gemini',
+ description: 'Google AI Studio (Gemini 1.5 / 2.0 / 2.5)',
+ docsUrl: 'https://aistudio.google.com/app/apikey',
+ priority: 4
+ },
{ prefix: 'GEMINI_', name: 'Gemini', priority: 4 },
- { prefix: 'GLM_', name: 'GLM / Z.AI', priority: 5 },
- { prefix: 'ZAI_', name: 'GLM / Z.AI', priority: 5 },
- { prefix: 'Z_AI_', name: 'GLM / Z.AI', priority: 5 },
- { prefix: 'HF_', name: 'Hugging Face', priority: 6 },
- { prefix: 'KIMI_', name: 'Kimi / Moonshot', priority: 7 },
- { prefix: 'MINIMAX_', name: 'MiniMax', priority: 8 },
- { prefix: 'MINIMAX_CN_', name: 'MiniMax (China)', priority: 9 },
- { prefix: 'OPENCODE_GO_', name: 'OpenCode Go', priority: 10 },
- { prefix: 'OPENCODE_ZEN_', name: 'OpenCode Zen', priority: 11 },
- { prefix: 'OPENROUTER_', name: 'OpenRouter', priority: 12 },
- { prefix: 'XIAOMI_', name: 'Xiaomi MiMo', priority: 13 }
+ { prefix: 'HERMES_GEMINI_', name: 'Gemini', priority: 4 },
+ {
+ prefix: 'DEEPSEEK_',
+ name: 'DeepSeek',
+ description: 'Direct DeepSeek API (V3.x, R1)',
+ docsUrl: 'https://platform.deepseek.com/api_keys',
+ priority: 5
+ },
+ {
+ prefix: 'DASHSCOPE_',
+ name: 'DashScope (Qwen)',
+ description: 'Alibaba Cloud DashScope — Qwen and multi-vendor models',
+ docsUrl: 'https://modelstudio.console.alibabacloud.com/',
+ priority: 6
+ },
+ { prefix: 'HERMES_QWEN_', name: 'DashScope (Qwen)', priority: 6 },
+ {
+ prefix: 'GLM_',
+ name: 'GLM / Z.AI',
+ description: 'Zhipu GLM-4.6 and Z.AI hosted endpoints',
+ docsUrl: 'https://z.ai/',
+ priority: 7
+ },
+ { prefix: 'ZAI_', name: 'GLM / Z.AI', priority: 7 },
+ { prefix: 'Z_AI_', name: 'GLM / Z.AI', priority: 7 },
+ {
+ prefix: 'KIMI_',
+ name: 'Kimi / Moonshot',
+ description: 'Moonshot Kimi K2 / coding endpoints',
+ docsUrl: 'https://platform.moonshot.cn/',
+ priority: 8
+ },
+ {
+ prefix: 'KIMI_CN_',
+ name: 'Kimi (China)',
+ description: 'Moonshot China endpoint',
+ docsUrl: 'https://platform.moonshot.cn/',
+ priority: 9
+ },
+ {
+ prefix: 'MINIMAX_',
+ name: 'MiniMax',
+ description: 'MiniMax-M2 and Hailuo international endpoints',
+ docsUrl: 'https://www.minimax.io/',
+ priority: 10
+ },
+ {
+ prefix: 'MINIMAX_CN_',
+ name: 'MiniMax (China)',
+ description: 'MiniMax mainland China endpoint',
+ docsUrl: 'https://www.minimaxi.com/',
+ priority: 11
+ },
+ {
+ prefix: 'HF_',
+ name: 'Hugging Face',
+ description: 'Inference Providers — 20+ open models via router.huggingface.co',
+ docsUrl: 'https://huggingface.co/settings/tokens',
+ priority: 12
+ },
+ {
+ prefix: 'OPENCODE_ZEN_',
+ name: 'OpenCode Zen',
+ description: 'Pay-as-you-go access to curated coding models',
+ docsUrl: 'https://opencode.ai/auth',
+ priority: 13
+ },
+ {
+ prefix: 'OPENCODE_GO_',
+ name: 'OpenCode Go',
+ description: '$10/month subscription for open coding models',
+ docsUrl: 'https://opencode.ai/auth',
+ priority: 14
+ },
+ {
+ prefix: 'NVIDIA_',
+ name: 'NVIDIA NIM',
+ description: 'build.nvidia.com or your own local NIM endpoint',
+ docsUrl: 'https://build.nvidia.com/',
+ priority: 15
+ },
+ {
+ prefix: 'OLLAMA_',
+ name: 'Ollama Cloud',
+ description: 'Cloud-hosted open models from ollama.com',
+ docsUrl: 'https://ollama.com/settings',
+ priority: 16
+ },
+ {
+ prefix: 'LM_',
+ name: 'LM Studio',
+ description: 'Local LM Studio server (OpenAI-compatible)',
+ docsUrl: 'https://lmstudio.ai/docs/local-server',
+ priority: 17
+ },
+ {
+ prefix: 'STEPFUN_',
+ name: 'StepFun',
+ description: 'StepFun Step Plan coding models',
+ docsUrl: 'https://platform.stepfun.com/',
+ priority: 18
+ },
+ {
+ prefix: 'XIAOMI_',
+ name: 'Xiaomi MiMo',
+ description: 'MiMo-V2.5 and Xiaomi proprietary models',
+ docsUrl: 'https://platform.xiaomimimo.com',
+ priority: 19
+ },
+ {
+ prefix: 'ARCEEAI_',
+ name: 'Arcee AI',
+ description: 'Arcee-hosted small + medium models',
+ docsUrl: 'https://chat.arcee.ai/',
+ priority: 20
+ },
+ { prefix: 'ARCEE_', name: 'Arcee AI', priority: 20 },
+ {
+ prefix: 'GMI_',
+ name: 'GMI Cloud',
+ description: 'GMI Cloud GPU + model serving',
+ docsUrl: 'https://www.gmicloud.ai/',
+ priority: 21
+ },
+ {
+ prefix: 'AZURE_FOUNDRY_',
+ name: 'Azure Foundry',
+ description: 'Azure AI Foundry custom endpoints (OpenAI / Anthropic-compatible)',
+ docsUrl: 'https://ai.azure.com/',
+ priority: 22
+ },
+ {
+ prefix: 'AWS_',
+ name: 'AWS Bedrock',
+ description: 'Authenticate via AWS profile + region',
+ docsUrl: 'https://docs.aws.amazon.com/bedrock/latest/userguide/bedrock-regions.html',
+ priority: 23
+ }
]
export const BUILTIN_PERSONALITIES = [
diff --git a/apps/desktop/src/app/settings/env-credentials.tsx b/apps/desktop/src/app/settings/env-credentials.tsx
new file mode 100644
index 00000000000..5bcfd8f9baf
--- /dev/null
+++ b/apps/desktop/src/app/settings/env-credentials.tsx
@@ -0,0 +1,354 @@
+import { useEffect, useState } from 'react'
+
+import { Button } from '@/components/ui/button'
+import { Codicon } from '@/components/ui/codicon'
+import { Input } from '@/components/ui/input'
+import { deleteEnvVar, getEnvVars, revealEnvVar, setEnvVar } from '@/hermes'
+import { Check, Eye, EyeOff, type IconComponent, Save, Trash2 } from '@/lib/icons'
+import { cn } from '@/lib/utils'
+import { notify, notifyError } from '@/store/notifications'
+import type { EnvVarInfo } from '@/types/hermes'
+
+import { CONTROL_TEXT } from './constants'
+import { asText, includesQuery, redactedValue, withoutKey } from './helpers'
+import { Pill } from './primitives'
+import type { EnvRowProps } from './types'
+
+// Shared filter used by every credential surface (Providers + Keys pages):
+// category gate first, then a free-text match across key name + description.
+export function filterEnv(info: EnvVarInfo, key: string, q: string, cat: string, extra?: string): boolean {
+ if (asText(info.category) !== cat) {
+ return false
+ }
+
+ if (!q) {
+ return true
+ }
+
+ return (
+ key.toLowerCase().includes(q) ||
+ includesQuery(info.description, q) ||
+ Boolean(extra && extra.toLowerCase().includes(q))
+ )
+}
+
+function EnvActions({
+ varKey,
+ info,
+ saving,
+ onEdit,
+ onClear,
+ onReveal,
+ isRevealed,
+ showReveal = true
+}: EnvActionsProps) {
+ return (
+
+ {info.url && (
+
+
+ Docs
+
+
+ )}
+ {info.is_set && showReveal && (
+
onReveal(varKey)}
+ size="icon-xs"
+ title={isRevealed ? 'Hide value' : 'Reveal value'}
+ variant="ghost"
+ >
+ {isRevealed ? : }
+
+ )}
+
+ {info.is_set ? 'Replace' : 'Set'}
+
+ {info.is_set && (
+
onClear(varKey)}
+ size="icon-xs"
+ title="Clear value"
+ variant="ghost"
+ >
+
+
+ )}
+
+ )
+}
+
+export function EnvVarRow({
+ varKey,
+ info,
+ edits,
+ revealed,
+ saving,
+ setEdits,
+ onSave,
+ onClear,
+ onReveal,
+ compact = false
+}: EnvRowProps) {
+ const isEditing = edits[varKey] !== undefined
+ const isRevealed = revealed[varKey] !== undefined
+ const value = isRevealed ? revealed[varKey] : info.redacted_value
+ const startEdit = () => setEdits(c => ({ ...c, [varKey]: '' }))
+
+ if (compact && !isEditing) {
+ return (
+
+
+
{varKey}
+
{info.description}
+
+
+
+ )
+ }
+
+ return (
+
+
+
+
+
{varKey}
+
+ {info.is_set && }
+ {info.is_set ? 'Set' : 'Not set'}
+
+
+
{info.description}
+
+
+
+
+ {!isEditing && info.is_set && (
+
+ {value || '---'}
+
+ )}
+
+ {isEditing && (
+
+ setEdits(c => ({ ...c, [varKey]: e.target.value }))}
+ placeholder={info.is_set ? 'Replace current value' : 'Enter value'}
+ type={info.is_password ? 'password' : 'text'}
+ value={edits[varKey]}
+ />
+ onSave(varKey)} size="sm">
+
+ {saving === varKey ? 'Saving' : 'Save'}
+
+ setEdits(c => withoutKey(c, varKey))} size="sm" variant="outline">
+
+ Cancel
+
+
+ )}
+
+ )
+}
+
+export function SettingsCategoryHeading({ count, icon: Icon, title }: CategoryHeadingProps) {
+ return (
+
+
+
{title}
+ {count &&
{count} }
+
+ )
+}
+
+// Owns the env-var fetch + the edit/reveal/save/delete lifecycle so multiple
+// credential pages (Providers, Keys) share one source of truth and one set of
+// mutation handlers instead of duplicating the plumbing.
+export function useEnvCredentials(): UseEnvCredentials {
+ const [vars, setVars] = useState | null>(null)
+ const [edits, setEdits] = useState>({})
+ const [revealed, setRevealed] = useState>({})
+ const [saving, setSaving] = useState(null)
+
+ // Best-effort cleanup of a retired localStorage flag (global "Show
+ // advanced" toggle) — everything in these views is configuration-level.
+ useEffect(() => {
+ try {
+ window.localStorage.removeItem('desktop.settings.keys.show_advanced')
+ } catch {
+ // Ignore — old key cleanup is best-effort.
+ }
+ }, [])
+
+ useEffect(() => {
+ let cancelled = false
+
+ void (async () => {
+ try {
+ const next = await getEnvVars()
+
+ if (!cancelled) {
+ setVars(next)
+ }
+ } catch (err) {
+ notifyError(err, 'API keys failed to load')
+ }
+ })()
+
+ return () => void (cancelled = true)
+ }, [])
+
+ function patchVar(key: string, patch: Partial>) {
+ setVars(c => (c ? { ...c, [key]: { ...c[key], ...patch } } : c))
+ }
+
+ function clearLocalState(key: string) {
+ setEdits(c => withoutKey(c, key))
+ setRevealed(c => withoutKey(c, key))
+ }
+
+ async function handleSave(key: string) {
+ const value = edits[key]
+
+ if (!value) {
+ return
+ }
+
+ setSaving(key)
+
+ try {
+ await setEnvVar(key, value)
+ patchVar(key, { is_set: true, redacted_value: redactedValue(value) })
+ clearLocalState(key)
+ notify({ kind: 'success', title: 'Credential saved', message: `${key} updated.` })
+ } catch (err) {
+ notifyError(err, `Failed to save ${key}`)
+ } finally {
+ setSaving(null)
+ }
+ }
+
+ // Direct save for a known value (no edit-state round-trip) — used by the
+ // onboarding-style key form, which owns its own input. Returns a result so
+ // the form can surface inline errors instead of only toasting.
+ async function saveValue(key: string, value: string): Promise<{ message?: string; ok: boolean }> {
+ const trimmed = value.trim()
+
+ if (!trimmed) {
+ return { message: 'Enter a value first.', ok: false }
+ }
+
+ setSaving(key)
+
+ try {
+ await setEnvVar(key, trimmed)
+ patchVar(key, { is_set: true, redacted_value: redactedValue(trimmed) })
+ clearLocalState(key)
+ notify({ kind: 'success', message: `${key} updated.`, title: 'Credential saved' })
+
+ return { ok: true }
+ } catch (err) {
+ notifyError(err, `Failed to save ${key}`)
+
+ return { message: err instanceof Error ? err.message : 'Could not save credential.', ok: false }
+ } finally {
+ setSaving(null)
+ }
+ }
+
+ async function handleClear(key: string) {
+ if (!window.confirm(`Remove ${key} from .env?`)) {
+ return
+ }
+
+ setSaving(key)
+
+ try {
+ await deleteEnvVar(key)
+ patchVar(key, { is_set: false, redacted_value: null })
+ clearLocalState(key)
+ notify({ kind: 'success', title: 'Credential removed', message: `${key} removed.` })
+ } catch (err) {
+ notifyError(err, `Failed to remove ${key}`)
+ } finally {
+ setSaving(null)
+ }
+ }
+
+ async function handleReveal(key: string) {
+ if (revealed[key]) {
+ setRevealed(c => withoutKey(c, key))
+
+ return
+ }
+
+ try {
+ const result = await revealEnvVar(key)
+ setRevealed(c => ({ ...c, [key]: result.value }))
+ } catch (err) {
+ notifyError(err, `Failed to reveal ${key}`)
+ }
+ }
+
+ return {
+ saveValue,
+ vars,
+ rowProps: {
+ edits,
+ revealed,
+ saving,
+ setEdits,
+ onSave: handleSave,
+ onClear: handleClear,
+ onReveal: handleReveal
+ }
+ }
+}
+
+interface CategoryHeadingProps {
+ count?: string
+ icon: IconComponent
+ title: string
+}
+
+interface EnvActionsProps {
+ varKey: string
+ info: EnvVarInfo
+ saving: string | null
+ onEdit: () => void
+ onClear: (key: string) => void
+ onReveal: (key: string) => void
+ isRevealed: boolean
+ showReveal?: boolean
+}
+
+interface UseEnvCredentials {
+ rowProps: Omit
+ saveValue: (key: string, value: string) => Promise<{ message?: string; ok: boolean }>
+ vars: Record | null
+}
diff --git a/apps/desktop/src/app/settings/helpers.test.ts b/apps/desktop/src/app/settings/helpers.test.ts
index 87ff47bb25e..097b9cfed41 100644
--- a/apps/desktop/src/app/settings/helpers.test.ts
+++ b/apps/desktop/src/app/settings/helpers.test.ts
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
import type { HermesConfigRecord } from '@/types/hermes'
-import { getNested, setNested } from './helpers'
+import { getNested, providerGroup, setNested } from './helpers'
describe('settings helpers', () => {
it('reads and writes nested config paths', () => {
@@ -20,4 +20,28 @@ describe('settings helpers', () => {
expect(() => setNested(config, 'constructor.prototype.polluted', true)).toThrow('Unsafe config path')
expect(({} as Record).polluted).toBeUndefined()
})
+
+ describe('providerGroup', () => {
+ it('maps a provider env var to its labeled group', () => {
+ expect(providerGroup('XAI_API_KEY')).toBe('xAI')
+ expect(providerGroup('NOUS_API_KEY')).toBe('Nous Portal')
+ expect(providerGroup('OPENROUTER_API_KEY')).toBe('OpenRouter')
+ })
+
+ it('prefers the longest matching prefix so CN/regional buckets win', () => {
+ // MINIMAX_CN_ must beat the generic MINIMAX_ prefix.
+ expect(providerGroup('MINIMAX_CN_API_KEY')).toBe('MiniMax (China)')
+ expect(providerGroup('MINIMAX_API_KEY')).toBe('MiniMax')
+ // KIMI_CN_ likewise must beat KIMI_.
+ expect(providerGroup('KIMI_CN_API_KEY')).toBe('Kimi (China)')
+ expect(providerGroup('KIMI_API_KEY')).toBe('Kimi / Moonshot')
+ // HERMES_QWEN_ and HERMES_GEMINI_ both share the HERMES_ stem.
+ expect(providerGroup('HERMES_QWEN_BASE_URL')).toBe('DashScope (Qwen)')
+ expect(providerGroup('HERMES_GEMINI_CLIENT_ID')).toBe('Gemini')
+ })
+
+ it('falls back to "Other" for un-grouped env vars', () => {
+ expect(providerGroup('SOMETHING_RANDOM')).toBe('Other')
+ })
+ })
})
diff --git a/apps/desktop/src/app/settings/helpers.ts b/apps/desktop/src/app/settings/helpers.ts
index f27db8478db..1c4f61f9a56 100644
--- a/apps/desktop/src/app/settings/helpers.ts
+++ b/apps/desktop/src/app/settings/helpers.ts
@@ -19,9 +19,30 @@ export const withoutKey = (record: Record, key: string) => {
export const redactedValue = (v: string) => (v.length <= 8 ? '••••' : `${v.slice(0, 4)}...${v.slice(-4)}`)
-export const providerGroup = (key: string) => PROVIDER_GROUPS.find(g => key.startsWith(g.prefix))?.name ?? 'Other'
+// Longest-prefix match so a more specific group like ``MINIMAX_CN_`` is
+// chosen over its shorter parent ``MINIMAX_``. Falls back to the bucket
+// "Other" used by the Keys settings view for un-grouped env vars.
+export const providerGroup = (key: string) => {
+ let best: (typeof PROVIDER_GROUPS)[number] | undefined
-export const providerPriority = (name: string) => PROVIDER_GROUPS.find(g => g.name === name)?.priority ?? 99
+ for (const candidate of PROVIDER_GROUPS) {
+ if (!key.startsWith(candidate.prefix)) {
+ continue
+ }
+
+ if (!best || candidate.prefix.length > best.prefix.length) {
+ best = candidate
+ }
+ }
+
+ return best?.name ?? 'Other'
+}
+
+export const providerMeta = (name: string) =>
+ PROVIDER_GROUPS.find(g => g.name === name && (g.description || g.docsUrl)) ??
+ PROVIDER_GROUPS.find(g => g.name === name)
+
+export const providerPriority = (name: string) => providerMeta(name)?.priority ?? 99
const POLLUTING_PATH_PARTS = new Set(['__proto__', 'constructor', 'prototype'])
diff --git a/apps/desktop/src/app/settings/index.tsx b/apps/desktop/src/app/settings/index.tsx
index 46c2081ec8f..a2580723ca9 100644
--- a/apps/desktop/src/app/settings/index.tsx
+++ b/apps/desktop/src/app/settings/index.tsx
@@ -3,7 +3,7 @@ import { useRef } from 'react'
import { getHermesConfigDefaults, getHermesConfigRecord, saveHermesConfig } from '@/hermes'
import { triggerHaptic } from '@/lib/haptics'
-import { Archive, Globe, Info, KeyRound, Wrench } from '@/lib/icons'
+import { Archive, Globe, Info, KeyRound, Sparkles, Wrench, Zap } from '@/lib/icons'
import { notifyError } from '@/store/notifications'
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
@@ -18,11 +18,13 @@ import { SECTIONS } from './constants'
import { GatewaySettings } from './gateway-settings'
import { KeysSettings } from './keys-settings'
import { McpSettings } from './mcp-settings'
+import { PROVIDER_VIEWS, ProvidersSettings, type ProviderView } from './providers-settings'
import { SessionsSettings } from './sessions-settings'
import type { SettingsPageProps, SettingsView as SettingsViewId } from './types'
const SETTINGS_VIEWS: readonly SettingsViewId[] = [
...SECTIONS.map(s => `config:${s.id}` as SettingsViewId),
+ 'providers',
'gateway',
'keys',
'mcp',
@@ -32,6 +34,14 @@ const SETTINGS_VIEWS: readonly SettingsViewId[] = [
export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChanged }: SettingsPageProps) {
const [activeView, setActiveView] = useRouteEnumParam('tab', SETTINGS_VIEWS, 'config:model' as SettingsViewId)
+ // Providers subnav (Accounts vs API keys) lives in its own param so each
+ // sub-view is deep-linkable and survives a refresh.
+ const [providerView, setProviderView] = useRouteEnumParam('pview', PROVIDER_VIEWS, 'accounts')
+
+ const openProviderView = (view: ProviderView) => {
+ setActiveView('providers')
+ setProviderView(view)
+ }
const importInputRef = useRef(null)
@@ -83,6 +93,30 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
)
})}
+ setActiveView('providers')}
+ />
+ {activeView === 'providers' && (
+
+ openProviderView('accounts')}
+ />
+ openProviderView('keys')}
+ />
+
+ )}
setActiveView('keys')}
/>
+ ) : activeView === 'providers' ? (
+
) : activeView === 'keys' ? (
) : activeView === 'mcp' ? (
diff --git a/apps/desktop/src/app/settings/keys-settings.tsx b/apps/desktop/src/app/settings/keys-settings.tsx
index 2dbf228e6ae..a09950f1cdf 100644
--- a/apps/desktop/src/app/settings/keys-settings.tsx
+++ b/apps/desktop/src/app/settings/keys-settings.tsx
@@ -1,425 +1,162 @@
-import { useEffect, useMemo, useState } from 'react'
+import { useMemo, useState } from 'react'
-import { Button } from '@/components/ui/button'
-import { Input } from '@/components/ui/input'
-import { deleteEnvVar, getEnvVars, revealEnvVar, setEnvVar } from '@/hermes'
-import { Check, Eye, EyeOff, Save, Settings2, Trash2, Zap } from '@/lib/icons'
+import { Settings2, Wrench } from '@/lib/icons'
import { cn } from '@/lib/utils'
-import { notify, notifyError } from '@/store/notifications'
import type { EnvVarInfo } from '@/types/hermes'
-import { CONTROL_TEXT } from './constants'
-import { asText, prettyName, providerGroup, providerPriority, redactedValue, withoutKey } from './helpers'
-import { LoadingState, Pill, SectionHeading, SettingsContent } from './primitives'
-import type { EnvPatch, EnvRowProps, ProviderGroup } from './types'
-import { useDeepLinkHighlight } from './use-deep-link-highlight'
+import { EnvVarRow, useEnvCredentials } from './env-credentials'
+import { asText } from './helpers'
+import { LoadingState, SettingsContent } from './primitives'
-interface EnvActionsProps {
- varKey: string
- info: EnvVarInfo
- saving: string | null
- onEdit: () => void
- onClear: (key: string) => void
- onReveal: (key: string) => void
- isRevealed: boolean
- showReveal?: boolean
+// Providers live on their own page; messaging-platform credentials live on the
+// dedicated Messaging page (and are hidden here via `channel_managed`). This
+// view covers tool API keys plus server/setting env vars (API server, webhook,
+// gateway), which fold into the Settings tab.
+const KEY_TABS = [
+ { icon: Wrench, id: 'tool', label: 'Tools' },
+ { icon: Settings2, id: 'setting', label: 'Settings' }
+] as const
+
+type KeyCategoryId = (typeof KEY_TABS)[number]['id']
+
+const CATEGORY_LABELS: Record = {
+ setting: 'Settings',
+ tool: 'Tools'
}
-function EnvActions({
- varKey,
- info,
- saving,
- onEdit,
- onClear,
- onReveal,
- isRevealed,
- showReveal = true
-}: EnvActionsProps) {
- return (
-
- {info.url && (
-
-
- Docs
-
-
- )}
- {info.is_set && showReveal && (
-
onReveal(varKey)}
- size="icon-xs"
- title={isRevealed ? 'Hide value' : 'Reveal value'}
- variant="ghost"
- >
- {isRevealed ? : }
-
- )}
-
- {info.is_set ? 'Replace' : 'Set'}
-
- {info.is_set && (
-
onClear(varKey)}
- size="icon-xs"
- title="Clear value"
- variant="ghost"
- >
-
-
- )}
-
- )
+// Backend categories that surface under each tab. Server/gateway vars carry the
+// `messaging` category server-side but belong with general settings here, since
+// the platform-credential half of `messaging` is owned by the Messaging page.
+const TAB_CATEGORIES: Record = {
+ setting: ['setting', 'messaging'],
+ tool: ['tool']
}
-function EnvVarRow({
- varKey,
- info,
- edits,
- revealed,
- saving,
- setEdits,
- onSave,
- onClear,
- onReveal,
- compact = false
-}: EnvRowProps) {
- const isEditing = edits[varKey] !== undefined
- const isRevealed = revealed[varKey] !== undefined
- const value = isRevealed ? revealed[varKey] : info.redacted_value
- const startEdit = () => setEdits(c => ({ ...c, [varKey]: '' }))
-
- if (compact && !isEditing) {
- return (
-
-
-
{varKey}
-
{info.description}
-
-
-
- )
+function tabForCategory(category: string): KeyCategoryId | null {
+ for (const tab of KEY_TABS) {
+ if (TAB_CATEGORIES[tab.id].includes(category)) {
+ return tab.id
+ }
}
- return (
-
-
-
-
-
{varKey}
-
- {info.is_set && }
- {info.is_set ? 'Set' : 'Not set'}
-
-
-
{info.description}
-
-
-
-
- {!isEditing && info.is_set && (
-
- {value || '---'}
-
- )}
-
- {isEditing && (
-
- setEdits(c => ({ ...c, [varKey]: e.target.value }))}
- placeholder={info.is_set ? 'Replace current value' : 'Enter value'}
- type={info.is_password ? 'password' : 'text'}
- value={edits[varKey]}
- />
- onSave(varKey)} size="sm">
-
- {saving === varKey ? 'Saving' : 'Save'}
-
- setEdits(c => withoutKey(c, varKey))} size="sm" variant="text">
- Cancel
-
-
- )}
-
- )
+ return null
}
-function EnvProviderGroup({
- group,
- rowProps,
- forceExpand = false
+function CategoryTabs({
+ active,
+ counts,
+ onSelect
}: {
- group: ProviderGroup
- rowProps: Omit
- forceExpand?: boolean
+ active: KeyCategoryId
+ counts: Record
+ onSelect: (id: KeyCategoryId) => void
}) {
- const setCount = group.entries.filter(([, info]) => info.is_set).length
- // Default-expand providers that already have at least one key set; the
- // user is much more likely to be coming back to edit those than to start
- // configuring a fresh provider from scratch.
- const [expanded, setExpanded] = useState(setCount > 0 || forceExpand)
-
- useEffect(() => {
- if (forceExpand) {
- setExpanded(true)
- }
- }, [forceExpand])
-
return (
-
-
setExpanded(e => !e)}
- type="button"
- >
-
-
-
- {group.name === 'Other' ? 'Other providers' : group.name}
-
- {setCount > 0 && {setCount} set }
-
- {group.entries.length} keys
-
- {expanded && (
-
- {group.entries.map(([key, info]) => (
-
-
-
- ))}
-
- )}
+
+ {KEY_TABS.map(tab => {
+ const isActive = active === tab.id
+ const count = counts[tab.id]
+
+ return (
+ onSelect(tab.id)}
+ type="button"
+ >
+
+ {tab.label}
+ {count > 0 && (
+
+ {count}
+
+ )}
+
+ )
+ })}
)
}
export function KeysSettings() {
- const [vars, setVars] = useState
| null>(null)
- const [edits, setEdits] = useState>({})
- const [revealed, setRevealed] = useState>({})
- const [saving, setSaving] = useState(null)
+ const { rowProps, vars } = useEnvCredentials()
+ const [activeCategory, setActiveCategory] = useState('tool')
- // Deep-link from the command palette (?key=): force-expand the
- // matching provider group, scroll the row in, and flash it.
- const highlightKey = useDeepLinkHighlight({
- elementId: key => `env-var-${key}`,
- param: 'key',
- ready: key => Boolean(vars?.[key])
- })
-
- // We used to hide ~80% of rows behind a global "Show advanced" toggle, but
- // everything in this view is configuration-level — "advanced" was a poor
- // distinction. The full list is rendered now and provider groups
- // default-collapsed-unless-set keep the surface manageable.
- useEffect(() => {
- try {
- window.localStorage.removeItem('desktop.settings.keys.show_advanced')
- } catch {
- // Ignore — old key cleanup is best-effort.
- }
- }, [])
-
- useEffect(() => {
- let cancelled = false
-
- void (async () => {
- try {
- const next = await getEnvVars()
-
- if (!cancelled) {
- setVars(next)
- }
- } catch (err) {
- notifyError(err, 'API keys failed to load')
- }
- })()
-
- return () => void (cancelled = true)
- }, [])
-
- const providerGroups = useMemo(() => {
+ const groups = useMemo(() => {
if (!vars) {
return []
}
- const entries = Object.entries(vars).filter(([, info]) => asText(info.category) === 'provider')
+ return KEY_TABS.map(t => t.id).flatMap(tab => {
+ const cats = TAB_CATEGORIES[tab]
- const groups = new Map()
-
- for (const entry of entries) {
- const name = providerGroup(entry[0])
- groups.set(name, [...(groups.get(name) ?? []), entry])
- }
-
- return Array.from(groups, ([name, entries]) => ({
- name,
- priority: providerPriority(name),
- entries: entries.sort(([a], [b]) => a.localeCompare(b)),
- hasAnySet: entries.some(([, info]) => info.is_set)
- })).sort((a, b) => a.priority - b.priority || a.name.localeCompare(b.name))
- }, [vars])
-
- const otherGroups = useMemo(() => {
- if (!vars) {
- return []
- }
-
- const labels: Record = {
- tool: 'Tools',
- messaging: 'Messaging',
- setting: 'Settings'
- }
-
- return ['tool', 'messaging', 'setting'].flatMap(cat => {
const entries = Object.entries(vars)
- .filter(([, info]) => asText(info.category) === cat)
+ .filter(([, info]) => !info.channel_managed && cats.includes(asText(info.category)))
.sort(([a], [b]) => a.localeCompare(b))
- return entries.length === 0 ? [] : [{ category: cat, label: labels[cat] ?? prettyName(cat), entries }]
+ return entries.length === 0 ? [] : [{ category: tab, label: CATEGORY_LABELS[tab], entries }]
})
}, [vars])
- function patchVar(key: string, patch: EnvPatch) {
- setVars(c => (c ? { ...c, [key]: { ...c[key], ...patch } } : c))
- }
+ // Tab badge counts reflect how many keys are set per tab. Channel-managed
+ // credentials are owned by the Messaging page and excluded here.
+ const categoryCounts = useMemo>(() => {
+ const counts: Record = { setting: 0, tool: 0 }
- function clearLocalState(key: string) {
- setEdits(c => withoutKey(c, key))
- setRevealed(c => withoutKey(c, key))
- }
-
- async function handleSave(key: string) {
- const value = edits[key]
-
- if (!value) {
- return
+ if (!vars) {
+ return counts
}
- setSaving(key)
+ for (const info of Object.values(vars)) {
+ if (!info.is_set || info.channel_managed) {
+ continue
+ }
- try {
- await setEnvVar(key, value)
- patchVar(key, { is_set: true, redacted_value: redactedValue(value) })
- clearLocalState(key)
- notify({ kind: 'success', title: 'Credential saved', message: `${key} updated.` })
- } catch (err) {
- notifyError(err, `Failed to save ${key}`)
- } finally {
- setSaving(null)
- }
- }
+ const tab = tabForCategory(asText(info.category))
- async function handleClear(key: string) {
- if (!window.confirm(`Remove ${key} from .env?`)) {
- return
+ if (tab) {
+ counts[tab] += 1
+ }
}
- setSaving(key)
-
- try {
- await deleteEnvVar(key)
- patchVar(key, { is_set: false, redacted_value: null })
- clearLocalState(key)
- notify({ kind: 'success', title: 'Credential removed', message: `${key} removed.` })
- } catch (err) {
- notifyError(err, `Failed to remove ${key}`)
- } finally {
- setSaving(null)
- }
- }
-
- async function handleReveal(key: string) {
- if (revealed[key]) {
- setRevealed(c => withoutKey(c, key))
-
- return
- }
-
- try {
- const result = await revealEnvVar(key)
- setRevealed(c => ({ ...c, [key]: result.value }))
- } catch (err) {
- notifyError(err, `Failed to reveal ${key}`)
- }
- }
+ return counts
+ }, [vars])
if (!vars) {
return
}
- const rowProps = {
- edits,
- revealed,
- saving,
- setEdits,
- onSave: handleSave,
- onClear: handleClear,
- onReveal: handleReveal
- }
-
- const configuredCount = providerGroups.filter(g => g.hasAnySet).length
+ const visible = groups.filter(g => g.category === activeCategory)
return (
-
-
-
- {providerGroups.map(group => (
- key === highlightKey)}
- group={group}
- key={group.name}
- rowProps={rowProps}
- />
- ))}
-
-
+
- {otherGroups.map(group => (
-
-
i.is_set).length} of ${group.entries.length} set`}
- title={group.label}
- />
+ {visible.map(group => (
+
- {group.entries.map(([key, info]) => (
-
-
-
+ {group.entries.map(([key, info]: [string, EnvVarInfo]) => (
+
))}
-
+
))}
+
+ {visible.length === 0 && (
+
+ Nothing configured in this category yet.
+
+ )}
)
}
diff --git a/apps/desktop/src/app/settings/providers-settings.tsx b/apps/desktop/src/app/settings/providers-settings.tsx
new file mode 100644
index 00000000000..74c91de2ff6
--- /dev/null
+++ b/apps/desktop/src/app/settings/providers-settings.tsx
@@ -0,0 +1,489 @@
+import { useStore } from '@nanostores/react'
+import { type ChangeEvent, type KeyboardEvent, useEffect, useMemo, useState } from 'react'
+
+import {
+ FEATURED_ID,
+ FeaturedProviderRow,
+ KeyProviderRow,
+ ProviderRow,
+ sortProviders
+} from '@/components/desktop-onboarding-overlay'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { listOAuthProviders } from '@/hermes'
+import { ChevronDown, ExternalLink, KeyRound, Loader2, Save } from '@/lib/icons'
+import { cn } from '@/lib/utils'
+import { $desktopOnboarding, startManualProviderOAuth } from '@/store/onboarding'
+import type { EnvVarInfo, OAuthProvider } from '@/types/hermes'
+
+import { SettingsCategoryHeading, useEnvCredentials } from './env-credentials'
+import { providerGroup, providerMeta, providerPriority, withoutKey } from './helpers'
+import { LoadingState, SettingsContent } from './primitives'
+import type { EnvRowProps } from './types'
+
+// Sub-views surfaced as a sidebar subnav: account sign-in vs raw API keys.
+export const PROVIDER_VIEWS = ['accounts', 'keys'] as const
+
+export type ProviderView = (typeof PROVIDER_VIEWS)[number]
+
+const isKeyVar = (key: string, info: EnvVarInfo) => info.is_password || /(?:_API_KEY|_TOKEN|_KEY)$/.test(key)
+
+const friendlyFieldLabel = (key: string, info: EnvVarInfo) =>
+ info.description?.trim() || key.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, c => c.toUpperCase())
+
+// Advanced (non-primary) fields are mostly base-URL / endpoint overrides, not
+// keys — so don't reuse the "Paste key" placeholder that makes them read as a
+// duplicate key input. URL-ish vars get a URL hint; everything else stays optional.
+const advancedPlaceholder = (key: string, info: EnvVarInfo): string =>
+ isKeyVar(key, info) ? 'Paste key' : /URL$/i.test(key) ? 'https://…' : 'Optional'
+
+// Group the env catalog by provider so the keys view can render one collapsible
+// row per vendor: a primary key field inline, with any secondary / advanced vars
+// (base URL overrides, alt tokens) revealed when the row is focused/expanded.
+// Mirrors what Cursor's API-keys section does. Groups without a key field (e.g.
+// Nous Portal's lone base-URL override) and the "Other" bucket are skipped.
+function buildProviderKeyGroups(vars: Record): ProviderKeyGroup[] {
+ const buckets = new Map()
+
+ for (const [key, info] of Object.entries(vars)) {
+ if (info.category !== 'provider') {
+ continue
+ }
+
+ const name = providerGroup(key)
+
+ if (name === 'Other') {
+ continue
+ }
+
+ buckets.set(name, [...(buckets.get(name) ?? []), [key, info]])
+ }
+
+ const groups: ProviderKeyGroup[] = []
+
+ for (const [name, entries] of buckets) {
+ const primary = entries.find(([k, i]) => !i.advanced && isKeyVar(k, i)) ?? entries.find(([k, i]) => isKeyVar(k, i))
+
+ if (!primary) {
+ continue
+ }
+
+ const meta = providerMeta(name)
+
+ groups.push({
+ // Advanced = the provider's non-key knobs (base URL, region, deployment).
+ // Skip redundant alias key vars (e.g. ANTHROPIC_TOKEN vs ANTHROPIC_API_KEY)
+ // so we never render a second "Paste key" input — unless one is already
+ // set, in which case keep it visible so it stays clearable.
+ advanced: entries
+ .filter(([k, i]) => k !== primary[0] && (!isKeyVar(k, i) || i.is_set))
+ .sort(([a], [b]) => a.localeCompare(b)),
+ description: meta?.description ?? primary[1].description,
+ docsUrl: meta?.docsUrl ?? primary[1].url ?? undefined,
+ hasAnySet: entries.some(([, i]) => i.is_set),
+ name,
+ primary,
+ priority: providerPriority(name)
+ })
+ }
+
+ return groups.sort((a, b) => a.priority - b.priority || a.name.localeCompare(b.name))
+}
+
+// A single credential field: a set key shows as a filled read-only input
+// (redacted value) that edits in place on click. Save appears once typed; a set
+// key also offers Remove, and Esc cancels without closing the overlay.
+function KeyField({
+ compact = false,
+ info,
+ label,
+ placeholder,
+ rowProps,
+ varKey
+}: {
+ compact?: boolean
+ info: EnvVarInfo
+ label?: string
+ placeholder?: string
+ rowProps: KeyRowProps
+ varKey: string
+}) {
+ const { edits, onClear, onSave, saving, setEdits } = rowProps
+ const editing = edits[varKey] !== undefined
+ const draft = edits[varKey] ?? ''
+ const dirty = draft.trim().length > 0
+ const busy = saving === varKey
+ const masked = info.redacted_value ?? '••••••••'
+ const startEdit = () => setEdits(c => ({ ...c, [varKey]: '' }))
+ const cancel = () => setEdits(c => withoutKey(c, varKey))
+ const update = (e: ChangeEvent) => setEdits(c => ({ ...c, [varKey]: e.target.value }))
+
+ // Enter saves; Esc cancels in place without bubbling to the overlay's window
+ // Escape listener (which would otherwise close the whole settings panel).
+ const keydown = (e: KeyboardEvent) => {
+ if (e.key === 'Enter' && dirty) {
+ void onSave(varKey)
+ } else if (e.key === 'Escape' && editing) {
+ e.preventDefault()
+ e.stopPropagation()
+ cancel()
+ }
+ }
+
+ // Advanced overrides render quieter (xs) than the primary key field so the key
+ // stays the visual anchor. Padding-driven sizing — no fixed heights.
+ const inputSize = compact ? 'xs' : 'sm'
+ const editType = info.is_password ? 'password' : 'text'
+
+ // A set value reads as a single filled, read-only field (showing the redacted
+ // value). Clicking it drops into edit mode in place — no Replace/Cancel chrome.
+ const control =
+ info.is_set && !editing ? (
+
+ ) : (
+
+
+
+ {dirty && (
+ void onSave(varKey)} size="sm">
+ {busy ? : }
+ {busy ? 'Saving' : 'Save'}
+
+ )}
+
+ {editing && (
+
+ {info.is_set && (
+ <>
+ void onClear(varKey)}
+ type="button"
+ variant="text"
+ >
+ Remove
+
+ or
+ >
+ )}
+ esc to cancel
+
+ )}
+
+ )
+
+ // Standard stacked form field: small muted label above, input below. Same shape
+ // for the primary key and every advanced override — just smaller when compact.
+ // Empty advanced inputs (not labels) fade back, brightening on hover/focus/set.
+ const dim = compact && !info.is_set
+
+ return (
+
+ {label && (
+
+ {label}
+
+ )}
+ {dim ? (
+
{control}
+ ) : (
+ control
+ )}
+
+ )
+}
+
+function ProviderKeyCard({
+ expanded,
+ group,
+ onExpand,
+ onToggle,
+ rowProps
+}: {
+ expanded: boolean
+ group: ProviderKeyGroup
+ onExpand: () => void
+ onToggle: () => void
+ rowProps: KeyRowProps
+}) {
+ // Expandable when there's anything to reveal — advanced overrides and/or a
+ // "Get a key" docs link (which lives at the bottom of the expanded panel).
+ const expandable = group.advanced.length > 0 || Boolean(group.docsUrl)
+
+ return (
+ {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault()
+ onToggle()
+ }
+ }
+ : undefined
+ }
+ role={expandable ? 'button' : undefined}
+ tabIndex={expandable ? 0 : undefined}
+ >
+
+
+
+ {group.name}
+ {expandable && (
+
+ )}
+
+
e.stopPropagation()}
+ onFocus={() => {
+ if (expandable && !expanded) {
+ onExpand()
+ }
+ }}
+ >
+
+
+
+ {expandable && expanded && (
+
+ )}
+
+ )
+}
+
+// Deliberately a near-1:1 replica of the first-run onboarding picker
+// (`Picker` in desktop-onboarding-overlay): same recommended card, same
+// provider rows, same "Other providers" disclosure, same OpenRouter quick-key
+// row, and the same bottom-right "I have an API key" affordance. The leaf cards
+// are the exact shared components, so the two surfaces stay visually identical.
+// Selecting a provider hands off to the shared onboarding overlay, which runs
+// that provider's real sign-in flow; the key affordances open the API-key
+// catalog below.
+function OAuthPicker({ onWantApiKey, providers }: { onWantApiKey: () => void; providers: OAuthProvider[] }) {
+ const [showAll, setShowAll] = useState(false)
+ const ordered = useMemo(() => sortProviders(providers), [providers])
+
+ if (ordered.length === 0) {
+ return null
+ }
+
+ const select = (p: OAuthProvider) => startManualProviderOAuth(p.id)
+
+ const featured = ordered.find(p => p.id === FEATURED_ID) ?? null
+ const rest = featured ? ordered.filter(p => p.id !== FEATURED_ID) : ordered
+ // Keep connected accounts grouped and always visible; only the unconnected
+ // providers hide behind the disclosure, so the page leads with what's set up.
+ const connected = rest.filter(p => p.status?.logged_in)
+ const others = rest.filter(p => !p.status?.logged_in)
+ const collapsible = others.length > 0
+ const showOthers = !collapsible || showAll
+
+ return (
+
+
+
+
+ Have an API key instead?
+
+
+
+ Sign in with a subscription — no API key to copy. Hermes runs the browser sign-in for you, right here in the
+ app.
+
+ {featured && }
+ {connected.length > 0 && (
+ <>
+
+ Connected
+
+ {connected.map(p => (
+
+ ))}
+ >
+ )}
+ {showOthers && (
+ <>
+ {others.map(p => (
+
+ ))}
+
+ >
+ )}
+ {collapsible && (
+ setShowAll(v => !v)}
+ type="button"
+ variant="text"
+ >
+ {showAll ? 'Collapse' : connected.length > 0 ? 'Connect another provider' : 'Other providers'}
+
+
+ )}
+
+ )
+}
+
+function NoProviderKeys() {
+ return (
+
+ No provider API keys available.
+
+ )
+}
+
+export function ProvidersSettings({ onViewChange, view }: ProvidersSettingsProps) {
+ const { rowProps, vars } = useEnvCredentials()
+ const [oauthProviders, setOauthProviders] = useState([])
+ // Single-open accordion for the per-provider "advanced options" panels.
+ const [openProvider, setOpenProvider] = useState(null)
+ // The onboarding overlay owns the OAuth flow. Watch its `manual` flag so we
+ // re-read connection state when the user finishes (or dismisses) a sign-in
+ // they launched from this page — otherwise the cards keep their stale status.
+ const onboardingActive = useStore($desktopOnboarding).manual
+
+ useEffect(() => {
+ if (onboardingActive) {
+ return
+ }
+
+ let cancelled = false
+
+ // OAuth providers are best-effort — a failure here just hides the panel.
+ void (async () => {
+ try {
+ const { providers } = await listOAuthProviders()
+
+ if (!cancelled) {
+ setOauthProviders(providers)
+ }
+ } catch {
+ // Ignore — the OAuth panel just won't render.
+ }
+ })()
+
+ return () => void (cancelled = true)
+ }, [onboardingActive])
+
+ if (!vars) {
+ return
+ }
+
+ const hasOauth = oauthProviders.length > 0
+ // The sidebar subnav owns the Accounts/API-keys split now; with no OAuth
+ // providers there's nothing for the "Accounts" view to show, so fall to keys.
+ const showApiKeys = view === 'keys' || !hasOauth
+
+ const keyGroups = buildProviderKeyGroups(vars)
+
+ if (showApiKeys) {
+ return (
+
+ {keyGroups.length > 0 ? (
+
+ {keyGroups.map(group => (
+
setOpenProvider(group.name)}
+ onToggle={() => setOpenProvider(prev => (prev === group.name ? null : group.name))}
+ rowProps={rowProps}
+ />
+ ))}
+
+ ) : (
+
+ )}
+
+ )
+ }
+
+ return (
+
+ onViewChange('keys')} providers={oauthProviders} />
+
+ )
+}
+
+type KeyRowProps = Omit
+
+interface ProviderKeyGroup {
+ advanced: [string, EnvVarInfo][]
+ description?: string
+ docsUrl?: string
+ hasAnySet: boolean
+ name: string
+ primary: [string, EnvVarInfo]
+ priority: number
+}
+
+interface ProvidersSettingsProps {
+ onViewChange: (view: ProviderView) => void
+ view: ProviderView
+}
diff --git a/apps/desktop/src/app/settings/types.ts b/apps/desktop/src/app/settings/types.ts
index 665ce65cb87..33c88e761c1 100644
--- a/apps/desktop/src/app/settings/types.ts
+++ b/apps/desktop/src/app/settings/types.ts
@@ -4,7 +4,7 @@ import type { HermesGateway } from '@/hermes'
import type { IconComponent } from '@/lib/icons'
import type { EnvVarInfo } from '@/types/hermes'
-export type SettingsView = 'about' | 'gateway' | 'keys' | 'mcp' | 'sessions' | `config:${string}`
+export type SettingsView = 'about' | 'gateway' | 'keys' | 'mcp' | 'providers' | 'sessions' | `config:${string}`
export type EnvPatch = Partial>
export interface SettingsPageProps {
diff --git a/apps/desktop/src/components/desktop-onboarding-overlay.tsx b/apps/desktop/src/components/desktop-onboarding-overlay.tsx
index 6b430ee7b64..2f335d8b251 100644
--- a/apps/desktop/src/components/desktop-onboarding-overlay.tsx
+++ b/apps/desktop/src/components/desktop-onboarding-overlay.tsx
@@ -24,12 +24,14 @@ import { $desktopBoot, type DesktopBootState } from '@/store/boot'
import {
$desktopOnboarding,
cancelOnboardingFlow,
+ clearPendingProviderOAuth,
closeManualOnboarding,
confirmOnboardingModel,
copyDeviceCode,
copyExternalCommand,
type OnboardingContext,
type OnboardingFlow,
+ peekPendingProviderOAuth,
recheckExternalSignin,
refreshOnboarding,
saveOnboardingApiKey,
@@ -47,7 +49,7 @@ interface DesktopOnboardingOverlayProps {
requestGateway: OnboardingContext['requestGateway']
}
-interface ApiKeyOption {
+export interface ApiKeyOption {
description: string
docsUrl: string
envKey: string
@@ -125,7 +127,7 @@ const FLOW_SUBTITLES: Record = {
const providerTitle = (p: OAuthProvider) => PROVIDER_DISPLAY[p.id]?.title ?? p.name
const orderOf = (p: OAuthProvider) => PROVIDER_DISPLAY[p.id]?.order ?? 99
-const sortProviders = (providers: OAuthProvider[]) =>
+export const sortProviders = (providers: OAuthProvider[]) =>
[...providers].sort((a, b) => orderOf(a) - orderOf(b) || a.name.localeCompare(b.name))
export function DesktopOnboardingOverlay({ enabled, onCompleted, requestGateway }: DesktopOnboardingOverlayProps) {
@@ -148,6 +150,36 @@ export function DesktopOnboardingOverlay({ enabled, onCompleted, requestGateway
}
}, [ctx, enabled, onboarding.requested])
+ // When the Providers settings page asked to connect a specific provider, the
+ // store stashed its id. Once the provider list has loaded and we're back at
+ // an idle picker, launch that exact OAuth flow so the user lands directly in
+ // sign-in instead of the picker they just came from.
+ useEffect(() => {
+ if (!onboarding.manual || onboarding.providers === null || onboarding.flow.status !== 'idle') {
+ return
+ }
+
+ const pendingId = peekPendingProviderOAuth()
+
+ if (!pendingId) {
+ return
+ }
+
+ const provider = onboarding.providers.find(p => p.id === pendingId)
+
+ if (provider) {
+ // Only clear once we've committed to launching it, so a failed/empty
+ // provider fetch doesn't silently drop the hand-off.
+ clearPendingProviderOAuth()
+ void startProviderOAuth(provider, ctx)
+ } else if (onboarding.providers.length > 0) {
+ // The list loaded but the id isn't a real provider — drop the stale
+ // hand-off. An empty list means the fetch isn't ready yet, so keep it
+ // and let a later refresh retry.
+ clearPendingProviderOAuth()
+ }
+ }, [ctx, onboarding.flow.status, onboarding.manual, onboarding.providers])
+
// Mount from frame 1 so we replace the boot overlay seamlessly. The
// configured field stays null until the runtime check resolves; only then
// do we know whether to dismiss (true) or surface the picker (false).
@@ -190,9 +222,12 @@ export function DesktopOnboardingOverlay({ enabled, onCompleted, requestGateway
)
}
+// The launch reason is a prompt ("why am I seeing this"), not an error — real
+// provider-setup failures are filtered out upstream and surfaced by FlowPanel.
+// Keep it neutral so it never reads as a failure.
function ReasonNotice({ reason }: { reason: string }) {
return (
-
+
{reason}
)
@@ -246,7 +281,7 @@ function Header() {
)
}
-const FEATURED_ID = 'nous'
+export const FEATURED_ID = 'nous'
const FEATURED_PITCH = 'One subscription, 300+ frontier models — the recommended way to run Hermes'
const SHOW_ALL_KEY = 'hermes-onboarding-show-all-v1'
@@ -275,7 +310,13 @@ export function Picker({ ctx }: { ctx: OnboardingContext }) {
const hasOauth = ordered.length > 0
if (mode === 'apikey' || !hasOauth) {
- return
+ return (
+
setOnboardingMode('oauth')}
+ onSave={(envKey, value, name) => saveOnboardingApiKey(envKey, value, name, ctx)}
+ />
+ )
}
if (providers === null) {
@@ -324,7 +365,7 @@ export function Picker({ ctx }: { ctx: OnboardingContext }) {
)
}
-function FeaturedProviderRow({
+export function FeaturedProviderRow({
onSelect,
provider
}: {
@@ -335,17 +376,17 @@ function FeaturedProviderRow({
return (
onSelect(provider)}
type="button"
>
+
-
{providerTitle(provider)}
+
+ {providerTitle(provider)}
+
{loggedIn ? (
) : (
@@ -357,7 +398,7 @@ function FeaturedProviderRow({
{FEATURED_PITCH}
-
+
)
}
@@ -371,15 +412,15 @@ function ConnectedTag() {
)
}
-function KeyProviderRow({ onClick }: { onClick: () => void }) {
+export function KeyProviderRow({ onClick }: { onClick: () => void }) {
return (
-
OpenRouter
+
OpenRouter
One key, hundreds of models — a solid default
@@ -387,22 +428,27 @@ function KeyProviderRow({ onClick }: { onClick: () => void }) {
)
}
-function ProviderRow({ onSelect, provider }: { onSelect: (provider: OAuthProvider) => void; provider: OAuthProvider }) {
+export function ProviderRow({
+ onSelect,
+ provider
+}: {
+ onSelect: (provider: OAuthProvider) => void
+ provider: OAuthProvider
+}) {
const loggedIn = provider.status?.logged_in
const Trail = provider.flow === 'external' ? Terminal : ChevronRight
return (
onSelect(provider)}
type="button"
>
- {providerTitle(provider)}
+
+ {providerTitle(provider)}
+
{loggedIn ? : null}
{FLOW_SUBTITLES[provider.flow]}
@@ -412,13 +458,62 @@ function ProviderRow({ onSelect, provider }: { onSelect: (provider: OAuthProvide
)
}
-function ApiKeyForm({ canGoBack, ctx }: { canGoBack: boolean; ctx: OnboardingContext }) {
- const [option, setOption] = useState
(API_KEY_OPTIONS[0])
+// Presentational two-column key picker. Onboarding feeds it its curated
+// options + a ctx-bound save; the Providers settings page feeds it the full
+// provider catalog + a setEnvVar-backed save (plus `isSet`/`onClear` so it can
+// double as a manage surface). Keep it free of store/ctx coupling so both
+// surfaces render the identical form.
+export function ApiKeyForm({
+ canGoBack,
+ isSet,
+ onBack,
+ onClear,
+ onSave,
+ options = API_KEY_OPTIONS,
+ redactedValue
+}: {
+ canGoBack: boolean
+ isSet?: (envKey: string) => boolean
+ onBack: () => void
+ onClear?: (envKey: string) => void
+ onSave: (envKey: string, value: string, name: string) => Promise<{ message?: string; ok: boolean }>
+ options?: ApiKeyOption[]
+ redactedValue?: (envKey: string) => null | string | undefined
+}) {
+ const [option, setOption] = useState(options[0])
const [value, setValue] = useState('')
const [saving, setSaving] = useState(false)
const [error, setError] = useState(null)
+ // `options` can change at runtime when callers filter the catalog (e.g. the
+ // 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)) {
+ setOption(options[0])
+ setValue('')
+ setError(null)
+ }
+ }, [option.id, 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.
+ const entryRef = useRef(null)
+
+ const pick = (o: ApiKeyOption) => {
+ setOption(o)
+ setValue('')
+ setError(null)
+ requestAnimationFrame(() => {
+ entryRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' })
+ entryRef.current?.querySelector('input')?.focus()
+ })
+ }
const isLocal = option.envKey === 'OPENAI_BASE_URL'
+ const alreadySet = isSet?.(option.envKey) ?? false
+ // When set, surface the backend's redacted value (e.g. "sk-12…wxyz") as the
+ // placeholder so users can eyeball that the right key is in place.
+ const currentRedacted = alreadySet ? (redactedValue?.(option.envKey) ?? null) : null
// Only require a non-empty value — no length/format validation, so a short
// or unusual key can't block the user from continuing.
const canSave = value.trim().length >= 1
@@ -430,7 +525,7 @@ function ApiKeyForm({ canGoBack, ctx }: { canGoBack: boolean; ctx: OnboardingCon
setSaving(true)
setError(null)
- const result = await saveOnboardingApiKey(option.envKey, value, option.name, ctx)
+ const result = await onSave(option.envKey, value, option.name)
if (result.ok) {
setValue('')
@@ -446,7 +541,7 @@ function ApiKeyForm({ canGoBack, ctx }: { canGoBack: boolean; ctx: OnboardingCon
{canGoBack ? (
setOnboardingMode('oauth')}
+ onClick={onBack}
type="button"
>
@@ -455,30 +550,30 @@ function ApiKeyForm({ canGoBack, ctx }: { canGoBack: boolean; ctx: OnboardingCon
) : null}
- {API_KEY_OPTIONS.map(o => (
+ {options.map(o => (
{
- setOption(o)
- setValue('')
- setError(null)
- }}
+ onClick={() => pick(o)}
type="button"
>
{o.name}
- {option.id === o.id ? : null}
+ {option.id === o.id ? (
+
+ ) : isSet?.(o.envKey) ? (
+
+ ) : null}
{o.short ? {o.short}
: null}
))}
-
+
{option.description}
{option.docsUrl ?
Get a key : null}
@@ -489,17 +584,24 @@ function ApiKeyForm({ canGoBack, ctx }: { canGoBack: boolean; ctx: OnboardingCon
className="font-mono"
onChange={e => setValue(e.target.value)}
onKeyDown={e => e.key === 'Enter' && void submit()}
- placeholder={option.placeholder || 'Paste API key'}
+ placeholder={currentRedacted ?? (alreadySet ? 'Replace current value' : option.placeholder || 'Paste API key')}
type={isLocal ? 'text' : 'password'}
value={value}
/>
{error ?
{error}
: null}
-
+
+
+ {alreadySet && onClear ? (
+ onClear(option.envKey)} size="sm" variant="ghost">
+ Remove
+
+ ) : null}
+
void submit()}>
{saving ? : }
- {saving ? 'Connecting' : 'Connect'}
+ {saving ? 'Connecting' : alreadySet ? 'Update' : 'Connect'}
diff --git a/apps/desktop/src/components/ui/control.ts b/apps/desktop/src/components/ui/control.ts
index c4d2721090a..473d43700b7 100644
--- a/apps/desktop/src/components/ui/control.ts
+++ b/apps/desktop/src/components/ui/control.ts
@@ -10,6 +10,7 @@ export const controlVariants = cva(
{
variants: {
size: {
+ xs: 'px-2 py-0.5 text-[0.6875rem] leading-4',
sm: 'px-2 py-1',
default: 'px-2.5 py-1.5',
lg: 'px-3 py-2 text-sm leading-5'
diff --git a/apps/desktop/src/store/onboarding.ts b/apps/desktop/src/store/onboarding.ts
index aa41435094a..4ede830baa6 100644
--- a/apps/desktop/src/store/onboarding.ts
+++ b/apps/desktop/src/store/onboarding.ts
@@ -346,20 +346,49 @@ export function requestDesktopOnboarding(reason = DEFAULT_ONBOARDING_REASON) {
// onboarding flow (OAuth rows, API-key form, model-confirm) instead of
// duplicating provider UI. Sets manual=true so the overlay shows the picker
// even though configured===true, and refreshes the provider list.
-export function startManualOnboarding(reason = 'Add or switch inference provider.') {
+export function startManualOnboarding(reason: null | string = 'Add or switch inference provider.') {
patch({
manual: true,
requested: true,
- reason: reason.trim() || DEFAULT_ONBOARDING_REASON,
+ // `null` opts out of the prompt banner entirely (e.g. when the user already
+ // picked a specific provider and we auto-start its sign-in).
+ reason: reason ? reason.trim() || DEFAULT_ONBOARDING_REASON : null,
flow: { status: 'idle' }
})
void refreshProviders()
}
+// One-shot hand-off used when the dedicated Providers settings page launches a
+// specific provider's sign-in: we open the manual onboarding overlay AND
+// remember which provider to start, so the overlay drives that exact OAuth
+// flow instead of re-showing the picker the user just clicked through.
+// Module-level (not store state) because it's consumed immediately on the next
+// overlay render and never needs to persist or re-render anything itself.
+let pendingProviderOAuthId: null | string = null
+
+export function startManualProviderOAuth(providerId: string, reason: null | string = null) {
+ pendingProviderOAuthId = providerId
+ startManualOnboarding(reason)
+}
+
+// Read the pending provider id without clearing it. The overlay only clears it
+// (via clearPendingProviderOAuth) once it has actually launched that provider,
+// so a transient empty/failed provider fetch doesn't drop the hand-off and the
+// deep-link can still auto-start after the list loads.
+export function peekPendingProviderOAuth(): null | string {
+ return pendingProviderOAuthId
+}
+
+export function clearPendingProviderOAuth() {
+ pendingProviderOAuthId = null
+}
+
// Dismiss a manually-opened provider selector without touching the existing
// (working) configuration. Only valid in the manual path — the unconfigured
// first-run flow has no close affordance because the app can't run yet.
export function closeManualOnboarding() {
+ pendingProviderOAuthId = null
+
patch({ manual: false, requested: false, flow: { status: 'idle' } })
}
diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css
index ab483818a63..2ca8efd4437 100644
--- a/apps/desktop/src/styles.css
+++ b/apps/desktop/src/styles.css
@@ -562,6 +562,19 @@
animation: arc-border var(--arc-duration) linear infinite;
}
+/* Flip the arc's travel direction (e.g. the Nous Portal hero row). */
+.arc-border.arc-reverse::before {
+ animation-direction: reverse;
+}
+
+/* Nous Portal hero: slower, blue → orange arc. */
+.arc-border.arc-nous,
+:root.dark .arc-border.arc-nous {
+ --arc-c1: #4f8cff;
+ --arc-c2: #ff8c42;
+ --arc-duration: 3.27s;
+}
+
@media (prefers-reduced-motion: reduce) {
.arc-border::before {
animation: none;
@@ -669,11 +682,10 @@ canvas {
var(--dt-composer-ring) calc(var(--ring-pct) * var(--composer-ring-strength)),
var(--ring-fall)
);
- box-shadow: var(--shadow-composer);
+ box-shadow: none;
transition:
background-color 200ms ease-out,
- border-color 200ms ease-out,
- box-shadow 200ms ease-out;
+ border-color 200ms ease-out;
}
.desktop-input-chrome:hover {
@@ -685,7 +697,7 @@ canvas {
--ring-pct: 45%;
--ring-fall: transparent;
background: var(--dt-card);
- box-shadow: var(--shadow-composer-focus);
+ box-shadow: none;
outline: none;
}
@@ -693,13 +705,6 @@ canvas {
border-color: var(--dt-destructive);
}
-.desktop-input-chrome[aria-invalid='true']:focus {
- box-shadow:
- 0 0 0 0.125rem color-mix(in srgb, var(--dt-destructive) 18%, transparent),
- 0 0 0 0.0625rem color-mix(in srgb, var(--dt-destructive) 34%, transparent),
- 0 0.1875rem 0.625rem color-mix(in srgb, var(--dt-destructive) 12%, transparent);
-}
-
@layer components {
.scrollbar-dt,
.scrollbar-dt * {
diff --git a/apps/desktop/src/types/hermes.ts b/apps/desktop/src/types/hermes.ts
index 4d92d72223c..d5819ea24ad 100644
--- a/apps/desktop/src/types/hermes.ts
+++ b/apps/desktop/src/types/hermes.ts
@@ -96,6 +96,10 @@ export interface OAuthPollResponse {
export interface EnvVarInfo {
advanced: boolean
category: string
+ // True when this var is a messaging-platform credential owned by a card on
+ // the dedicated Messaging page. The Keys page hides these to avoid
+ // duplicating the richer channel-configuration UI.
+ channel_managed?: boolean
description: string
is_password: boolean
is_set: boolean