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 { 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' export function ModelPicker({ gw, 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('') 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('model.options', sessionId ? { session_id: sessionId } : {}) .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, sessionId]) const provider = providers[providerIdx] const models = provider?.models ?? [] const names = useMemo(() => providerDisplayNames(providers), [providers]) const back = () => { if (stage === 'model' || stage === 'key' || stage === 'disconnect') { setStage('provider') setModelIdx(0) setKeyInput('') setKeyError('') setKeySaving(false) return } onCancel() } useOverlayKeys({ 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 } const count = stage === 'provider' ? providers.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) { setStage('key') setKeyInput('') setKeyError('') } // Other auth types: no-op (warning shown tells them to run hermes model) return } setStage('model') setModelIdx(0) return } const model = models[modelIdx] if (provider && model) { onSelect(`${model} --provider ${provider.slug}${persistGlobal ? ' --global' : ` ${TUI_SESSION_MODEL_FLAG}`}`) } else { setStage('provider') } return } if (ch.toLowerCase() === 'g') { setPersistGlobal(v => !v) return } // Disconnect: only in provider stage, only for authenticated providers if (ch.toLowerCase() === 'd' && stage === 'provider' && provider?.authenticated !== false) { setStage('disconnect') return } }) 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 = providers.map( (p, i) => { 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} ${names[i]} · ${suffix}` } ) const { items, offset } = windowItems(rows, providerIdx, VISIBLE) return ( Select provider (step 1/2) Full model IDs on the next step · Enter to continue Current: {currentModel || '(unknown)'} {provider?.warning ? `warning: ${provider.warning}` : ' '} {offset > 0 ? ` ↑ ${offset} more` : ' '} {Array.from({ length: VISIBLE }, (_, i) => { const row = items[i] const idx = offset + i const p = providers[idx] const dimmed = p?.authenticated === false return row ? ( {providerIdx === idx ? '▸ ' : ' '} {idx + 1}. {row} ) : ( {' '} ) })} {offset + VISIBLE < rows.length ? ` ↓ ${rows.length - offset - VISIBLE} more` : ' '} persist: {persistGlobal ? 'global' : 'session'} · g toggle ↑/↓ select · Enter choose · d disconnect · Esc/q cancel ) } // ── Model selection stage ──────────────────────────────────────────── const { items, offset } = windowItems(models, modelIdx, VISIBLE) return ( Select model (step 2/2) {names[providerIdx] || '(unknown provider)'} · Esc back {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 !models.length && i === 0 ? ( 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: {persistGlobal ? 'global' : 'session'} · g toggle {models.length ? '↑/↓ select · Enter switch · Esc back · q close' : 'Enter/Esc back · q close'} ) } interface ModelPickerProps { gw: GatewayClient onCancel: () => void onSelect: (value: string) => void sessionId: string | null t: Theme }