Merge pull request #46959 from NousResearch/bb/composer-model-selector

feat(desktop): composer model selector, per-model presets & external-provider disconnect
This commit is contained in:
brooklyn! 2026-06-16 09:55:57 -05:00 committed by GitHub
commit c6e99ab375
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
40 changed files with 1238 additions and 261 deletions

View File

@ -300,11 +300,20 @@ def _restore_or_build_system_prompt(agent, system_message, conversation_history)
agent.session_id, exc,
)
if stored_prompt:
if stored_prompt and _stored_prompt_matches_runtime(agent, stored_prompt):
# Continuing session — reuse the exact system prompt from the
# previous turn so the Anthropic cache prefix matches.
agent._cached_system_prompt = stored_prompt
return
if stored_prompt:
stored_state = "stale_runtime"
logger.info(
"Stored system prompt for session %s has stale runtime identity; "
"rebuilding for model=%s provider=%s.",
agent.session_id,
getattr(agent, "model", "") or "",
getattr(agent, "provider", "") or "",
)
if conversation_history and stored_state in ("null", "empty"):
# Continuing session whose stored prompt is unusable. The
@ -366,6 +375,30 @@ def _restore_or_build_system_prompt(agent, system_message, conversation_history)
)
def _stored_prompt_matches_runtime(agent, prompt: str) -> bool:
"""Return False when the persisted Model/Provider lines are stale."""
def line_value(label: str) -> str:
prefix = f"{label}:"
value = ""
for line in prompt.splitlines():
if line.startswith(prefix):
value = line[len(prefix):].strip()
return value
stored_model = line_value("Model")
current_model = str(getattr(agent, "model", "") or "").strip()
if stored_model and current_model and stored_model != current_model:
return False
stored_provider = line_value("Provider")
current_provider = str(getattr(agent, "provider", "") or "").strip()
if stored_provider and current_provider and stored_provider != current_provider:
return False
return True
def _get_continuation_prompt(is_partial_stub: bool, dropped_tools: Optional[List[str]] = None) -> str:
if is_partial_stub and dropped_tools:
tool_list = ", ".join(dropped_tools[:3])

View File

@ -9,6 +9,7 @@ import { formatCombo } from '@/lib/keybinds/combo'
import { cn } from '@/lib/utils'
import type { ConversationStatus } from './hooks/use-voice-conversation'
import { ModelPill } from './model-pill'
import type { ChatBarState, VoiceStatus } from './types'
export const ICON_BTN = 'size-(--composer-control-size) shrink-0 rounded-md'
@ -66,6 +67,7 @@ export function ComposerControls({
const c = t.composer
const steerCombo = formatCombo('mod+enter')
const steerLabel = `${c.steer} (${steerCombo})`
const steerTip = (
<span className="inline-flex items-center gap-1.5">
{c.steer}
@ -81,8 +83,10 @@ export function ComposerControls({
return (
<div className="ml-auto flex shrink-0 items-center gap-(--composer-control-gap)">
<DictationButton disabled={disabled} onToggle={onDictate} state={state.voice} status={voiceStatus} />
{canSteer && (
<ModelPill disabled={disabled} model={state.model} />
{/* While the agent runs and the user is typing, steer takes over the mic's
slot rather than crowding the row with an extra button. */}
{canSteer ? (
<Tip label={steerTip}>
<Button
aria-label={steerLabel}
@ -96,6 +100,8 @@ export function ComposerControls({
<SteeringWheel size={16} />
</Button>
</Tip>
) : (
<DictationButton disabled={disabled} onToggle={onDictate} state={state.voice} status={voiceStatus} />
)}
{showVoicePrimary ? (
<Tip label={c.startVoice}>

View File

@ -0,0 +1,86 @@
import { useStore } from '@nanostores/react'
import { useState } from 'react'
import { ModelMenuCloseContext } from '@/app/shell/model-menu-panel'
import { Button } from '@/components/ui/button'
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
import { useI18n } from '@/i18n'
import { ChevronDown } from '@/lib/icons'
import { formatModelStatusLabel } from '@/lib/model-status-label'
import { cn } from '@/lib/utils'
import {
$currentFastMode,
$currentModel,
$currentProvider,
$currentReasoningEffort,
setModelPickerOpen
} from '@/store/session'
import type { ChatBarState } from './types'
const PILL = cn(
'h-(--composer-control-size) max-w-40 shrink-0 gap-1 rounded-md px-2 text-xs font-normal',
'text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground'
)
/**
* Composer model selector the relocated status-bar pill. Reuses the live
* `model.options` dropdown (`modelMenuContent`) verbatim; falls back to the
* full picker when the gateway is closed and no live menu exists.
*/
export function ModelPill({ disabled, model }: { disabled: boolean; model: ChatBarState['model'] }) {
const copy = useI18n().t.shell.statusbar
const currentModel = useStore($currentModel)
const currentProvider = useStore($currentProvider)
const fastMode = useStore($currentFastMode)
const reasoningEffort = useStore($currentReasoningEffort)
const [open, setOpen] = useState(false)
// The model resolves a beat after the gateway/session comes up. Rather than
// flash a literal "No model", show a quiet loader (inherits the pill text
// color at half opacity) until a model lands.
const label = (
<>
{currentModel.trim() ? (
<span className="truncate">{formatModelStatusLabel(currentModel, { fastMode, reasoningEffort })}</span>
) : (
<GlyphSpinner className="opacity-50" spinner="braille" />
)}
<ChevronDown className="size-2.5 shrink-0 opacity-50" />
</>
)
const title = currentProvider ? copy.modelTitle(currentProvider, currentModel || copy.modelNone) : copy.switchModel
if (!model.modelMenuContent) {
return (
<Button
aria-label={copy.openModelPicker}
className={PILL}
disabled={disabled}
onClick={() => setModelPickerOpen(true)}
title={copy.openModelPicker}
type="button"
variant="ghost"
>
{label}
</Button>
)
}
return (
<DropdownMenu onOpenChange={setOpen} open={open}>
<DropdownMenuTrigger asChild>
<Button aria-label={title} className={PILL} disabled={disabled} title={title} type="button" variant="ghost">
{label}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64 p-0" side="top" sideOffset={8}>
<ModelMenuCloseContext.Provider value={() => setOpen(false)}>
{model.modelMenuContent}
</ModelMenuCloseContext.Provider>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@ -1,3 +1,5 @@
import type { ReactNode } from 'react'
import type { HermesGateway } from '@/hermes'
import type { ComposerAttachment } from '@/store/composer'
@ -22,6 +24,8 @@ export interface ChatBarState {
canSwitch: boolean
loading?: boolean
quickModels?: QuickModelOption[]
/** Reused status-bar dropdown (built with gateway + selectModel upstream). */
modelMenuContent?: ReactNode
}
tools: { enabled: boolean; label: string; suggestions?: ContextSuggestion[] }
voice: { enabled: boolean; active: boolean }

View File

@ -62,6 +62,7 @@ import { threadLoadingState } from './thread-loading'
interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> {
gateway: HermesGateway | null
modelMenuContent?: React.ReactNode
onToggleSelectedPin: () => void
onDeleteSelectedSession: () => void
onCancel: () => Promise<void> | void
@ -250,6 +251,7 @@ function ChatRuntimeBoundary({
export function ChatView({
className,
gateway,
modelMenuContent,
onToggleSelectedPin,
onDeleteSelectedSession,
onCancel,
@ -346,6 +348,7 @@ export function ChatView({
provider: currentProvider,
canSwitch: gatewayOpen,
loading: !gatewayOpen || (!currentModel && !currentProvider),
modelMenuContent,
quickModels
},
tools: {
@ -358,7 +361,7 @@ export function ChatView({
active: false
}
}),
[contextSuggestions, currentModel, currentProvider, gatewayOpen, quickModels]
[contextSuggestions, currentModel, currentProvider, gatewayOpen, modelMenuContent, quickModels]
)
// Drop files anywhere in the conversation area, not just on the composer

View File

@ -711,7 +711,9 @@ export function DesktopController() {
}
lastGatewayProfileRef.current = activeGatewayProfile
void refreshCurrentModel()
// Force: the new profile has its own default, so reseed even if the composer
// already shows the previous profile's model.
void refreshCurrentModel(true)
void refreshActiveProfile()
}, [activeGatewayProfile, refreshCurrentModel])
@ -859,7 +861,6 @@ export function DesktopController() {
gatewayLogLines,
gatewayState,
inferenceStatus,
modelMenuContent,
openAgents,
freshDraftReady,
openCommandCenterSection,
@ -981,6 +982,7 @@ export function DesktopController() {
<ChatView
gateway={gatewayRef.current}
maxVoiceRecordingSeconds={voiceMaxRecordingSeconds}
modelMenuContent={modelMenuContent}
onAddContextRef={composer.addContextRefAttachment}
onAddUrl={url => composer.addContextRefAttachment(`@url:${formatRefValue(url)}`, url)}
onAttachDroppedItems={composer.attachDroppedItems}

View File

@ -9,3 +9,22 @@ export const $terminalTakeover = atom(storedBoolean(TAKEOVER_KEY, false))
$terminalTakeover.subscribe(active => persistBoolean(TAKEOVER_KEY, active))
export const setTerminalTakeover = (active: boolean) => $terminalTakeover.set(active)
/** A command queued to run in the embedded terminal. The terminal pane flushes
* (and clears) it once its session is live, so a value set before the pane
* mounts still runs. Cleared after flush so a later remount can't replay it. */
export const $terminalInjection = atom<null | string>(null)
/** Open the terminal pane and run a command in it. Used to disconnect external
* (CLI-managed) providers, which Hermes can't clear via the API the user
* sees exactly what runs instead of Hermes silently deleting their creds. */
export const runInTerminal = (command: string) => {
const trimmed = command.trim()
if (!trimmed) {
return
}
setTerminalTakeover(true)
$terminalInjection.set(trimmed)
}

View File

@ -10,6 +10,8 @@ import { triggerHaptic } from '@/lib/haptics'
import { $filePreviewTarget, $previewTarget } from '@/store/preview'
import { useTheme } from '@/themes/context'
import { $terminalInjection } from '../store'
import { makeTerminalReader, setActiveTerminalReader } from './buffer'
import {
isAddSelectionShortcut,
@ -675,6 +677,28 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
return () => cancelAnimationFrame(raf)
}, [activeTheme, themeName])
// Flush a queued command (e.g. a provider-disconnect) into the live session.
// Only active while open; the subscribe fires immediately, so a command set
// before this pane mounted runs as soon as the session is ready. Clearing the
// atom after writing stops a later remount from replaying a stale command.
useEffect(() => {
if (status !== 'open') {
return
}
return $terminalInjection.subscribe(command => {
const id = sessionIdRef.current
if (!command || !id) {
return
}
void window.hermesDesktop?.terminal?.write(id, `${command}\r`)
$terminalInjection.set(null)
termRef.current?.focus()
})
}, [status])
return {
addSelectionToChat,
hostRef,

View File

@ -130,7 +130,6 @@ describe('useModelControls', () => {
await expect(
controls.selectModel({
model: 'claude-sonnet-4.6',
persistGlobal: false,
provider: 'anthropic'
})
).resolves.toBe(true)
@ -143,26 +142,57 @@ describe('useModelControls', () => {
expect(requestGateway).not.toHaveBeenCalledWith('slash.exec', expect.anything())
})
it('keeps the global path on setGlobalModel when there is no active session', async () => {
setGlobalModel.mockResolvedValue(undefined)
it('stores a no-session pick as UI state with no gateway or global write', async () => {
const requestGateway = vi.fn()
let controls!: Controls
render(
<Harness
activeSessionId={null}
onReady={value => (controls = value)}
requestGateway={vi.fn()}
requestGateway={requestGateway}
/>
)
await expect(
controls.selectModel({
model: 'claude-sonnet-4.6',
persistGlobal: false,
provider: 'anthropic'
})
).resolves.toBe(true)
expect(setGlobalModel).toHaveBeenCalledWith('anthropic', 'claude-sonnet-4.6')
// The pick is plain UI state; session.create ships it later. Nothing touches
// the gateway or the profile default here.
expect($currentModel.get()).toBe('claude-sonnet-4.6')
expect($currentProvider.get()).toBe('anthropic')
expect(requestGateway).not.toHaveBeenCalled()
expect(setGlobalModel).not.toHaveBeenCalled()
})
it('seeds an empty composer model from global but never clobbers a pick', async () => {
vi.mocked(getGlobalModelInfo).mockResolvedValue({ model: 'openai/gpt-5.5', provider: 'openai-codex' })
const { result } = renderHook(() =>
useModelControls({
activeSessionId: null,
queryClient: new QueryClient(),
requestGateway: vi.fn()
})
)
// Empty → seeds the default.
await result.current.refreshCurrentModel()
expect($currentModel.get()).toBe('openai/gpt-5.5')
// A user pick must survive the lifecycle refreshes that fire on boot / fresh
// draft / session events.
setCurrentModel('anthropic/claude-sonnet-4.6')
setCurrentProvider('anthropic')
await result.current.refreshCurrentModel()
expect($currentModel.get()).toBe('anthropic/claude-sonnet-4.6')
// A profile swap forces a reseed to the new profile's default.
await result.current.refreshCurrentModel(true)
expect($currentModel.get()).toBe('openai/gpt-5.5')
})
})

View File

@ -1,7 +1,7 @@
import { type QueryClient } from '@tanstack/react-query'
import { useCallback } from 'react'
import { getGlobalModelInfo, setGlobalModel } from '@/hermes'
import { getGlobalModelInfo } from '@/hermes'
import { useI18n } from '@/i18n'
import { notifyError } from '@/store/notifications'
import {
@ -15,7 +15,6 @@ import type { ModelOptionsResponse } from '@/types/hermes'
interface ModelSelection {
model: string
persistGlobal: boolean
provider: string
}
@ -28,6 +27,7 @@ interface ModelControlsOptions {
export function useModelControls({ activeSessionId, queryClient, requestGateway }: ModelControlsOptions) {
const { t } = useI18n()
const copy = t.desktop
const updateModelOptionsCache = useCallback(
(provider: string, model: string, includeGlobal: boolean) => {
const patch = (prev: ModelOptionsResponse | undefined) => ({ ...(prev ?? {}), provider, model })
@ -41,14 +41,24 @@ export function useModelControls({ activeSessionId, queryClient, requestGateway
[activeSessionId, queryClient]
)
const refreshCurrentModel = useCallback(async () => {
// Seed the composer's model state from the profile default. `force` reseeds
// for a profile swap (the new profile has its own default); otherwise this
// only fills an EMPTY selection so a user's pick (plain UI state in
// $currentModel) survives the lifecycle refreshes that fire on boot / fresh
// draft / session events. A live session owns the footer, so skip entirely.
const refreshCurrentModel = useCallback(async (force = false) => {
try {
if ($activeSessionId.get()) {
return
}
if (!force && $currentModel.get()) {
return
}
const result = await getGlobalModelInfo()
// A resumed/live session owns the footer model state. Global config
// refreshes (gateway boot, profile swap, settings save) must not clobber
// the active chat's runtime model/provider in the status bar.
if ($activeSessionId.get()) {
if ($activeSessionId.get() || (!force && $currentModel.get())) {
return
}
@ -64,12 +74,14 @@ export function useModelControls({ activeSessionId, queryClient, requestGateway
}
}, [])
// Returns whether the switch succeeded so callers can await it before
// applying follow-up changes (e.g. editing a model's reasoning/fast must land
// on the right active model — bail rather than write to the previous one).
// Returns whether the switch succeeded so callers can await it before applying
// follow-up changes. The composer model is plain UI state: with no live
// session it's just stored (and shipped on the next session.create); with one
// it's scoped to that session via config.set. It NEVER writes the profile
// default — that lives in Settings → Model — so picking a model here can't
// silently mutate global config.
const selectModel = useCallback(
async (selection: ModelSelection): Promise<boolean> => {
const includeGlobal = selection.persistGlobal || !activeSessionId
// Snapshot for rollback: the switch is applied optimistically, so a
// failure must restore the prior model/provider (store + query cache)
// rather than leave the UI showing a model the backend never selected.
@ -78,42 +90,34 @@ export function useModelControls({ activeSessionId, queryClient, requestGateway
setCurrentModel(selection.model)
setCurrentProvider(selection.provider)
updateModelOptionsCache(selection.provider, selection.model, includeGlobal)
try {
if (activeSessionId) {
await requestGateway('config.set', {
session_id: activeSessionId,
key: 'model',
value: `${selection.model} --provider ${selection.provider}${selection.persistGlobal ? ' --global' : ''}`
})
if (selection.persistGlobal) {
void refreshCurrentModel()
}
void queryClient.invalidateQueries({
queryKey: selection.persistGlobal ? ['model-options'] : ['model-options', activeSessionId]
})
updateModelOptionsCache(selection.provider, selection.model, !activeSessionId)
// No live session yet: the pick is pure UI state. session.create reads
// $currentModel/$currentProvider and applies it as that session's override.
if (!activeSessionId) {
return true
}
await setGlobalModel(selection.provider, selection.model)
void refreshCurrentModel()
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
try {
await requestGateway('config.set', {
session_id: activeSessionId,
key: 'model',
value: `${selection.model} --provider ${selection.provider}`
})
void queryClient.invalidateQueries({ queryKey: ['model-options', activeSessionId] })
return true
} catch (err) {
setCurrentModel(prevModel)
setCurrentProvider(prevProvider)
updateModelOptionsCache(prevProvider, prevModel, includeGlobal)
updateModelOptionsCache(prevProvider, prevModel, !activeSessionId)
notifyError(err, copy.modelSwitchFailed)
return false
}
},
[activeSessionId, copy.modelSwitchFailed, queryClient, refreshCurrentModel, requestGateway, updateModelOptionsCache]
[activeSessionId, copy.modelSwitchFailed, queryClient, requestGateway, updateModelOptionsCache]
)
return { refreshCurrentModel, selectModel, updateModelOptionsCache }

View File

@ -15,6 +15,10 @@ import { requestDesktopOnboarding } from '@/store/onboarding'
import { $activeGatewayProfile, $newChatProfile, $profiles, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile'
import {
$currentCwd,
$currentFastMode,
$currentModel,
$currentProvider,
$currentReasoningEffort,
$messages,
$sessions,
$yoloActive,
@ -407,13 +411,13 @@ export function useSessionActions({
})
setSessionStartedAt(null)
setTurnStartedAt(null)
// New chats start in the configured default project dir when set,
// otherwise the sticky last-used workspace (PR #37586).
setCurrentModel('')
setCurrentProvider('')
setCurrentReasoningEffort('')
// The composer's model/effort/fast is sticky UI state (persisted in
// localStorage) — a new chat FOLLOWS your last pick instead of snapping
// back to the profile default, so we deliberately don't reset it here. The
// profile default still owns first-run seeding and profile switches (see
// refreshCurrentModel). Only $currentServiceTier (a live-session mirror)
// is cleared.
setCurrentServiceTier('')
setCurrentFastMode(false)
setYoloActive(false)
setCurrentCwd(workspaceCwdForNewSession())
setCurrentBranch('')
@ -443,11 +447,23 @@ export function useSessionActions({
const newChatProfile = $newChatProfile.get() ?? normalizeProfileKey($activeGatewayProfile.get())
await ensureGatewayProfile(newChatProfile)
const cwd = $currentCwd.get().trim() || workspaceCwdForNewSession()
// The composer's model/effort/fast is sticky UI state ($currentModel,
// $currentProvider, $currentReasoningEffort, $currentFastMode). Ship it
// with every session.create so the new chat opens on whatever the picker
// shows — applied as per-session overrides, never written to the profile
// default (that lives in Settings → Model).
const uiModel = $currentModel.get().trim()
const uiProvider = $currentProvider.get().trim()
const uiEffort = $currentReasoningEffort.get().trim()
const uiFast = $currentFastMode.get()
const created = await requestGateway<SessionCreateResponse>('session.create', {
cols: 96,
...(cwd && { cwd }),
...(newChatProfile ? { profile: newChatProfile } : {})
...(newChatProfile ? { profile: newChatProfile } : {}),
...(uiModel ? { model: uiModel, ...(uiProvider ? { provider: uiProvider } : {}) } : {}),
...(uiEffort ? { reasoning_effort: uiEffort } : {}),
...(uiFast ? { fast: true } : {})
})
const stored = created.stored_session_id ?? null

View File

@ -228,7 +228,7 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
onMainModelChanged={onMainModelChanged}
/>
) : activeView === 'providers' ? (
<ProvidersSettings onViewChange={setProviderView} view={providerView} />
<ProvidersSettings onClose={onClose} onViewChange={setProviderView} view={providerView} />
) : activeView === 'keys' ? (
<KeysSettings view={keysView} />
) : activeView === 'mcp' ? (

View File

@ -55,7 +55,7 @@ afterEach(() => {
async function renderProvidersSettings() {
const { ProvidersSettings } = await import('./providers-settings')
return render(<ProvidersSettings onViewChange={vi.fn()} view="accounts" />)
return render(<ProvidersSettings onClose={vi.fn()} onViewChange={vi.fn()} view="accounts" />)
}
describe('ProvidersSettings', () => {
@ -95,6 +95,6 @@ describe('ProvidersSettings', () => {
expect(await screen.findByText('Qwen Code')).toBeTruthy()
expect(screen.queryByRole('button', { name: 'Remove Qwen Code' })).toBeNull()
expect(screen.getByText(/managed outside Hermes/)).toBeTruthy()
expect(screen.getByText(/managed by its own CLI/)).toBeTruthy()
})
})

View File

@ -1,6 +1,8 @@
import { useStore } from '@nanostores/react'
import type { ReactNode } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { runInTerminal } from '@/app/right-sidebar/store'
import {
FEATURED_ID,
FeaturedProviderRow,
@ -23,6 +25,20 @@ import { SettingsCategoryHeading, useEnvCredentials } from './env-credentials'
import { providerGroup, providerMeta, providerPriority } from './helpers'
import { LoadingState, SettingsContent } from './primitives'
// The embedded terminal (and thus the "run disconnect command" path) only
// exists in the Electron desktop shell, not the web dashboard.
const canRunInTerminal = () => typeof window !== 'undefined' && Boolean(window.hermesDesktop?.terminal)
// Parallel group headers ("Connected", "Other providers") so the expanded list
// reads as its own section instead of bleeding into the connected group.
function GroupLabel({ children }: { children: ReactNode }) {
return (
<p className="mt-3 px-0.5 text-[length:var(--conversation-caption-font-size)] font-medium text-(--ui-text-tertiary)">
{children}
</p>
)
}
// Sub-views surfaced as a sidebar subnav: account sign-in vs raw API keys.
export const PROVIDER_VIEWS = ['accounts', 'keys'] as const
@ -90,11 +106,13 @@ function buildProviderKeyGroups(vars: Record<string, EnvVarInfo>): ProviderKeyGr
function OAuthPicker({
disconnecting,
onDisconnect,
onTerminalDisconnect,
onWantApiKey,
providers
}: {
disconnecting: null | string
onDisconnect: (provider: OAuthProvider) => void
onTerminalDisconnect: (provider: OAuthProvider) => void
onWantApiKey: () => void
providers: OAuthProvider[]
}) {
@ -138,15 +156,14 @@ function OAuthPicker({
{featured && <FeaturedProviderRow onSelect={select} provider={featured} />}
{connected.length > 0 && (
<>
<p className="mt-1 px-0.5 text-[length:var(--conversation-caption-font-size)] font-medium text-(--ui-text-tertiary)">
{p.connected}
</p>
<GroupLabel>{p.connected}</GroupLabel>
{connected.map(p => (
<ConnectedProviderRow
disconnecting={disconnecting === p.id}
key={p.id}
onDisconnect={onDisconnect}
onSelect={select}
onTerminalDisconnect={onTerminalDisconnect}
provider={p}
/>
))}
@ -154,6 +171,7 @@ function OAuthPicker({
)}
{showOthers && (
<>
{connected.length > 0 && <GroupLabel>{p.otherProviders}</GroupLabel>}
{others.map(p => (
<ProviderRow key={p.id} onSelect={select} provider={p} />
))}
@ -180,21 +198,26 @@ function ConnectedProviderRow({
disconnecting,
onDisconnect,
onSelect,
onTerminalDisconnect,
provider
}: {
disconnecting: boolean
onDisconnect: (provider: OAuthProvider) => void
onSelect: (provider: OAuthProvider) => void
onTerminalDisconnect: (provider: OAuthProvider) => void
provider: OAuthProvider
}) {
const { t } = useI18n()
const copy = t.settings.providers
const title = providerTitle(provider)
const Trail = provider.flow === 'external' ? Terminal : ChevronRight
// Hermes can clear this provider's creds via the API.
const canDisconnect = provider.disconnectable ?? provider.flow !== 'external'
const disconnectHint = provider.flow === 'external'
? t.settings.providers.removeExternal(title, provider.cli_command)
: t.settings.providers.removeKeyManaged(title)
// External (CLI-managed) provider Hermes can't clear via the API, but ships a
// command we can run in the embedded terminal (Electron shell only).
const terminalDisconnect = !canDisconnect && Boolean(provider.disconnect_command) && canRunInTerminal()
// Only fall back to a static "remove it elsewhere" hint when we offer no button.
const showHint = !canDisconnect && !terminalDisconnect
return (
<div className="group grid grid-cols-[minmax(0,1fr)_auto] items-center gap-1 rounded-[6px] transition-colors hover:bg-(--ui-control-hover-background)">
@ -203,13 +226,13 @@ function ConnectedProviderRow({
<span className="truncate text-[length:var(--conversation-text-font-size)] font-semibold">{title}</span>
<span className="inline-flex shrink-0 items-center gap-1 bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
<Check className="size-3" />
{t.settings.providers.connected}
{copy.connected}
</span>
</div>
<p className="mt-1 text-xs leading-5 text-muted-foreground">{t.onboarding.flowSubtitles[provider.flow]}</p>
{!canDisconnect && (
{showHint && (
<p className="mt-0.5 truncate text-[0.68rem] leading-5 text-muted-foreground/70">
{disconnectHint}
{provider.flow === 'external' ? copy.removeExternalGeneric(title) : copy.removeKeyManaged(title)}
</p>
)}
</button>
@ -228,6 +251,18 @@ function ConnectedProviderRow({
{disconnecting ? <Loader2 className="size-3 animate-spin" /> : <Trash2 className="size-3" />}
</Button>
)}
{terminalDisconnect && (
<Button
aria-label={`${copy.disconnect} ${title}`}
onClick={() => onTerminalDisconnect(provider)}
size="icon-xs"
title={copy.disconnectInTerminal}
type="button"
variant="ghost"
>
<Trash2 className="size-3" />
</Button>
)}
</div>
</div>
)
@ -243,7 +278,7 @@ function NoProviderKeys() {
)
}
export function ProvidersSettings({ onViewChange, view }: ProvidersSettingsProps) {
export function ProvidersSettings({ onClose, onViewChange, view }: ProvidersSettingsProps) {
const { t } = useI18n()
const { rowProps, vars } = useEnvCredentials()
const [oauthProviders, setOauthProviders] = useState<OAuthProvider[]>([])
@ -282,6 +317,29 @@ export function ProvidersSettings({ onViewChange, view }: ProvidersSettingsProps
return () => void (cancelled = true)
}, [onboardingActive])
// External (CLI-managed) providers can't be cleared via the API by design —
// Hermes never deletes creds another tool owns behind a silent API call.
// Instead we run the documented removal command in the embedded terminal so
// the user sees exactly what executes, then return them to chat to watch it.
function handleTerminalDisconnect(provider: OAuthProvider) {
const command = provider.disconnect_command
if (!command) {
return
}
const name = providerTitle(provider)
if (!window.confirm(t.settings.providers.removeTerminalConfirm(name, command))) {
return
}
// Leave the settings overlay so the terminal pane (chat-only) is visible.
onClose()
runInTerminal(command)
notify({ kind: 'info', title: t.settings.providers.removedTitle, message: t.settings.providers.removeTerminalRunning(name) })
}
async function handleDisconnect(provider: OAuthProvider) {
const name = providerTitle(provider)
@ -341,6 +399,7 @@ export function ProvidersSettings({ onViewChange, view }: ProvidersSettingsProps
<OAuthPicker
disconnecting={disconnecting}
onDisconnect={provider => void handleDisconnect(provider)}
onTerminalDisconnect={handleTerminalDisconnect}
onWantApiKey={() => onViewChange('keys')}
providers={oauthProviders}
/>
@ -359,6 +418,7 @@ interface ProviderKeyGroup {
}
interface ProvidersSettingsProps {
onClose: () => void
onViewChange: (view: ProviderView) => void
view: ProviderView
}

View File

@ -1,5 +1,4 @@
import { useStore } from '@nanostores/react'
import type { ReactNode } from 'react'
import { useCallback, useMemo } from 'react'
import type { CommandCenterSection } from '@/app/command-center'
@ -9,7 +8,6 @@ import { useI18n } from '@/i18n'
import {
Activity,
AlertCircle,
ChevronDown,
Clock,
Command,
Hash,
@ -19,7 +17,6 @@ import {
Zap,
ZapFilled
} from '@/lib/icons'
import { formatModelStatusLabel } from '@/lib/model-status-label'
import type { RuntimeReadinessResult } from '@/lib/runtime-readiness'
import { contextBarLabel, LiveDuration, usageContextLabel } from '@/lib/statusbar'
import { cn } from '@/lib/utils'
@ -30,16 +27,11 @@ import {
$activeSessionId,
$busy,
$connection,
$currentFastMode,
$currentModel,
$currentProvider,
$currentReasoningEffort,
$currentUsage,
$sessionStartedAt,
$turnStartedAt,
$workingSessionIds,
$yoloActive,
setModelPickerOpen,
setYoloActive
} from '@/store/session'
import { $subagentsBySession, activeSubagentCount } from '@/store/subagents'
@ -65,7 +57,6 @@ interface StatusbarItemsOptions {
gatewayLogLines: readonly string[]
gatewayState: string
inferenceStatus: RuntimeReadinessResult | null
modelMenuContent?: ReactNode
openAgents: () => void
openCommandCenterSection: (section: CommandCenterSection) => void
freshDraftReady: boolean
@ -83,7 +74,6 @@ export function useStatusbarItems({
gatewayLogLines,
gatewayState,
inferenceStatus,
modelMenuContent,
openAgents,
openCommandCenterSection,
freshDraftReady,
@ -97,10 +87,6 @@ export function useStatusbarItems({
const terminalTakeover = useStore($terminalTakeover)
const yoloActive = useStore($yoloActive)
const busy = useStore($busy)
const currentFastMode = useStore($currentFastMode)
const currentModel = useStore($currentModel)
const currentProvider = useStore($currentProvider)
const currentReasoningEffort = useStore($currentReasoningEffort)
const currentUsage = useStore($currentUsage)
const desktopActionTasks = useStore($desktopActionTasks)
const previewServerRestartStatus = useStore($previewServerRestartStatus)
@ -416,37 +402,6 @@ export function useStatusbarItems({
title: yoloActive ? copy.yoloOn : copy.yoloOff,
variant: 'action'
},
{
id: 'model-summary',
label: (
<span className="inline-flex min-w-0 items-center gap-0.5">
<span className="truncate">
{formatModelStatusLabel(currentModel, {
fastMode: currentFastMode,
reasoningEffort: currentReasoningEffort
})}
</span>
<ChevronDown className="size-2.5 shrink-0 opacity-50" />
</span>
),
...(modelMenuContent
? {
menuAlign: 'end' as const,
menuClassName: 'w-64',
menuContent: modelMenuContent,
title: currentProvider
? copy.modelTitle(currentProvider, currentModel || copy.modelNone)
: copy.switchModel,
variant: 'menu' as const
}
: {
onSelect: () => setModelPickerOpen(true),
title: currentProvider
? copy.providerModelTitle(currentProvider, currentModel || copy.noModel)
: copy.openModelPicker,
variant: 'action' as const
})
},
{
className: `w-7 justify-center px-0${terminalTakeover ? ' bg-accent/55 text-foreground' : ''}`,
hidden: !chatOpen,
@ -465,11 +420,6 @@ export function useStatusbarItems({
contextBar,
contextUsage,
copy,
currentFastMode,
currentModel,
currentProvider,
currentReasoningEffort,
modelMenuContent,
sessionStartedAt,
showYoloToggle,
terminalTakeover,

View File

@ -0,0 +1,84 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { DropdownMenu, DropdownMenuContent, DropdownMenuSub, DropdownMenuSubTrigger } from '@/components/ui/dropdown-menu'
import { $modelPresets, getModelPreset } from '@/store/model-presets'
import { $activeSessionId } from '@/store/session'
import { type FastControl, ModelEditSubmenu } from './model-edit-submenu'
// Radix calls these on open; jsdom doesn't implement them.
beforeAll(() => {
Element.prototype.scrollIntoView = vi.fn()
Element.prototype.hasPointerCapture = vi.fn(() => false)
Element.prototype.releasePointerCapture = vi.fn()
})
beforeEach(() => {
$modelPresets.set({})
$activeSessionId.set(null)
})
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
// Render the submenu inside an open menu/sub so its content (switches) mounts.
function renderSubmenu(opts: { fastControl: FastControl; reasoning: boolean; requestGateway: () => Promise<unknown> }) {
return render(
<DropdownMenu open>
<DropdownMenuContent>
<DropdownMenuSub open>
<DropdownMenuSubTrigger>edit</DropdownMenuSubTrigger>
<ModelEditSubmenu
effort="medium"
fastControl={opts.fastControl}
isActive
model="m1"
onSelectModel={vi.fn()}
provider="p1"
reasoning={opts.reasoning}
requestGateway={opts.requestGateway as never}
/>
</DropdownMenuSub>
</DropdownMenuContent>
</DropdownMenu>
)
}
// Regression: editing the active row before a live session exists must stay
// preset-only — the gateway's config.set falls back to global config when no
// session matches, so it must not be called. (Caught in the second review.)
describe('ModelEditSubmenu no-session guard', () => {
it('param fast: records the preset but skips the gateway without a session', () => {
const requestGateway = vi.fn().mockResolvedValue({})
renderSubmenu({ fastControl: { kind: 'param', on: false }, reasoning: false, requestGateway })
fireEvent.click(screen.getByRole('switch'))
expect(getModelPreset('p1', 'm1').fast).toBe(true)
expect(requestGateway).not.toHaveBeenCalled()
})
it('reasoning: records the preset but skips the gateway without a session', () => {
const requestGateway = vi.fn().mockResolvedValue({})
renderSubmenu({ fastControl: { kind: 'none' }, reasoning: true, requestGateway })
// Thinking starts on (medium); toggling it off routes through patchReasoning.
fireEvent.click(screen.getByRole('switch'))
expect(getModelPreset('p1', 'm1').effort).toBe('none')
expect(requestGateway).not.toHaveBeenCalled()
})
it('param fast: pushes to the gateway once a session is active', async () => {
const requestGateway = vi.fn().mockResolvedValue({})
$activeSessionId.set('sess1')
renderSubmenu({ fastControl: { kind: 'param', on: false }, reasoning: false, requestGateway })
fireEvent.click(screen.getByRole('switch'))
expect(requestGateway).toHaveBeenCalledWith('config.set', { key: 'fast', session_id: 'sess1', value: 'fast' })
})
})

View File

@ -12,13 +12,9 @@ import {
} from '@/components/ui/dropdown-menu'
import { Switch } from '@/components/ui/switch'
import { useI18n } from '@/i18n'
import { setModelPreset } from '@/store/model-presets'
import { notifyError } from '@/store/notifications'
import {
$activeSessionId,
$currentReasoningEffort,
setCurrentFastMode,
setCurrentReasoningEffort
} from '@/store/session'
import { $activeSessionId, setCurrentFastMode, setCurrentReasoningEffort } from '@/store/session'
// Hermes' real reasoning levels (see VALID_REASONING_EFFORTS); `none` is owned
// by the Thinking toggle, not the radio.
@ -76,96 +72,104 @@ export function resolveFastControl(
}
interface ModelEditSubmenuProps {
/** This row's effective reasoning effort (live for the active model, else its
* preset) the submenu shows and edits from this, never the raw session. */
effort: string
/** How fast mode is offered for this model (param toggle vs. variant swap). */
fastControl: FastControl
/** Whether this row's model is the active one. */
isActive: boolean
/** Switch to this model (resolves false on failure). Awaited before applying
* edits when not active so a failed switch doesn't write to the old model. */
onActivate: () => Promise<boolean> | void
/** This row's model id — edits persist as its global preset. */
model: string
/** Switch to a specific model id (used to swap base ⇄ -fast variant). */
onSelectModel: (model: string) => Promise<boolean> | void
/** This row's provider slug — edits persist as its global preset. */
provider: string
/** Whether this model supports reasoning effort. */
reasoning: boolean
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
}
export function ModelEditSubmenu({
effort,
fastControl,
isActive,
onActivate,
model,
onSelectModel,
provider,
reasoning,
requestGateway
}: ModelEditSubmenuProps) {
const { t } = useI18n()
const copy = t.shell.modelOptions
// Reactive session state comes straight from the stores rather than being
// drilled through the panel, so editing it re-renders only this submenu.
const activeSessionId = useStore($activeSessionId)
const currentReasoningEffort = useStore($currentReasoningEffort)
const effort = normalizeEffort(currentReasoningEffort)
const thinkingOn = isThinkingEnabled(currentReasoningEffort)
const effortValue = normalizeEffort(effort)
const thinkingOn = isThinkingEnabled(effort)
// Reasoning/fast are session-scoped (they apply to the active model), so
// editing a non-active model first switches to it. Returns false if the
// switch failed, so callers skip applying to the wrong (previous) model.
const ensureActive = async (): Promise<boolean> => {
if (isActive) {
return true
}
return (await onActivate()) !== false
}
const patchReasoning = async (next: string, rollback: string) => {
setCurrentReasoningEffort(next)
try {
if (!(await ensureActive())) {
setCurrentReasoningEffort(rollback)
// Editing always records the model's global preset; the active model also gets
// it pushed onto the live session. Non-active edits stay preset-only — they do
// not switch you to that model.
const patchReasoning = async (next: string) => {
setModelPreset(provider, model, { effort: next })
if (!isActive) {
return
}
await requestGateway('config.set', {
key: 'reasoning',
session_id: activeSessionId ?? '',
value: next
})
setCurrentReasoningEffort(next)
// Preset-only without a session: `isActive` holds for the global/default
// row pre-session, and the gateway's `config.set` falls back to global
// config when none matches — so don't reach it (preset + optimistic store
// are the whole effect). Same guard in applyModelPreset / toggleFast.
if (!activeSessionId) {
return
}
try {
await requestGateway('config.set', { key: 'reasoning', session_id: activeSessionId, value: next })
} catch (err) {
setCurrentReasoningEffort(rollback)
setCurrentReasoningEffort(effort)
setModelPreset(provider, model, { effort })
notifyError(err, copy.updateFailed)
}
}
const toggleFast = (enabled: boolean) => {
if (fastControl.kind === 'variant') {
// Fast is a separate model id — swap to it (or back to the base).
// Fast is a separate model id. Record the choice on the base model's
// preset (selectFamily picks the `-fast` sibling later when set), and
// only swap models now if this is the active row — inactive edits must
// stay preset-only, same as the param path below.
setModelPreset(provider, fastControl.baseId, { fast: enabled })
if (isActive) {
void onSelectModel(enabled ? fastControl.fastId : fastControl.baseId)
}
return
}
if (fastControl.kind === 'param') {
setCurrentFastMode(enabled)
void (async () => {
try {
if (!(await ensureActive())) {
setCurrentFastMode(!enabled)
setModelPreset(provider, model, { fast: enabled })
if (!isActive) {
return
}
await requestGateway('config.set', {
key: 'fast',
session_id: activeSessionId ?? '',
value: enabled ? 'fast' : 'normal'
})
setCurrentFastMode(enabled)
// Preset-only without a session (see patchReasoning).
if (!activeSessionId) {
return
}
void (async () => {
try {
await requestGateway('config.set', { key: 'fast', session_id: activeSessionId, value: enabled ? 'fast' : 'normal' })
} catch (err) {
setCurrentFastMode(!enabled)
setModelPreset(provider, model, { fast: !enabled })
notifyError(err, copy.fastFailed)
}
})()
@ -188,9 +192,7 @@ export function ModelEditSubmenu({
<Switch
checked={thinkingOn}
className="ml-auto"
onCheckedChange={checked =>
void patchReasoning(checked ? effort || 'medium' : 'none', currentReasoningEffort)
}
onCheckedChange={checked => void patchReasoning(checked ? effortValue || 'medium' : 'none')}
size="xs"
/>
</DropdownMenuItem>
@ -205,10 +207,7 @@ export function ModelEditSubmenu({
<>
<DropdownMenuSeparator className="mx-0" />
<DropdownMenuLabel className={dropdownMenuSectionLabel}>{copy.effort}</DropdownMenuLabel>
<DropdownMenuRadioGroup
onValueChange={value => void patchReasoning(value, currentReasoningEffort)}
value={effort}
>
<DropdownMenuRadioGroup onValueChange={value => void patchReasoning(value)} value={effortValue}>
{EFFORT_OPTIONS.map(option => (
<DropdownMenuRadioItem
className={dropdownMenuRow}

View File

@ -1,6 +1,6 @@
import { useStore } from '@nanostores/react'
import { useQuery } from '@tanstack/react-query'
import { useMemo, useState } from 'react'
import { createContext, useContext, useMemo, useState } from 'react'
import { Codicon } from '@/components/ui/codicon'
import {
@ -20,6 +20,7 @@ import { getGlobalModelOptions } from '@/hermes'
import { useI18n } from '@/i18n'
import { displayModelName, modelDisplayParts, reasoningEffortLabel } from '@/lib/model-status-label'
import { cn } from '@/lib/utils'
import { $modelPresets, applyModelPreset, modelPresetKey } from '@/store/model-presets'
import {
$visibleModels,
collapseModelFamilies,
@ -40,9 +41,14 @@ import type { ModelOptionProvider, ModelOptionsResponse } from '@/types/hermes'
import { ModelEditSubmenu, resolveFastControl } from './model-edit-submenu'
// Lets the host dropdown (model-pill) hand the panel a way to dismiss itself so
// clicking a model row commits + closes, while the hover-revealed edit submenu
// (reasoning/fast) stays open to play with (its items preventDefault on select).
export const ModelMenuCloseContext = createContext<() => void>(() => {})
interface ModelMenuPanelProps {
gateway?: HermesGateway
onSelectModel: (selection: { model: string; persistGlobal: boolean; provider: string }) => Promise<boolean> | void
onSelectModel: (selection: { model: string; provider: string }) => Promise<boolean> | void
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
}
@ -54,6 +60,7 @@ interface ProviderGroup {
export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: ModelMenuPanelProps) {
const { t } = useI18n()
const copy = t.shell.modelMenu
const closeMenu = useContext(ModelMenuCloseContext)
const [search, setSearch] = useState('')
// Reactive session state is read from the stores here (not drilled in), so
// toggling effort/fast/model re-renders this panel in place without forcing
@ -63,6 +70,7 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
const currentModel = useStore($currentModel)
const currentProvider = useStore($currentProvider)
const currentReasoningEffort = useStore($currentReasoningEffort)
const modelPresets = useStore($modelPresets)
const visibleModels = useStore($visibleModels)
const modelOptions = useQuery({
@ -87,13 +95,41 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
: null
const providers = modelOptions.data?.providers
const effectiveVisibleModels = useMemo(
() => effectiveVisibleKeys(visibleModels, providers ?? []),
[visibleModels, providers]
)
const switchTo = (model: string, provider: string) =>
onSelectModel({ model, persistGlobal: !activeSessionId, provider })
// The composer picker never persists the profile default. With a session it
// scopes the switch to that session; with none it's UI state shipped on the
// next session.create (see selectModel). The default lives in Settings → Model.
const switchTo = (model: string, provider: string) => onSelectModel({ model, provider })
// Selecting a model row restores that model's remembered preset onto the
// session (effort/fast), gated by capability. Unset → Hermes defaults.
const selectFamily = async (family: ModelFamily, provider: ModelOptionProvider) => {
const caps = provider.capabilities?.[family.id]
const preset = modelPresets[modelPresetKey(provider.slug, family.id)] ?? {}
// Variant-fast models (no speed param) express "fast" as a separate `-fast`
// id, so honor the saved preset by selecting that sibling. Param-fast is
// applied via applyModelPreset below instead.
const variantFast = !(caps?.fast ?? false) && !!family.fastId
const targetId = variantFast && preset.fast === true ? family.fastId! : family.id
if ((await switchTo(targetId, provider.slug)) === false) {
return
}
await applyModelPreset(
{
effort: (caps?.reasoning ?? true) ? (preset.effort ?? 'medium') : undefined,
fast: (caps?.fast ?? false) ? (preset.fast ?? false) : undefined
},
{ failMessage: t.shell.modelOptions.updateFailed, request: requestGateway, sessionId: activeSessionId }
)
}
const groups = useMemo(
() => groupModels(providers ?? [], search, { model: optionsModel, provider: optionsProvider }, effectiveVisibleModels),
@ -152,37 +188,42 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
// -fast variant carries the same param support as its base.
const caps = group.provider.capabilities?.[family.id]
// Single source of truth for the active row's fast state — keeps
// the row label in lock-step with the submenu's Fast toggle and
// handles the standalone `-fast` id case.
// Effective settings for this row: live session state when it's
// the active model, otherwise its remembered preset (Hermes
// defaults when unset). Row label AND submenu read from these so
// they never disagree.
const preset = modelPresets[modelPresetKey(group.provider.slug, family.id)] ?? {}
const effEffort = isCurrent ? currentReasoningEffort : preset.effort ?? ''
const effFast = isCurrent ? currentFastMode : preset.fast ?? false
const fastControl = resolveFastControl(
activeId ?? family.id,
group.provider.models ?? [],
caps?.fast ?? false,
currentFastMode
effFast
)
// Grayed text is live session state only. Do not label inactive
// rows as "Fast" just because they have a fast-capable sibling:
// that makes an off Fast toggle look like it is already on.
const meta = isCurrent
? [
const meta = [
fastControl.kind !== 'none' && fastControl.on ? copy.fast : null,
reasoningEffortLabel(currentReasoningEffort) || copy.medium
(caps?.reasoning ?? true) ? reasoningEffortLabel(effEffort) || copy.medium : null
]
.filter(Boolean)
.join(' ')
: ''
// Every row is a hover-Edit submenu trigger. Activating it
// (pointer or keyboard) switches to the family's base model;
// the Fast toggle inside swaps to the -fast sibling (or flips
// the speed param). The sub-trigger has no `onSelect`, so wire
// both click and Enter/Space for keyboard parity.
// (pointer or keyboard) switches to the family's base model and
// restores its preset; the Fast toggle inside swaps to the -fast
// sibling (or flips the speed param). The sub-trigger has no
// `onSelect`, so wire both click and Enter/Space for keyboard parity.
// Clicking the row commits the model and closes the picker; the
// edit submenu (reasoning/fast) is reached by HOVER, so you can
// still tweak those without the click dismissing everything.
const activate = () => {
if (!isCurrent) {
void switchTo(family.id, group.provider.slug)
void selectFamily(family, group.provider)
}
closeMenu()
}
return (
@ -204,10 +245,12 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
{isCurrent ? <Codicon className="ml-auto text-foreground" name="check" size="0.75rem" /> : null}
</DropdownMenuSubTrigger>
<ModelEditSubmenu
effort={effEffort}
fastControl={fastControl}
isActive={isCurrent}
onActivate={() => switchTo(family.id, group.provider.slug)}
model={family.id}
onSelectModel={nextModel => switchTo(nextModel, group.provider.slug)}
provider={group.provider.slug}
reasoning={caps?.reasoning ?? true}
requestGateway={requestGateway}
/>

View File

@ -140,7 +140,7 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
? 'pt-[calc(var(--titlebar-height)+0.75rem)]'
: isSecondaryWindow()
? 'pt-6'
: 'pt-[calc(var(--titlebar-height)+1.5rem)]'
: 'pt-[calc(var(--titlebar-height)-0.5rem)]'
useEffect(() => setThreadAtBottom(isAtBottom), [isAtBottom])
useEffect(() => () => resetThreadScroll(), [])

View File

@ -11,7 +11,6 @@ import { startManualOnboarding } from '../store/onboarding'
import { InlineNotice } from './notifications'
import { Button } from './ui/button'
import { Checkbox } from './ui/checkbox'
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from './ui/command'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from './ui/dialog'
import { Skeleton } from './ui/skeleton'
@ -23,7 +22,7 @@ interface ModelPickerDialogProps {
sessionId?: string | null
currentModel: string
currentProvider: string
onSelect: (selection: { provider: string; model: string; persistGlobal: boolean }) => void
onSelect: (selection: { provider: string; model: string }) => void
/**
* Optional class to apply to DialogContent. Use to override z-index when
* stacking the picker on top of another fixed overlay (e.g. the desktop
@ -45,7 +44,6 @@ export function ModelPickerDialog({
}: ModelPickerDialogProps) {
const { t } = useI18n()
const copy = t.modelPicker
const [persistGlobal, setPersistGlobal] = useState(!sessionId)
// Own the search term so we can filter manually. cmdk's built-in
// shouldFilter reorders items by its fuzzy-match score (≈alphabetical with
// an empty query), which destroys the backend's curated order. We disable
@ -79,11 +77,7 @@ export function ModelPickerDialog({
: null
const selectModel = (provider: ModelOptionProvider, model: string) => {
onSelect({
provider: provider.slug,
model,
persistGlobal: persistGlobal || !sessionId
})
onSelect({ provider: provider.slug, model })
onOpenChange(false)
}
@ -128,24 +122,13 @@ export function ModelPickerDialog({
</CommandList>
</Command>
<DialogFooter className="flex-row items-center justify-between gap-3 bg-card p-3 sm:justify-between">
<label className="flex cursor-pointer select-none items-center gap-2 text-xs text-muted-foreground">
<Checkbox
checked={persistGlobal || !sessionId}
disabled={!sessionId}
onCheckedChange={checked => setPersistGlobal(checked === true)}
/>
{sessionId ? copy.persistGlobalSession : copy.persistGlobal}
</label>
<div className="flex items-center gap-2">
<DialogFooter className="flex-row items-center justify-end gap-2 bg-card p-3">
<Button onClick={addProvider} variant="ghost">
{copy.addProvider}
</Button>
<Button onClick={() => onOpenChange(false)} variant="outline">
{t.common.cancel}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>

View File

@ -565,9 +565,14 @@ export const en: Translations = {
collapse: 'Collapse',
connectAnother: 'Connect another provider',
otherProviders: 'Other providers',
disconnect: 'Disconnect',
disconnectInTerminal: 'Disconnect (runs the removal command in the terminal)',
removeConfirm: provider => `Remove ${provider}?`,
removeExternal: (provider, command) => `${provider} is managed outside Hermes. Remove it with ${command}.`,
removeExternalGeneric: provider => `${provider} is managed by its own CLI — remove it there.`,
removeKeyManaged: provider => `${provider} is configured from an API key. Remove it from API Keys.`,
removeTerminalConfirm: (provider, command) =>
`Disconnect ${provider}? This runs "${command}" in the terminal to clear the credential.`,
removeTerminalRunning: provider => `Running ${provider} disconnect in the terminal…`,
removedTitle: 'Account removed',
removedMessage: provider => `${provider} was removed.`,
failedRemove: provider => `Could not remove ${provider}`,
@ -1498,8 +1503,6 @@ export const en: Translations = {
unknown: '(unknown)',
search: 'Filter providers and models...',
noModels: 'No models found.',
persistGlobalSession: 'Persist globally (otherwise this session only)',
persistGlobal: 'Persist globally',
addProvider: 'Add provider',
loadFailed: 'Could not load models',
noAuthenticatedProviders: 'No authenticated providers.',

View File

@ -695,7 +695,6 @@ export const ja = defineLocale({
connectAnother: '別のプロバイダーを接続',
otherProviders: 'その他のプロバイダー',
removeConfirm: provider => `${provider} を削除しますか?`,
removeExternal: (provider, command) => `${provider} は Hermes の外部で管理されています。${command} で削除してください。`,
removeKeyManaged: provider => `${provider} は API キーで設定されています。API Keys から削除してください。`,
removedTitle: 'アカウントを削除しました',
removedMessage: provider => `${provider} を削除しました。`,
@ -1638,8 +1637,6 @@ export const ja = defineLocale({
unknown: '(不明)',
search: 'プロバイダーとモデルをフィルター...',
noModels: 'モデルが見つかりません。',
persistGlobalSession: 'グローバルに保持(それ以外はこのセッションのみ)',
persistGlobal: 'グローバルに保持',
addProvider: 'プロバイダーを追加',
loadFailed: 'モデルを読み込めませんでした',
noAuthenticatedProviders: '認証済みプロバイダーがありません。',

View File

@ -447,9 +447,13 @@ export interface Translations {
collapse: string
connectAnother: string
otherProviders: string
disconnect: string
disconnectInTerminal: string
removeConfirm: (provider: string) => string
removeExternal: (provider: string, command: string) => string
removeExternalGeneric: (provider: string) => string
removeKeyManaged: (provider: string) => string
removeTerminalConfirm: (provider: string, command: string) => string
removeTerminalRunning: (provider: string) => string
removedTitle: string
removedMessage: (provider: string) => string
failedRemove: (provider: string) => string
@ -1141,8 +1145,6 @@ export interface Translations {
unknown: string
search: string
noModels: string
persistGlobalSession: string
persistGlobal: string
addProvider: string
loadFailed: string
noAuthenticatedProviders: string

View File

@ -672,7 +672,6 @@ export const zhHant = defineLocale({
connectAnother: '連結其他提供方',
otherProviders: '其他提供方',
removeConfirm: provider => `移除 ${provider}`,
removeExternal: (provider, command) => `${provider} 由 Hermes 外部管理。請使用 ${command} 移除。`,
removeKeyManaged: provider => `${provider} 由 API 金鑰設定。請從 API Keys 中移除。`,
removedTitle: '帳號已移除',
removedMessage: provider => `${provider} 已移除。`,
@ -1582,8 +1581,6 @@ export const zhHant = defineLocale({
unknown: '(未知)',
search: '篩選提供方和模型...',
noModels: '找不到模型。',
persistGlobalSession: '全域儲存(否則僅限此工作階段)',
persistGlobal: '全域儲存',
addProvider: '新增提供方',
loadFailed: '無法載入模型',
noAuthenticatedProviders: '沒有已驗證的提供方。',

View File

@ -759,9 +759,13 @@ export const zh: Translations = {
collapse: '收起',
connectAnother: '连接其他提供方',
otherProviders: '其他提供方',
disconnect: '断开连接',
disconnectInTerminal: '断开连接(在终端中运行移除命令)',
removeConfirm: provider => `移除 ${provider}`,
removeExternal: (provider, command) => `${provider} 由 Hermes 外部管理。请使用 ${command} 移除。`,
removeExternalGeneric: provider => `${provider} 由其自身的 CLI 管理 — 请在那里移除。`,
removeKeyManaged: provider => `${provider} 由 API 密钥配置。请从 API Keys 中移除。`,
removeTerminalConfirm: (provider, command) => `断开 ${provider}?这将在终端中运行 "${command}" 以清除凭据。`,
removeTerminalRunning: provider => `正在终端中断开 ${provider}`,
removedTitle: '账号已移除',
removedMessage: provider => `${provider} 已移除。`,
failedRemove: provider => `无法移除 ${provider}`,
@ -1679,8 +1683,6 @@ export const zh: Translations = {
unknown: '(未知)',
search: '筛选提供方和模型...',
noModels: '未找到模型。',
persistGlobalSession: '全局保存 (否则仅当前会话)',
persistGlobal: '全局保存',
addProvider: '添加提供方',
loadFailed: '无法加载模型',
noAuthenticatedProviders: '没有已认证的提供方。',

View File

@ -10,6 +10,11 @@ describe('model-status-label', () => {
expect(displayModelName('openai/gpt-5.5')).toBe('GPT-5.5')
})
it('strips trailing date-pin snapshots from the display name', () => {
expect(displayModelName('claude-opus-4-5-20251101')).toBe('Opus 4 5')
expect(displayModelName('anthropic/claude-haiku-4-5-20251001')).toBe('Haiku 4 5')
})
it('maps reasoning effort to compact labels', () => {
expect(reasoningEffortLabel('high')).toBe('High')
expect(reasoningEffortLabel('xhigh')).toBe('Max')

View File

@ -68,6 +68,9 @@ export function modelDisplayParts(model: string): { name: string; tag: string }
}
}
// Drop a trailing date-pin (`…-20251101`) — snapshot noise, not a name.
base = base.replace(/-\d{8}$/, '')
return { name: prettifyBase(base) || model.trim() || 'No model', tag }
}

View File

@ -0,0 +1,51 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { $modelPresets, applyModelPreset, getModelPreset, modelPresetKey, setModelPreset } from './model-presets'
describe('model presets', () => {
beforeEach(() => $modelPresets.set({}))
it('round-trips a preset and merges patches without dropping prior fields', () => {
setModelPreset('anthropic', 'claude-opus-4-8', { effort: 'high' })
setModelPreset('anthropic', 'claude-opus-4-8', { fast: true })
expect(getModelPreset('anthropic', 'claude-opus-4-8')).toEqual({ effort: 'high', fast: true })
})
it('returns an empty preset for unknown models', () => {
expect(getModelPreset('x', 'y')).toEqual({})
})
it('keys by provider::model', () => {
expect(modelPresetKey('openai', 'gpt-5.5')).toBe('openai::gpt-5.5')
})
it('pushes only the provided dimensions to the gateway', async () => {
const calls: { method: string; params?: Record<string, unknown> }[] = []
const request = async <T>(method: string, params?: Record<string, unknown>) => {
calls.push({ method, params })
return {} as T
}
await applyModelPreset({ effort: 'high' }, { failMessage: 'x', request, sessionId: 's1' })
await applyModelPreset({}, { failMessage: 'x', request, sessionId: 's1' })
expect(calls).toEqual([{ method: 'config.set', params: { key: 'reasoning', session_id: 's1', value: 'high' } }])
})
it('no-ops without a session so selecting a model cannot mutate global config', async () => {
const calls: { method: string; params?: Record<string, unknown> }[] = []
const request = async <T>(method: string, params?: Record<string, unknown>) => {
calls.push({ method, params })
return {} as T
}
await applyModelPreset({ effort: 'high', fast: true }, { failMessage: 'x', request, sessionId: null })
expect(calls).toEqual([])
})
})

View File

@ -0,0 +1,86 @@
import { atom } from 'nanostores'
import { persistString, storedString } from '@/lib/storage'
import { notifyError } from './notifications'
import { setCurrentFastMode, setCurrentReasoningEffort } from './session'
const STORAGE_KEY = 'hermes.desktop.model-presets'
/** Per-model reasoning/fast preset, remembered globally across sessions and
* re-applied to the session whenever that model is selected. Unset dimensions
* fall back to the Hermes default (medium effort, no fast). */
export interface ModelPreset {
effort?: string
fast?: boolean
}
type RequestGateway = <T>(method: string, params?: Record<string, unknown>) => Promise<T>
/** Stable `provider::model` key (matches the visibility-store format). */
export const modelPresetKey = (provider: string, model: string): string => `${provider}::${model}`
function load(): Record<string, ModelPreset> {
const raw = storedString(STORAGE_KEY)
if (!raw) {
return {}
}
try {
const parsed = JSON.parse(raw)
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Record<string, ModelPreset>) : {}
} catch {
return {}
}
}
export const $modelPresets = atom<Record<string, ModelPreset>>(load())
export function getModelPreset(provider: string, model: string): ModelPreset {
return $modelPresets.get()[modelPresetKey(provider, model)] ?? {}
}
/** Merge a partial preset for one model and persist. */
export function setModelPreset(provider: string, model: string, patch: ModelPreset): void {
const key = modelPresetKey(provider, model)
const next = { ...$modelPresets.get(), [key]: { ...$modelPresets.get()[key], ...patch } }
$modelPresets.set(next)
persistString(STORAGE_KEY, JSON.stringify(next))
}
/** Push a model's preset onto the active session (optimistic + gateway).
* `undefined` skips that dimension; values are capability-gated upstream.
* No-ops without a session the gateway's `config.set` reasoning/fast fall
* back to persistent (global/profile) config when none matches, so selecting
* a model must not reach it (else it rewrites `agent.*`, defaults included). */
export async function applyModelPreset(
{ effort, fast }: ModelPreset,
ctx: { failMessage: string; request: RequestGateway; sessionId: null | string }
): Promise<void> {
if (!ctx.sessionId) {
return
}
if (effort !== undefined) {
setCurrentReasoningEffort(effort)
}
if (fast !== undefined) {
setCurrentFastMode(fast)
}
try {
if (effort !== undefined) {
await ctx.request('config.set', { key: 'reasoning', session_id: ctx.sessionId, value: effort })
}
if (fast !== undefined) {
await ctx.request('config.set', { key: 'fast', session_id: ctx.sessionId, value: fast ? 'fast' : 'normal' })
}
} catch (err) {
notifyError(err, ctx.failMessage)
}
}

View File

@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'
import type { ModelOptionProvider } from '@/types/hermes'
import {
collapseModelFamilies,
effectiveVisibleKeys,
emptyProviderSentinelKey,
isProviderSentinel,
@ -78,6 +79,18 @@ describe('model visibility', () => {
expect(visible.has(modelVisibilityKey('nous', 'hermes-3-llama-3.1-8b'))).toBe(false)
})
it('folds a date-pinned snapshot into its rolling alias when present', () => {
const families = collapseModelFamilies(['claude-opus-4-5', 'claude-opus-4-5-20251101'])
expect(families.map(f => f.id)).toEqual(['claude-opus-4-5'])
})
it('keeps a date-pinned snapshot standing alone when it has no alias', () => {
const families = collapseModelFamilies(['claude-opus-4-5-20251101', 'claude-haiku-4-5-20251001'])
expect(families.map(f => f.id)).toEqual(['claude-opus-4-5-20251101', 'claude-haiku-4-5-20251001'])
})
it('sentinel key helper produces correct format', () => {
expect(emptyProviderSentinelKey('openai')).toBe('openai::')
expect(isProviderSentinel('openai::')).toBe(true)

View File

@ -51,6 +51,11 @@ export function collapseModelFamilies(models: readonly string[]): ModelFamily[]
continue
}
if (/-\d{8}$/.test(model) && present.has(model.replace(/-\d{8}$/, ''))) {
// A date-pinned snapshot superseded by its rolling alias — drop the dupe.
continue
}
const fastId = `${model}-fast`
const hasFast = present.has(fastId)
families.push({ fastId: hasFast ? fastId : null, id: model })

View File

@ -4,13 +4,23 @@ import { lastVisibleMessageIsUser } from '@/app/chat/thread-loading'
import type { ContextSuggestion } from '@/app/types'
import type { HermesConnection } from '@/global'
import type { ChatMessage } from '@/lib/chat-messages'
import { persistString, storedString } from '@/lib/storage'
import { persistBoolean, persistString, storedBoolean, storedString } from '@/lib/storage'
import type { SessionInfo, UsageStats } from '@/types/hermes'
type Updater<T> = T | ((current: T) => T)
const WORKSPACE_CWD_KEY = 'hermes.desktop.workspace-cwd'
// The composer's model/effort/fast is sticky UI state, NOT the profile default
// (that lives in Settings → Model). Persisting it in localStorage makes a pick
// follow across Cmd+N and app restarts instead of snapping back to the default.
// It's deliberately global (not per-profile): a profile switch force-reseeds to
// that profile's default, while within a profile new chats keep your last pick.
const COMPOSER_MODEL_KEY = 'hermes.desktop.composer.model'
const COMPOSER_PROVIDER_KEY = 'hermes.desktop.composer.provider'
const COMPOSER_EFFORT_KEY = 'hermes.desktop.composer.reasoning-effort'
const COMPOSER_FAST_KEY = 'hermes.desktop.composer.fast'
let configuredDefaultProjectDir = ''
function workspaceCwdKey(connection: HermesConnection | null = $connection.get()): string {
@ -208,11 +218,11 @@ export const $lastVisibleMessageIsUser = computed($messages, lastVisibleMessageI
export const $freshDraftReady = atom(false)
export const $busy = atom(false)
export const $awaitingResponse = atom(false)
export const $currentModel = atom('')
export const $currentProvider = atom('')
export const $currentReasoningEffort = atom('')
export const $currentModel = atom(storedString(COMPOSER_MODEL_KEY) ?? '')
export const $currentProvider = atom(storedString(COMPOSER_PROVIDER_KEY) ?? '')
export const $currentReasoningEffort = atom(storedString(COMPOSER_EFFORT_KEY) ?? '')
export const $currentServiceTier = atom('')
export const $currentFastMode = atom(false)
export const $currentFastMode = atom(storedBoolean(COMPOSER_FAST_KEY, false))
// Effective approval-bypass state mirrored from the gateway (session.info).
// Persistence lives in the backend config (approvals.mode), so this is a plain
// reflection of the truth the gateway reports rather than its own store.
@ -254,11 +264,29 @@ export const setMessages = (next: Updater<ChatMessage[]>) => updateAtom($message
export const setFreshDraftReady = (next: Updater<boolean>) => updateAtom($freshDraftReady, next)
export const setBusy = (next: Updater<boolean>) => updateAtom($busy, next)
export const setAwaitingResponse = (next: Updater<boolean>) => updateAtom($awaitingResponse, next)
export const setCurrentModel = (next: Updater<string>) => updateAtom($currentModel, next)
export const setCurrentProvider = (next: Updater<string>) => updateAtom($currentProvider, next)
export const setCurrentReasoningEffort = (next: Updater<string>) => updateAtom($currentReasoningEffort, next)
export const setCurrentModel = (next: Updater<string>) => {
updateAtom($currentModel, next)
persistString(COMPOSER_MODEL_KEY, $currentModel.get() || null)
}
export const setCurrentProvider = (next: Updater<string>) => {
updateAtom($currentProvider, next)
persistString(COMPOSER_PROVIDER_KEY, $currentProvider.get() || null)
}
export const setCurrentReasoningEffort = (next: Updater<string>) => {
updateAtom($currentReasoningEffort, next)
persistString(COMPOSER_EFFORT_KEY, $currentReasoningEffort.get() || null)
}
export const setCurrentServiceTier = (next: Updater<string>) => updateAtom($currentServiceTier, next)
export const setCurrentFastMode = (next: Updater<boolean>) => updateAtom($currentFastMode, next)
export const setCurrentFastMode = (next: Updater<boolean>) => {
updateAtom($currentFastMode, next)
persistBoolean(COMPOSER_FAST_KEY, $currentFastMode.get())
}
export const setYoloActive = (next: Updater<boolean>) => updateAtom($yoloActive, next)
export const setCurrentCwd = (next: Updater<string>) => {

View File

@ -5,6 +5,9 @@ import type { DesktopUpdateStatus } from '@/global'
const storage = new Map<string, string>()
vi.mock('@/lib/storage', () => ({
persistBoolean: (key: string, value: boolean) => {
storage.set(key, String(value))
},
persistString: (key: string, value: null | string) => {
if (value === null) {
storage.delete(key)
@ -12,6 +15,11 @@ vi.mock('@/lib/storage', () => ({
storage.set(key, value)
}
},
storedBoolean: (key: string, fallback: boolean) => {
const value = storage.get(key)
return value === undefined ? fallback : value === 'true'
},
storedString: (key: string) => storage.get(key) ?? null
}))

View File

@ -47,6 +47,9 @@ export interface OAuthProviderStatus {
export interface OAuthProvider {
cli_command: string
/** Shell command that clears an external provider's credentials, run in the
* embedded terminal. Null when Hermes doesn't know how to remove it. */
disconnect_command?: null | string
disconnect_hint?: null | string
disconnectable?: boolean
docs_url: string

View File

@ -5228,10 +5228,39 @@ def _resolve_provider_status(provider_id: str, status_fn) -> Dict[str, Any]:
return {"logged_in": False}
def _oauth_provider_disconnect_command(provider: Dict[str, Any]) -> Optional[str]:
"""Shell command that clears an external provider's credentials.
External providers store their credentials outside Hermes, so the disconnect
API deliberately refuses them (we never delete files another CLI owns on the
user's behalf via a silent API call). For the ones we know how to clear we
instead hand the GUI a command it can *run in the embedded terminal* the
user sees exactly what executes, and Hermes then stops resolving the token.
Claude Code has no scriptable logout (only the interactive ``/logout``), so
we remove the credential the same way logout does: the macOS Keychain entry
(``Claude Code-credentials``) and/or the ``~/.claude/.credentials.json``
file the two sources ``read_claude_code_credentials()`` consults. Returns
None for providers we can't safely clear (the GUI shows a manual hint).
"""
if provider.get("flow") != "external":
return None
if provider.get("id") == "claude-code":
rm_file = "rm -f ~/.claude/.credentials.json"
if sys.platform == "darwin":
return f'security delete-generic-password -s "Claude Code-credentials" 2>/dev/null; {rm_file}'
return rm_file
return None
def _oauth_provider_disconnect_hint(provider: Dict[str, Any], status: Dict[str, Any]) -> Optional[str]:
"""Return the manual disconnect path when the API cannot clear this provider."""
if provider.get("flow") == "external":
return f"Use `{provider['cli_command']}` or that provider's CLI to remove it."
if _oauth_provider_disconnect_command(provider):
# The GUI offers a one-click "run in terminal" path; this hint is the
# fallback wording for surfaces that only show text.
return "Managed outside Hermes — run the disconnect command to remove it."
return "Managed by that provider's CLI; remove it there."
if status.get("source") == "env_var":
return "Remove the API key from Settings → Keys instead."
return None
@ -5246,6 +5275,8 @@ async def list_oauth_providers(profile: Optional[str] = None):
name human label
flow "pkce" | "device_code" | "external" | "loopback"
cli_command fallback CLI command for users to run manually
disconnect_command shell command that clears an external provider's
creds (run in the embedded terminal), else null
docs_url external docs/portal link for the "Learn more" link
status:
logged_in bool currently has usable creds
@ -5267,6 +5298,7 @@ async def list_oauth_providers(profile: Optional[str] = None):
"cli_command": p["cli_command"],
"docs_url": p["docs_url"],
"disconnect_hint": disconnect_hint,
"disconnect_command": _oauth_provider_disconnect_command(p),
"disconnectable": disconnect_hint is None,
"status": status,
})

View File

@ -29,6 +29,7 @@ def _make_agent(session_db=None, prebuilt_prompt: str = "BUILT_PROMPT"):
agent._cached_system_prompt = None
agent.session_id = "test-session-id"
agent.model = "test-model"
agent.provider = "openrouter"
agent.platform = "cli"
agent._session_db = session_db
agent._build_system_prompt = MagicMock(return_value=prebuilt_prompt)
@ -67,6 +68,47 @@ class TestStoredPromptReuse:
_restore_or_build_system_prompt(agent, None, [{"role": "user", "content": "hi"}])
assert agent._cached_system_prompt == stored
def test_present_row_with_stale_runtime_identity_rebuilds(self, caplog):
"""Stored prompts are cache gold unless their runtime identity is stale.
A live /model switch updates the agent and DB model_config immediately.
If the old system_prompt snapshot still says the previous model,
blindly restoring it makes the next turn call the new model while the
model reads old `Model:` metadata ("what model are you?" lies).
"""
stored = (
"You are Hermes Agent.\n\n"
"Conversation started: Tuesday, June 16, 2026\n"
"Session ID: test-session-id\n"
"Model: anthropic/claude-opus-4.8-fast\n"
"Provider: openrouter"
)
db = MagicMock()
db.get_session.return_value = {"system_prompt": stored}
agent = _make_agent(
session_db=db,
prebuilt_prompt=(
"You are Hermes Agent.\n\n"
"Conversation started: Tuesday, June 16, 2026\n"
"Session ID: test-session-id\n"
"Model: openai/gpt-5.5\n"
"Provider: openrouter"
),
)
agent.model = "openai/gpt-5.5"
with caplog.at_level(logging.INFO, logger="agent.conversation_loop"):
_restore_or_build_system_prompt(agent, None, [{"role": "user", "content": "hi"}])
assert agent._cached_system_prompt.endswith(
"Model: openai/gpt-5.5\nProvider: openrouter"
)
agent._build_system_prompt.assert_called_once_with(None)
db.update_system_prompt.assert_called_once_with(
agent.session_id, agent._cached_system_prompt
)
assert any("stale runtime identity" in r.getMessage() for r in caplog.records)
# ---------------------------------------------------------------------------
# Legitimate fresh-build paths (no history, no DB)

View File

@ -476,13 +476,21 @@ def test_oauth_catalog_marks_external_providers_not_disconnectable():
assert resp.status_code == 200, resp.text
providers = {p["id"]: p for p in resp.json()["providers"]}
# Qwen: external and not auto-removable, and we don't know a clear command,
# so it stays a manual hint with no runnable disconnect command.
assert providers["qwen-oauth"]["flow"] == "external"
assert providers["qwen-oauth"]["disconnectable"] is False
assert "provider's CLI" in providers["qwen-oauth"]["disconnect_hint"]
assert providers["qwen-oauth"]["disconnect_command"] is None
# Claude Code: still not API-disconnectable, but we hand the GUI a runnable
# command (clears the keychain entry / credentials file) so it can offer a
# one-click "run in terminal" disconnect.
assert providers["claude-code"]["flow"] == "external"
assert providers["claude-code"]["disconnectable"] is False
assert "provider's CLI" in providers["claude-code"]["disconnect_hint"]
assert providers["claude-code"]["disconnect_hint"]
cmd = providers["claude-code"]["disconnect_command"]
assert cmd and ".claude/.credentials.json" in cmd
def test_external_oauth_disconnect_rejected_before_auth_mutation(monkeypatch):

View File

@ -1851,8 +1851,10 @@ def test_ensure_session_db_row_persists_explicit_cwd(monkeypatch, tmp_path):
created = []
class _FakeDB:
def create_session(self, key, source=None, model=None, cwd=None):
created.append({"key": key, "source": source, "model": model, "cwd": cwd})
def create_session(self, key, source=None, model=None, model_config=None, cwd=None):
created.append(
{"key": key, "source": source, "model": model, "model_config": model_config, "cwd": cwd}
)
monkeypatch.setattr(server, "_get_db", lambda: _FakeDB())
monkeypatch.setattr(server, "_resolve_model", lambda: "test-model")
@ -1860,7 +1862,7 @@ def test_ensure_session_db_row_persists_explicit_cwd(monkeypatch, tmp_path):
server._ensure_session_db_row({"session_key": "k1", "cwd": str(tmp_path), "explicit_cwd": True})
assert created == [
{"key": "k1", "source": "tui", "model": "test-model", "cwd": str(tmp_path)}
{"key": "k1", "source": "tui", "model": "test-model", "model_config": None, "cwd": str(tmp_path)}
]
@ -1870,15 +1872,74 @@ def test_ensure_session_db_row_defaults_to_no_workspace(monkeypatch, tmp_path):
created = []
class _FakeDB:
def create_session(self, key, source=None, model=None, cwd=None):
created.append({"key": key, "source": source, "model": model, "cwd": cwd})
def create_session(self, key, source=None, model=None, model_config=None, cwd=None):
created.append(
{"key": key, "source": source, "model": model, "model_config": model_config, "cwd": cwd}
)
monkeypatch.setattr(server, "_get_db", lambda: _FakeDB())
monkeypatch.setattr(server, "_resolve_model", lambda: "test-model")
server._ensure_session_db_row({"session_key": "k1", "cwd": str(tmp_path)})
assert created == [{"key": "k1", "source": "tui", "model": "test-model", "cwd": None}]
assert created == [
{"key": "k1", "source": "tui", "model": "test-model", "model_config": None, "cwd": None}
]
def test_ensure_session_db_row_persists_session_model_override(monkeypatch):
"""The session's composer pick (model + effort + fast) must own the DB row.
Regression for the "switched to gpt-5.5, reconnect snapped back to opus"
bug: the row was created with the global default and won the INSERT-OR-IGNORE
race, so resume rebuilt from the global model and silently reverted the
chat. The override model + a model_config carrying provider/reasoning/
service_tier must be persisted so session.resume restores all three.
"""
created = []
class _FakeDB:
def create_session(self, key, source=None, model=None, model_config=None, cwd=None):
created.append(
{"key": key, "model": model, "model_config": model_config, "cwd": cwd}
)
monkeypatch.setattr(server, "_get_db", lambda: _FakeDB())
monkeypatch.setattr(server, "_resolve_model", lambda: "global/default")
server._ensure_session_db_row(
{
"session_key": "k1",
"model_override": {"model": "openai/gpt-5.5", "provider": "openrouter"},
"create_reasoning_override": {"effort": "high"},
"create_service_tier_override": "priority",
}
)
assert len(created) == 1
row = created[0]
assert row["model"] == "openai/gpt-5.5"
assert row["model_config"]["model"] == "openai/gpt-5.5"
assert row["model_config"]["provider"] == "openrouter"
assert row["model_config"]["reasoning_config"] == {"effort": "high"}
assert row["model_config"]["service_tier"] == "priority"
def test_ensure_session_db_row_no_override_uses_global(monkeypatch):
"""A chat that made no explicit pick falls back to the global model and
writes no model_config (so it tracks the profile default)."""
created = []
class _FakeDB:
def create_session(self, key, source=None, model=None, model_config=None, cwd=None):
created.append({"model": model, "model_config": model_config})
monkeypatch.setattr(server, "_get_db", lambda: _FakeDB())
monkeypatch.setattr(server, "_resolve_model", lambda: "global/default")
server._ensure_session_db_row({"session_key": "k1", "model_override": None})
assert created == [{"model": "global/default", "model_config": None}]
def test_session_title_clears_pending_after_persist(monkeypatch):
@ -3265,12 +3326,39 @@ def test_config_set_model_switches_agent_without_touching_env(monkeypatch):
provider = "openai-codex"
base_url = ""
api_key = ""
session_id = "sid"
_cached_system_prompt = "Model: gpt-5.3-codex\nProvider: openai-codex"
def switch_model(self, **kwargs):
self.model = kwargs["new_model"]
self.provider = kwargs["new_provider"]
def _build_system_prompt(self, _system_message=None):
return f"Model: {self.model}\nProvider: {self.provider}"
class SessionDB:
def __init__(self):
self.model_config = None
self.system_prompt = None
self.messages = []
def get_session(self, _session_id):
return {"model_config": self.model_config}
def update_session_meta(self, _session_id, model_config_json, _model=None):
self.model_config = model_config_json
def update_system_prompt(self, _session_id, system_prompt):
self.system_prompt = system_prompt
def append_message(self, session_id, role, content=None, **_kwargs):
self.messages.append(
{"session_id": session_id, "role": role, "content": content}
)
agent = Agent()
db = SessionDB()
agent._session_db = db
session = _session(agent=agent)
server._sessions["sid"] = session
monkeypatch.setenv("HERMES_TUI_PROVIDER", "openai-codex")
@ -3312,6 +3400,21 @@ def test_config_set_model_switches_agent_without_touching_env(monkeypatch):
# ...override recorded on the session...
assert session["model_override"]["model"] == "anthropic/claude-sonnet-4.6"
assert session["model_override"]["provider"] == "anthropic"
# ...the persisted prompt snapshot tracks the new runtime identity too.
# Without this, the next turn restored the old system prompt from the DB:
# API calls went to the new model, but "what model are you?" still read
# "Model: old/model" from the stored prompt.
assert db.system_prompt == (
"Model: anthropic/claude-sonnet-4.6\nProvider: anthropic"
)
assert agent._cached_system_prompt == db.system_prompt
assert session["history"][-1]["role"] == "system"
assert "changed to anthropic/claude-sonnet-4.6" in session["history"][-1]["content"]
assert db.messages[-1] == {
"session_id": "session-key",
"role": "system",
"content": session["history"][-1]["content"],
}
# ...and the shared process env was NOT touched.
assert os.environ["HERMES_TUI_PROVIDER"] == "openai-codex"
assert "HERMES_MODEL" not in os.environ
@ -7497,3 +7600,97 @@ def test_reap_idle_sessions_closes_only_evictable(monkeypatch):
assert closed == [("stale", "idle_timeout")]
finally:
server._sessions.clear()
def test_session_create_records_ui_model_as_session_override(monkeypatch):
"""The desktop composer owns its model as plain UI state and ships it on
session.create. The gateway must record it as a PER-SESSION override (built
into the agent), never a global config write picking a model for a new chat
must not mutate the profile default.
"""
monkeypatch.setattr(server, "_enable_gateway_prompts", lambda: None)
# Don't run the real deferred build in this storage-focused test.
monkeypatch.setattr(server, "_start_agent_build", lambda *a, **k: None)
try:
resp = server._methods["session.create"](
"r1",
{
"cols": 80,
"model": "claude-sonnet-4.6",
"provider": "anthropic",
"reasoning_effort": "high",
"fast": True,
},
)
sid = resp["result"]["session_id"]
sess = server._sessions[sid]
assert sess["model_override"] == {"model": "claude-sonnet-4.6", "provider": "anthropic"}
assert sess["create_reasoning_override"] is not None
assert sess["create_service_tier_override"] == "priority"
# The immediate response reflects the override (not the global default) so
# the client never clobbers its sticky pick before the build lands.
assert resp["result"]["info"]["model"] == "claude-sonnet-4.6"
assert resp["result"]["info"]["provider"] == "anthropic"
# No knobs → no overrides; the session builds from the profile default.
plain = server._methods["session.create"]("r2", {"cols": 80})
plain_sess = server._sessions[plain["result"]["session_id"]]
assert plain_sess["model_override"] is None
assert plain_sess["create_reasoning_override"] is None
assert plain_sess["create_service_tier_override"] is None
finally:
server._sessions.clear()
def test_start_agent_build_passes_session_model_override(monkeypatch):
"""A model staged on the session (e.g. by session.create from the desktop
composer) must reach _make_agent so the first build runs on it directly
no global config, no build-then-switch.
"""
captured = {}
class FakeWorker:
def __init__(self, *_a, **_k):
pass
def close(self):
pass
def fake_make_agent(sid, key, session_id=None, session_db=None, **kwargs):
captured.update(kwargs)
return types.SimpleNamespace(model="claude-sonnet-4.6")
monkeypatch.setattr(server, "_set_session_context", lambda target: [])
monkeypatch.setattr(server, "_clear_session_context", lambda tokens: None)
monkeypatch.setattr(server, "_make_agent", fake_make_agent)
monkeypatch.setattr(server, "_SlashWorker", FakeWorker)
monkeypatch.setattr(server, "_attach_worker", lambda *a, **k: None)
monkeypatch.setattr(server, "_wire_callbacks", lambda _sid: None)
monkeypatch.setattr(server, "_emit", lambda *a, **k: None)
monkeypatch.setattr(server, "_session_info", lambda *a, **k: {})
monkeypatch.setattr(server, "_start_notification_poller", lambda *a, **k: None)
monkeypatch.setattr(server, "_notify_session_boundary", lambda *a, **k: None)
monkeypatch.setattr(server, "_probe_config_health", lambda *_a: None)
sid = "build-sid"
override = {"model": "claude-sonnet-4.6", "provider": "anthropic"}
reasoning = {"enabled": True, "effort": "high"}
session = {
"agent": None,
"agent_ready": threading.Event(),
"session_key": "k1",
"profile_home": None,
"model_override": override,
"create_reasoning_override": reasoning,
"create_service_tier_override": "priority",
}
server._sessions[sid] = session
try:
server._start_agent_build(sid, session)
assert session["agent_ready"].wait(timeout=3), "agent build did not finish"
assert captured.get("model_override") == override
assert captured.get("reasoning_config_override") == reasoning
assert captured.get("service_tier_override") == "priority"
assert session["agent"].model == "claude-sonnet-4.6"
finally:
server._sessions.clear()

View File

@ -946,6 +946,15 @@ def _start_agent_build(sid: str, session: dict) -> None:
kw = {"session_db": session_db}
if resume_sid := current.get("resume_session_id"):
kw["session_id"] = resume_sid
# Model/effort/fast the desktop picked for a brand-new chat ride
# in as per-session overrides so the first build uses them
# directly (no global config, no build-then-switch).
if override := current.get("model_override"):
kw["model_override"] = override
if (reasoning := current.get("create_reasoning_override")) is not None:
kw["reasoning_config_override"] = reasoning
if (tier := current.get("create_service_tier_override")) is not None:
kw["service_tier_override"] = tier
agent = _make_agent(sid, key, **kw)
finally:
_clear_session_context(tokens)
@ -1174,11 +1183,38 @@ def _ensure_session_db_row(session: dict) -> None:
close_db = False
if db is None:
return
# The session's own model/effort/fast pick — the composer override shipped on
# session.create, or a restored /model switch — must own the row's model +
# model_config. The agent isn't built yet at first prompt.submit, so derive
# the row from the live override dict; fall back to the global resolved model
# only when this chat made no explicit pick. Writing the global default here
# used to win the INSERT-OR-IGNORE race against the agent's own correct
# lazy-create, so a reconnect/resume rebuilt from the global model and
# silently reverted the chat (e.g. picked gpt-5.5, reconnect snapped back to
# the profile default). model_config carries provider/reasoning/service_tier
# so resume restores effort + fast too, not just the model name.
override = session.get("model_override")
override = override if isinstance(override, dict) else {}
row_model = str(override.get("model") or "").strip() or _resolve_model()
model_config: dict = {}
for src_key, cfg_key in (
("model", "model"),
("provider", "provider"),
("base_url", "base_url"),
("api_mode", "api_mode"),
):
if val := override.get(src_key):
model_config[cfg_key] = str(val)
if (reasoning := session.get("create_reasoning_override")) is not None:
model_config["reasoning_config"] = reasoning
if tier := session.get("create_service_tier_override"):
model_config["service_tier"] = tier
try:
db.create_session(
key,
source="tui",
model=_resolve_model(),
model=row_model,
model_config=model_config or None,
cwd=_session_cwd(session) if session.get("explicit_cwd") else None,
)
except Exception:
@ -1637,6 +1673,69 @@ def _persist_live_session_runtime(session: dict | None) -> None:
logger.debug("failed to persist live session runtime", exc_info=True)
def _persist_live_session_system_prompt(session: dict | None) -> None:
"""Refresh the stored system prompt after a live runtime identity change."""
if not session:
return
agent = session.get("agent")
session_key = str(session.get("session_key") or "").strip()
if agent is None or not session_key or not hasattr(agent, "_build_system_prompt"):
return
db = getattr(agent, "_session_db", None) or _get_db()
if db is None or not hasattr(db, "update_system_prompt"):
return
try:
prompt = agent._build_system_prompt(None)
agent._cached_system_prompt = prompt
db.update_system_prompt(getattr(agent, "session_id", None) or session_key, prompt)
except Exception:
logger.debug("failed to persist live session system prompt", exc_info=True)
def _append_model_switch_marker(session: dict | None, *, model: str, provider: str) -> None:
"""Record a real system-history pivot after a live model switch."""
if not session:
return
session_key = str(session.get("session_key") or "").strip()
if not session_key:
return
provider_part = f" via provider {provider}" if provider else ""
marker = (
"[System: The active model for this chat has changed to "
f"{model}{provider_part}. From this point forward, use this runtime "
"metadata when answering questions about what model/provider is active.]"
)
entry = {"role": "system", "content": marker}
lock = session.get("history_lock")
if lock is not None:
with lock:
session.setdefault("history", []).append(entry)
session["history_version"] = int(session.get("history_version", 0)) + 1
else:
session.setdefault("history", []).append(entry)
session["history_version"] = int(session.get("history_version", 0)) + 1
try:
agent = session.get("agent")
db = getattr(agent, "_session_db", None) if agent is not None else None
if db is not None:
db.append_message(session_id=session_key, role="system", content=marker)
return
_ensure_session_db_row(session)
with _session_db(session) as scoped_db:
if scoped_db is not None:
scoped_db.append_message(
session_id=session_key, role="system", content=marker
)
except Exception:
logger.debug("failed to persist model switch marker", exc_info=True)
def _write_config_key(key_path: str, value):
cfg = _load_cfg()
current = cfg
@ -2056,6 +2155,10 @@ def _apply_model_switch(
)
_restart_slash_worker(sid, session)
_persist_live_session_runtime(session)
_persist_live_session_system_prompt(session)
_append_model_switch_marker(
session, model=result.new_model, provider=result.target_provider
)
_emit("session.info", sid, _session_info(agent, session))
# Record the switch as a PER-SESSION override so a later rebuild of THIS
@ -3887,6 +3990,29 @@ def _(rid, params: dict) -> dict:
profile = (params.get("profile") or "").strip() or None
profile_home = _profile_home(profile)
# The desktop composer owns its model/effort/fast as plain UI state and ships
# it on every session.create. Honor each as a PER-SESSION override (built into
# the agent below) — never a global config write, so picking a model/effort
# for a new chat can't mutate the profile default. provider is optional
# (resolved at build).
create_model = str(params.get("model") or "").strip()
session_model_override = (
{"model": create_model, "provider": str(params.get("provider") or "").strip() or None}
if create_model
else None
)
create_reasoning_override = None
if effort := str(params.get("reasoning_effort") or "").strip():
try:
from hermes_constants import parse_reasoning_effort
create_reasoning_override = parse_reasoning_effort(effort)
except Exception:
create_reasoning_override = None
# Only pin "fast" when explicitly requested; leaving it None lets the build
# fall back to the profile default service tier rather than forcing normal.
create_service_tier_override = "priority" if params.get("fast") else None
ready = threading.Event()
now = time.time()
lease, limit_message = _claim_active_session_slot(key, live_session_id=sid)
@ -3912,6 +4038,9 @@ def _(rid, params: dict) -> dict:
"cwd": resolved_cwd,
"inflight_turn": None,
"last_active": now,
"model_override": session_model_override,
"create_reasoning_override": create_reasoning_override,
"create_service_tier_override": create_service_tier_override,
"pending_title": title or None,
"profile_home": str(profile_home) if profile_home is not None else None,
"running": False,
@ -3951,7 +4080,20 @@ def _(rid, params: dict) -> dict:
"message_count": len(history),
"messages": _history_to_messages(history),
"info": {
"model": _resolve_model(),
# Reflect the per-session model override (desktop composer pick)
# in the immediate response so the client doesn't briefly clobber
# its sticky pick with the global default before the deferred
# build's session.info lands.
"model": (
session_model_override.get("model")
if session_model_override
else _resolve_model()
),
**(
{"provider": session_model_override["provider"]}
if session_model_override and session_model_override.get("provider")
else {}
),
"tools": {},
"skills": {},
"cwd": _sessions[sid]["cwd"],

View File

@ -50,11 +50,18 @@ The center of the app. You get:
The bar along the bottom of the chat shows live session state and exposes quick controls without opening Settings:
- **Inline model picker** — switch the model for the active session straight from the status bar.
- **Per-session YOLO toggle** — flip YOLO on or off for just this session (matching the TUI). YOLO bypasses the dangerous-command approval prompts, so know what you're turning off — see [Security → YOLO Mode](./security.md#yolo-mode).
Chatting against a Hermes instance on another machine instead of the bundled local backend? See [Connecting to a remote backend](#connecting-to-a-remote-backend) below — and for the full picture of how the remote-hosted dashboard connection works (the auth gate, the `/api/ws` chat socket, and WebSocket close-code triage), see [Web Dashboard → Connecting Hermes Desktop to a remote backend](./features/web-dashboard.md#connecting-hermes-desktop-to-a-remote-backend).
#### Choosing a model
The model picker lives in the **composer**, just left of the microphone. Click it to switch the model, reasoning effort, and fast mode from one dropdown.
- **The composer picker is sticky UI state and never touches your default.** It's remembered locally (per device) and **follows** across new chats and restarts instead of snapping back to the default — pick a model once and the next `Cmd/Ctrl+N` opens on it. With a live chat, switching models scopes the change to that **current chat**; either way the selection rides along when the session is created/switched and is **never** written to the profile default. (Switching [profiles](#sessions--profiles) reseeds to that profile's own default.)
- **Set the default in Settings → Model.** That "main" model is your **per-profile global default** — it's what new chats, crons, subagents, and auxiliary tasks start from, and it's the only place that writes it. Each [profile](#sessions--profiles) keeps its own default.
- **Per-model effort/fast presets.** Each model remembers its own reasoning effort and fast-mode choice in the desktop app, re-applied to the session whenever you pick that model. These presets are a desktop convenience and don't change crons or subagents.
### File browser
Explore and preview the working directory without leaving the app — useful for following along as the agent reads, writes, and edits files. Set the initial project directory with `hermes desktop --cwd <path>` (or the `HERMES_DESKTOP_CWD` environment variable).