mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-05 12:42:30 +00:00
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.
684 lines
21 KiB
TypeScript
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
|
|
}
|