hermes-agent/ui-tui/src/components/modelPicker.tsx
kshitijk4poor 58919f68ab fix: also preserve provider selection on Esc-clear-filter path
The back() handler had the same filtered-index drift bug as the Enter
and Ctrl+D transitions: when the user presses Esc to clear an active
filter on the provider stage, providerIdx was reset to 0, losing the
highlighted provider. Apply the same providerIndexAfterClearingFilter
fix as the other three transition paths.

Also adds edge-case tests for the helper: undefined provider, slug not
found, empty rows, and duplicate slug first-match behavior.

Found by hermes-pr-review Phase 2 + hermes-agent-dev 3-agent review.
2026-06-27 04:33:48 +05:30

684 lines
21 KiB
TypeScript

import { Box, Text, useInput, useStdout } from '@hermes/ink'
import { useEffect, useMemo, useState } from 'react'
import { providerDisplayNames } from '../domain/providers.js'
import { TUI_SESSION_MODEL_FLAG } from '../domain/slash.js'
import type { GatewayClient } from '../gatewayClient.js'
import type { ModelOptionProvider, ModelOptionsResponse } from '../gatewayTypes.js'
import { fuzzyRank } from '../lib/fuzzy.js'
import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
import type { Theme } from '../theme.js'
import { OverlayHint, useOverlayKeys, windowItems } from './overlayControls.js'
const VISIBLE = 12
const MIN_WIDTH = 40
const MAX_WIDTH = 90
type Stage = 'provider' | 'key' | 'model' | 'disconnect'
type ProviderRow = { name: string; provider: ModelOptionProvider }
export function providerIndexAfterClearingFilter(providerRows: ProviderRow[], provider: ModelOptionProvider | undefined) {
if (!provider) {
return -1
}
return providerRows.findIndex(row => row.provider.slug === provider.slug)
}
export function ModelPicker({ allowPersistGlobal = true, gw, onCancel, onSelect, sessionId, t }: ModelPickerProps) {
const [providers, setProviders] = useState<ModelOptionProvider[]>([])
const [currentModel, setCurrentModel] = useState('')
const [err, setErr] = useState('')
const [loading, setLoading] = useState(true)
const [persistGlobal, setPersistGlobal] = useState(false)
const [providerIdx, setProviderIdx] = useState(0)
const [modelIdx, setModelIdx] = useState(0)
const [stage, setStage] = useState<Stage>('provider')
const [keyInput, setKeyInput] = useState('')
const [keySaving, setKeySaving] = useState(false)
const [keyError, setKeyError] = useState('')
// Type-to-filter query, scoped per stage (cleared on stage change).
const [filter, setFilter] = useState('')
const { stdout } = useStdout()
// Pin the picker to a stable width so the FloatBox parent (which shrinks-
// to-fit with alignSelf="flex-start") doesn't resize as long provider /
// model names scroll into view, and so `wrap="truncate-end"` on each row
// has an actual constraint to truncate against.
const width = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, (stdout?.columns ?? 80) - 6))
useEffect(() => {
gw.request<ModelOptionsResponse>('model.options', sessionId ? { session_id: sessionId } : {})
.then(raw => {
const r = asRpcResult<ModelOptionsResponse>(raw)
if (!r) {
setErr('invalid response: model.options')
setLoading(false)
return
}
const next = r.providers ?? []
setProviders(next)
setCurrentModel(String(r.model ?? ''))
setProviderIdx(
Math.max(
0,
next.findIndex(p => p.is_current)
)
)
setModelIdx(0)
setStage('provider')
setErr('')
setLoading(false)
})
.catch((e: unknown) => {
setErr(rpcErrorMessage(e))
setLoading(false)
})
}, [gw, sessionId])
const names = useMemo(() => providerDisplayNames(providers), [providers])
// Provider rows carry their display name so fuzzy filtering can match on
// name + slug while keeping the name/provider pairing intact across ranking.
const providerRows = useMemo(
() => providers.map((p, i) => ({ provider: p, name: names[i] ?? p.name ?? p.slug })),
[providers, names]
)
// providerIdx / modelIdx always index into the *displayed* (filtered) lists.
// With an empty filter the filtered list equals the full list, so navigation
// behaves exactly as before. Filtering only applies on the relevant stage.
const filteredProviderRows = useMemo(() => {
if (stage !== 'provider' || !filter.trim()) {
return providerRows
}
return fuzzyRank(
providerRows,
filter,
row => `${row.name} ${row.provider.slug} ${(row.provider.models ?? []).join(' ')}`
).map(r => r.item)
}, [providerRows, filter, stage])
const provider = filteredProviderRows[providerIdx]?.provider
const allModels = useMemo(() => provider?.models ?? [], [provider])
const filteredModels = useMemo(() => {
if (stage !== 'model' || !filter.trim()) {
return allModels
}
return fuzzyRank(allModels, filter, m => m).map(r => r.item)
}, [allModels, filter, stage])
const models = filteredModels
// Keep the active selection within the (possibly filtered) list bounds.
useEffect(() => {
if (providerIdx >= filteredProviderRows.length && filteredProviderRows.length > 0) {
setProviderIdx(0)
}
}, [filteredProviderRows.length, providerIdx])
useEffect(() => {
if (modelIdx >= models.length && models.length > 0) {
setModelIdx(0)
}
}, [models.length, modelIdx])
const back = () => {
// Esc first clears an active filter on the list stages, before navigating.
if ((stage === 'provider' || stage === 'model') && filter.trim()) {
// Preserve the selected provider across filter clear (same fix as
// Enter→key/model and Ctrl+D transitions above).
const fullProviderIdx = providerIndexAfterClearingFilter(providerRows, provider)
if (fullProviderIdx >= 0) {
setProviderIdx(fullProviderIdx)
} else if (stage === 'provider') {
setProviderIdx(0)
}
setFilter('')
setModelIdx(0)
return
}
if (stage === 'model' || stage === 'key' || stage === 'disconnect') {
setStage('provider')
setModelIdx(0)
setKeyInput('')
setKeyError('')
setKeySaving(false)
setFilter('')
return
}
onCancel()
}
// On the list stages we capture printable keys (including 'q') into the
// filter, so the shared overlay q/Esc handler must yield to our own handler.
const listStage = stage === 'provider' || stage === 'model'
useOverlayKeys({ disabled: listStage, onBack: back, onClose: onCancel })
useInput((ch, key) => {
// Key entry stage handles its own input
if (stage === 'key') {
if (keySaving) {
return
}
if (key.return) {
if (!keyInput.trim()) {
return
}
setKeySaving(true)
setKeyError('')
gw.request<{ provider?: ModelOptionProvider }>('model.save_key', {
slug: provider?.slug,
api_key: keyInput.trim(),
...(sessionId ? { session_id: sessionId } : {})
})
.then(raw => {
const r = asRpcResult<{ provider?: ModelOptionProvider }>(raw)
if (!r?.provider) {
setKeyError('failed to save key')
setKeySaving(false)
return
}
// Update the provider in our list with fresh data
setProviders(prev => prev.map(p => (p.slug === r.provider!.slug ? r.provider! : p)))
setKeyInput('')
setKeySaving(false)
setStage('model')
setModelIdx(0)
})
.catch((e: unknown) => {
setKeyError(rpcErrorMessage(e))
setKeySaving(false)
})
return
}
if (key.backspace || key.delete) {
setKeyInput(v => v.slice(0, -1))
return
}
// ctrl+u clears input
if (ch === '\u0015') {
setKeyInput('')
return
}
if (ch && !key.ctrl && !key.meta) {
setKeyInput(v => v + ch)
}
return
}
// Disconnect confirmation stage
if (stage === 'disconnect') {
if (ch.toLowerCase() === 'y' || key.return) {
if (!provider) {
setStage('provider')
return
}
setKeySaving(true)
gw.request<{ disconnected?: boolean }>('model.disconnect', {
slug: provider.slug,
...(sessionId ? { session_id: sessionId } : {})
})
.then(raw => {
const r = asRpcResult<{ disconnected?: boolean }>(raw)
if (r?.disconnected) {
// Mark provider as unauthenticated in local state
setProviders(prev =>
prev.map(p =>
p.slug === provider.slug
? {
...p,
authenticated: false,
models: [],
total_models: 0,
warning: p.key_env ? `paste ${p.key_env} to activate` : 'run `hermes model` to configure'
}
: p
)
)
}
setKeySaving(false)
setStage('provider')
})
.catch(() => {
setKeySaving(false)
setStage('provider')
})
return
}
if (ch.toLowerCase() === 'n' || key.escape) {
setStage('provider')
return
}
return
}
// List-stage Esc/q handling (overlay keys are disabled while on a list
// stage so 'q' can be typed into the filter).
if (key.escape) {
back()
return
}
if (ch === 'q' && !filter) {
onCancel()
return
}
const count = stage === 'provider' ? filteredProviderRows.length : models.length
const sel = stage === 'provider' ? providerIdx : modelIdx
const setSel = stage === 'provider' ? setProviderIdx : setModelIdx
if (key.upArrow && sel > 0) {
setSel(v => v - 1)
return
}
if (key.downArrow && sel < count - 1) {
setSel(v => v + 1)
return
}
if (key.return) {
if (stage === 'provider') {
if (!provider) {
return
}
if (provider.authenticated === false) {
// api_key providers: prompt for key inline
if (provider.auth_type === 'api_key' && provider.key_env) {
const fullProviderIdx = providerIndexAfterClearingFilter(providerRows, provider)
if (fullProviderIdx >= 0) {
setProviderIdx(fullProviderIdx)
}
setStage('key')
setKeyInput('')
setKeyError('')
setFilter('')
}
// Other auth types: no-op (warning shown tells them to run hermes model)
return
}
const fullProviderIdx = providerIndexAfterClearingFilter(providerRows, provider)
if (fullProviderIdx >= 0) {
setProviderIdx(fullProviderIdx)
}
setStage('model')
setModelIdx(0)
setFilter('')
return
}
const model = models[modelIdx]
if (provider && model) {
onSelect(
`${model} --provider ${provider.slug}${allowPersistGlobal && persistGlobal ? ' --global' : ` ${TUI_SESSION_MODEL_FLAG}`}`
)
} else {
setStage('provider')
}
return
}
// Backspace removes the last filter character; Esc (above) clears a
// non-empty filter before navigating back.
if (key.backspace || key.delete) {
setFilter(v => v.slice(0, -1))
setSel(0)
return
}
// Ctrl+U clears the filter. (Ctrl held → ch is the key name 'u'.)
if (key.ctrl && ch === 'u') {
setFilter('')
setSel(0)
return
}
// Persist-global toggle moved to Ctrl+G so 'g' can be typed into the
// filter. With Ctrl held, @hermes/ink reports `ch` as the key name ('g'),
// not the raw control byte (see input-event.ts: input = ctrl ? name : seq).
if (allowPersistGlobal && key.ctrl && ch === 'g') {
setPersistGlobal(v => !v)
return
}
// Disconnect (Ctrl+D): only in provider stage, only for authenticated providers.
if (key.ctrl && ch === 'd' && stage === 'provider' && provider?.authenticated !== false) {
const fullProviderIdx = providerIndexAfterClearingFilter(providerRows, provider)
if (fullProviderIdx >= 0) {
setProviderIdx(fullProviderIdx)
}
setStage('disconnect')
setFilter('')
return
}
// Any other printable single character extends the filter.
if (ch && !key.ctrl && !key.meta && ch.length === 1 && ch >= ' ') {
setFilter(v => v + ch)
setSel(0)
}
})
if (loading) {
return <Text color={t.color.muted}>loading models</Text>
}
if (err) {
return (
<Box flexDirection="column">
<Text color={t.color.label}>error: {err}</Text>
<OverlayHint t={t}>Esc/q cancel</OverlayHint>
</Box>
)
}
if (!providers.length) {
return (
<Box flexDirection="column">
<Text color={t.color.muted}>no providers available</Text>
<OverlayHint t={t}>Esc/q cancel</OverlayHint>
</Box>
)
}
// ── Key entry stage ──────────────────────────────────────────────────
if (stage === 'key' && provider) {
const masked = keyInput ? '•'.repeat(Math.min(keyInput.length, 40)) : ''
return (
<Box flexDirection="column" width={width}>
<Text bold color={t.color.accent} wrap="truncate-end">
Configure {provider.name}
</Text>
<Text color={t.color.muted} wrap="truncate-end">
Paste your API key below (saved to ~/.hermes/.env)
</Text>
<Text color={t.color.muted} wrap="truncate-end">
{' '}
</Text>
<Text color={t.color.muted} wrap="truncate-end">
{provider.key_env}:
</Text>
<Text color={t.color.accent} wrap="truncate-end">
{' '}
{masked || '(empty)'}
{keySaving ? '' : '▎'}
</Text>
<Text color={t.color.muted} wrap="truncate-end">
{' '}
</Text>
{keyError ? (
<Text color={t.color.label} wrap="truncate-end">
error: {keyError}
</Text>
) : keySaving ? (
<Text color={t.color.muted} wrap="truncate-end">
saving
</Text>
) : (
<Text color={t.color.muted} wrap="truncate-end">
{' '}
</Text>
)}
<OverlayHint t={t}>Enter save · Ctrl+U clear · Esc back</OverlayHint>
</Box>
)
}
// ── Disconnect confirmation stage ─────────────────────────────────────
if (stage === 'disconnect' && provider) {
return (
<Box flexDirection="column" width={width}>
<Text bold color={t.color.accent} wrap="truncate-end">
Disconnect {provider.name}?
</Text>
<Text color={t.color.muted} wrap="truncate-end">
{' '}
</Text>
<Text color={t.color.muted} wrap="truncate-end">
This removes saved credentials for {provider.name}.
</Text>
<Text color={t.color.muted} wrap="truncate-end">
You can re-authenticate later by selecting it again.
</Text>
<Text color={t.color.muted} wrap="truncate-end">
{' '}
</Text>
{keySaving ? (
<Text color={t.color.muted} wrap="truncate-end">
disconnecting
</Text>
) : (
<OverlayHint t={t}>y/Enter confirm · n/Esc cancel</OverlayHint>
)}
</Box>
)
}
// ── Provider selection stage ─────────────────────────────────────────
if (stage === 'provider') {
const rows = filteredProviderRows.map(({ provider: p, name }) => {
const authMark = p.authenticated === false ? '○' : p.is_current ? '*' : '●'
const modelCount = p.total_models ?? p.models?.length ?? 0
const suffix =
p.authenticated === false ? (p.auth_type === 'api_key' ? '(no key)' : '(needs setup)') : `${modelCount} models`
return `${authMark} ${name} · ${suffix}`
})
const { items, offset } = windowItems(rows, providerIdx, VISIBLE)
const noMatches = !!filter.trim() && rows.length === 0
return (
<Box flexDirection="column" width={width}>
<Text bold color={t.color.accent} wrap="truncate-end">
Select provider (step 1/2)
</Text>
<Text color={t.color.muted} wrap="truncate-end">
Full model IDs on the next step · Enter to continue
</Text>
<Text color={t.color.muted} wrap="truncate-end">
Current: {currentModel || '(unknown)'}
</Text>
<Text color={filter ? t.color.accent : t.color.muted} wrap="truncate-end">
{filter ? `filter: ${filter}` : 'type to filter · ↑/↓ select'}
</Text>
<Text color={t.color.label} wrap="truncate-end">
{provider?.warning ? `warning: ${provider.warning}` : ' '}
</Text>
<Text color={t.color.muted} wrap="truncate-end">
{offset > 0 ? `${offset} more` : ' '}
</Text>
{noMatches ? (
<Text color={t.color.muted} wrap="truncate-end">
no providers match
</Text>
) : (
Array.from({ length: VISIBLE }, (_, i) => {
const row = items[i]
const idx = offset + i
const p = filteredProviderRows[idx]?.provider
const dimmed = p?.authenticated === false
return row ? (
<Text
bold={providerIdx === idx}
color={providerIdx === idx ? t.color.accent : dimmed ? t.color.label : t.color.muted}
inverse={providerIdx === idx}
key={p?.slug ?? `row-${idx}`}
wrap="truncate-end"
>
{providerIdx === idx ? '▸ ' : ' '}
{idx + 1}. {row}
</Text>
) : (
<Text color={t.color.muted} key={`pad-${i}`} wrap="truncate-end">
{' '}
</Text>
)
})
)}
<Text color={t.color.muted} wrap="truncate-end">
{offset + VISIBLE < rows.length ? `${rows.length - offset - VISIBLE} more` : ' '}
</Text>
<Text color={t.color.muted} wrap="truncate-end">
persist: {allowPersistGlobal ? (persistGlobal ? 'global' : 'session') : 'session'}
{allowPersistGlobal ? ' · ^g toggle' : ' only'}
</Text>
<OverlayHint t={t}>/ select · Enter choose · ^d disconnect · Esc clear/back · q close</OverlayHint>
</Box>
)
}
// ── Model selection stage ────────────────────────────────────────────
const { items, offset } = windowItems(models, modelIdx, VISIBLE)
const noModelMatches = !!filter.trim() && models.length === 0
return (
<Box flexDirection="column" width={width}>
<Text bold color={t.color.accent} wrap="truncate-end">
Select model (step 2/2)
</Text>
<Text color={t.color.muted} wrap="truncate-end">
{filteredProviderRows[providerIdx]?.name || '(unknown provider)'} · Esc back
</Text>
<Text color={filter ? t.color.accent : t.color.muted} wrap="truncate-end">
{filter ? `filter: ${filter}` : 'type to filter · ↑/↓ select'}
</Text>
<Text color={t.color.label} wrap="truncate-end">
{provider?.warning ? `warning: ${provider.warning}` : ' '}
</Text>
<Text color={t.color.muted} wrap="truncate-end">
{offset > 0 ? `${offset} more` : ' '}
</Text>
{Array.from({ length: VISIBLE }, (_, i) => {
const row = items[i]
const idx = offset + i
if (!row) {
return (!allModels.length || noModelMatches) && i === 0 ? (
<Text color={t.color.muted} key="empty" wrap="truncate-end">
{noModelMatches ? 'no models match filter' : 'no models listed for this provider'}
</Text>
) : (
<Text color={t.color.muted} key={`pad-${i}`} wrap="truncate-end">
{' '}
</Text>
)
}
const prefix = modelIdx === idx ? '▸ ' : row === currentModel ? '* ' : ' '
return (
<Text
bold={modelIdx === idx}
color={modelIdx === idx ? t.color.accent : t.color.muted}
inverse={modelIdx === idx}
key={`${provider?.slug ?? 'prov'}:${idx}:${row}`}
wrap="truncate-end"
>
{prefix}
{idx + 1}. {row}
</Text>
)
})}
<Text color={t.color.muted} wrap="truncate-end">
{offset + VISIBLE < models.length ? `${models.length - offset - VISIBLE} more` : ' '}
</Text>
<Text color={t.color.muted} wrap="truncate-end">
persist: {allowPersistGlobal ? (persistGlobal ? 'global' : 'session') : 'session'}
{allowPersistGlobal ? ' · ^g toggle' : ' only'}
</Text>
<OverlayHint t={t}>
{models.length ? '↑/↓ select · Enter switch · Esc clear/back · q close' : 'Esc back · q close'}
</OverlayHint>
</Box>
)
}
interface ModelPickerProps {
allowPersistGlobal?: boolean
gw: GatewayClient
onCancel: () => void
onSelect: (value: string) => void
sessionId: string | null
t: Theme
}