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 { modelSearchText } from '../lib/model-search-text.js' import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js' import type { Theme } from '../theme.js' import { OverlayHint, useOverlayKeys, windowItems } from './overlayControls.js' import { chipRowProps, clampOverlayWidth } from './overlayPrimitives.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, initialRefresh = false, maxWidth, onCancel, onSelect, sessionId, t }: ModelPickerProps) { const [providers, setProviders] = useState([]) 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('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. Optional maxWidth lets // grid layouts hand the picker its cell budget. const preferredWidth = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, (stdout?.columns ?? 80) - 6)) const width = clampOverlayWidth(preferredWidth, maxWidth) useEffect(() => { gw.request('model.options', { ...(sessionId ? { session_id: sessionId } : {}), ...(initialRefresh ? { refresh: true } : {}), // The TUI picker shows the full provider universe with setup // affordances ("paste KEY to activate"), so opt into unconfigured // rows — the backend now defaults to the configured subset for // desktop chat pickers (#56974). include_unconfigured: true }) .then(raw => { const r = asRpcResult(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, initialRefresh, 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 } // modelSearchText adds aliases for brand-less wire ids (e.g. Kimi // Coding `k3` still matches a "kimi" query). return fuzzyRank(allModels, filter, modelSearchText).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 loading models… } if (err) { return ( error: {err} Esc/q cancel ) } if (!providers.length) { return ( no providers available Esc/q cancel ) } // ── Key entry stage ────────────────────────────────────────────────── if (stage === 'key' && provider) { const masked = keyInput ? '•'.repeat(Math.min(keyInput.length, 40)) : '' return ( Configure {provider.name} Paste your API key below (saved to ~/.hermes/.env) {' '} {provider.key_env}: {' '} {masked || '(empty)'} {keySaving ? '' : '▎'} {' '} {keyError ? ( error: {keyError} ) : keySaving ? ( saving… ) : ( {' '} )} Enter save · Ctrl+U clear · Esc back ) } // ── Disconnect confirmation stage ───────────────────────────────────── if (stage === 'disconnect' && provider) { return ( Disconnect {provider.name}? {' '} This removes saved credentials for {provider.name}. You can re-authenticate later by selecting it again. {' '} {keySaving ? ( disconnecting… ) : ( y/Enter confirm · n/Esc cancel )} ) } // ── 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 ( Select provider (step 1/2) Full model IDs on the next step · Enter to continue Current: {currentModel || '(unknown)'} {filter ? `filter: ${filter}▎` : 'type to filter · ↑/↓ select'} {provider?.warning ? `warning: ${provider.warning}` : ' '} {offset > 0 ? ` ↑ ${offset} more` : ' '} {noMatches ? ( no providers match ) : ( 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 ? ( {providerIdx === idx ? '▸ ' : ' '} {idx + 1}. {row} ) : ( {' '} ) }) )} {offset + VISIBLE < rows.length ? ` ↓ ${rows.length - offset - VISIBLE} more` : ' '} persist: {allowPersistGlobal ? (persistGlobal ? 'global' : 'session') : 'session'} {allowPersistGlobal ? ' · ^g toggle' : ' only'} ↑/↓ select · Enter choose · ^d disconnect · Esc clear/back · q close ) } // ── Model selection stage ──────────────────────────────────────────── const { items, offset } = windowItems(models, modelIdx, VISIBLE) const noModelMatches = !!filter.trim() && models.length === 0 return ( Select model (step 2/2) {filteredProviderRows[providerIdx]?.name || '(unknown provider)'} · Esc back {filter ? `filter: ${filter}▎` : 'type to filter · ↑/↓ select'} {provider?.warning ? `warning: ${provider.warning}` : ' '} {offset > 0 ? ` ↑ ${offset} more` : ' '} {Array.from({ length: VISIBLE }, (_, i) => { const row = items[i] const idx = offset + i if (!row) { return (!allModels.length || noModelMatches) && i === 0 ? ( {noModelMatches ? 'no models match filter' : 'no models listed for this provider'} ) : ( {' '} ) } const prefix = modelIdx === idx ? '▸ ' : row === currentModel ? '* ' : ' ' return ( {prefix} {idx + 1}. {row} ) })} {offset + VISIBLE < models.length ? ` ↓ ${models.length - offset - VISIBLE} more` : ' '} persist: {allowPersistGlobal ? (persistGlobal ? 'global' : 'session') : 'session'} {allowPersistGlobal ? ' · ^g toggle' : ' only'} {models.length ? '↑/↓ select · Enter switch · Esc clear/back · q close' : 'Esc back · q close'} ) } interface ModelPickerProps { allowPersistGlobal?: boolean gw: GatewayClient initialRefresh?: boolean maxWidth?: number onCancel: () => void onSelect: (value: string) => void sessionId: string | null t: Theme }