feat: fuzzy search for the model picker (WebUI + TUI)
Adds fuzzy subsequence matching with quality ranking to the model pickers, replacing the WebUI's exact-substring filter and giving the TUI a search where it previously had none. - New fuzzy scorer (ui-tui/src/lib/fuzzy.ts + an identical copy at web/src/lib/fuzzy.ts, since the two are separate TS packages with no shared module). Matches a query as an ordered subsequence (so `g4o` matches `gpt-4o`), scores by quality (exact > prefix > word-boundary > contiguous > scattered) and returns matched character positions for highlighting. Multi-token AND semantics (`clad snnt` -> claude-sonnet). 15 vitest tests cover the algorithm. - WebUI ModelPickerDialog: ranked fuzzy filter on providers + models; matched characters in model rows are highlighted via <mark>. - TUI modelPicker: type-to-filter on the provider and model stages with live ranking. Backspace edits the filter, Ctrl+U clears it, Esc clears a non-empty filter before navigating back. Persist-global / disconnect shortcuts moved from g/d to Ctrl+G / Ctrl+D so letters feed the filter. Closes #30849
This commit is contained in:
@ -5,6 +5,7 @@ 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'
|
||||
|
||||
@ -28,6 +29,8 @@ export function ModelPicker({ allowPersistGlobal = true, gw, onCancel, onSelect,
|
||||
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-
|
||||
@ -68,17 +71,73 @@ export function ModelPicker({ allowPersistGlobal = true, gw, onCancel, onSelect,
|
||||
})
|
||||
}, [gw, sessionId])
|
||||
|
||||
const provider = providers[providerIdx]
|
||||
const models = provider?.models ?? []
|
||||
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()) {
|
||||
setFilter('')
|
||||
setProviderIdx(stage === 'provider' ? 0 : providerIdx)
|
||||
setModelIdx(0)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (stage === 'model' || stage === 'key' || stage === 'disconnect') {
|
||||
setStage('provider')
|
||||
setModelIdx(0)
|
||||
setKeyInput('')
|
||||
setKeyError('')
|
||||
setKeySaving(false)
|
||||
setFilter('')
|
||||
|
||||
return
|
||||
}
|
||||
@ -86,7 +145,10 @@ export function ModelPicker({ allowPersistGlobal = true, gw, onCancel, onSelect,
|
||||
onCancel()
|
||||
}
|
||||
|
||||
useOverlayKeys({ onBack: back, onClose: 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
|
||||
@ -206,7 +268,21 @@ export function ModelPicker({ allowPersistGlobal = true, gw, onCancel, onSelect,
|
||||
return
|
||||
}
|
||||
|
||||
const count = stage === 'provider' ? providers.length : models.length
|
||||
// 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
|
||||
|
||||
@ -234,6 +310,7 @@ export function ModelPicker({ allowPersistGlobal = true, gw, onCancel, onSelect,
|
||||
setStage('key')
|
||||
setKeyInput('')
|
||||
setKeyError('')
|
||||
setFilter('')
|
||||
}
|
||||
|
||||
// Other auth types: no-op (warning shown tells them to run hermes model)
|
||||
@ -242,6 +319,7 @@ export function ModelPicker({ allowPersistGlobal = true, gw, onCancel, onSelect,
|
||||
|
||||
setStage('model')
|
||||
setModelIdx(0)
|
||||
setFilter('')
|
||||
|
||||
return
|
||||
}
|
||||
@ -259,18 +337,44 @@ export function ModelPicker({ allowPersistGlobal = true, gw, onCancel, onSelect,
|
||||
return
|
||||
}
|
||||
|
||||
if (allowPersistGlobal && ch.toLowerCase() === 'g') {
|
||||
// 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: only in provider stage, only for authenticated providers
|
||||
if (ch.toLowerCase() === 'd' && stage === 'provider' && provider?.authenticated !== false) {
|
||||
// Disconnect (Ctrl+D): only in provider stage, only for authenticated providers.
|
||||
if (key.ctrl && ch === 'd' && stage === 'provider' && provider?.authenticated !== false) {
|
||||
setStage('disconnect')
|
||||
|
||||
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) {
|
||||
@ -383,16 +487,18 @@ export function ModelPicker({ allowPersistGlobal = true, gw, onCancel, onSelect,
|
||||
|
||||
// ── Provider selection stage ─────────────────────────────────────────
|
||||
if (stage === 'provider') {
|
||||
const rows = providers.map((p, i) => {
|
||||
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} ${names[i]} · ${suffix}`
|
||||
return `${authMark} ${name} · ${suffix}`
|
||||
})
|
||||
|
||||
const { items, offset } = windowItems(rows, providerIdx, VISIBLE)
|
||||
const noMatches = !!filter.trim() && rows.length === 0
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" width={width}>
|
||||
@ -407,6 +513,9 @@ export function ModelPicker({ allowPersistGlobal = true, gw, onCancel, onSelect,
|
||||
<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>
|
||||
@ -414,29 +523,35 @@ export function ModelPicker({ allowPersistGlobal = true, gw, onCancel, onSelect,
|
||||
{offset > 0 ? ` ↑ ${offset} more` : ' '}
|
||||
</Text>
|
||||
|
||||
{Array.from({ length: VISIBLE }, (_, i) => {
|
||||
const row = items[i]
|
||||
const idx = offset + i
|
||||
const p = providers[idx]
|
||||
const dimmed = p?.authenticated === false
|
||||
{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={providers[idx]?.slug ?? `row-${idx}`}
|
||||
wrap="truncate-end"
|
||||
>
|
||||
{providerIdx === idx ? '▸ ' : ' '}
|
||||
{idx + 1}. {row}
|
||||
</Text>
|
||||
) : (
|
||||
<Text color={t.color.muted} key={`pad-${i}`} wrap="truncate-end">
|
||||
{' '}
|
||||
</Text>
|
||||
)
|
||||
})}
|
||||
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` : ' '}
|
||||
@ -444,15 +559,16 @@ export function ModelPicker({ allowPersistGlobal = true, gw, onCancel, onSelect,
|
||||
|
||||
<Text color={t.color.muted} wrap="truncate-end">
|
||||
persist: {allowPersistGlobal ? (persistGlobal ? 'global' : 'session') : 'session'}
|
||||
{allowPersistGlobal ? ' · g toggle' : ' only'}
|
||||
{allowPersistGlobal ? ' · ^g toggle' : ' only'}
|
||||
</Text>
|
||||
<OverlayHint t={t}>↑/↓ select · Enter choose · d disconnect · Esc/q cancel</OverlayHint>
|
||||
<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}>
|
||||
@ -461,7 +577,10 @@ export function ModelPicker({ allowPersistGlobal = true, gw, onCancel, onSelect,
|
||||
</Text>
|
||||
|
||||
<Text color={t.color.muted} wrap="truncate-end">
|
||||
{names[providerIdx] || '(unknown provider)'} · Esc back
|
||||
{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}` : ' '}
|
||||
@ -475,9 +594,9 @@ export function ModelPicker({ allowPersistGlobal = true, gw, onCancel, onSelect,
|
||||
const idx = offset + i
|
||||
|
||||
if (!row) {
|
||||
return !models.length && i === 0 ? (
|
||||
return (!allModels.length || noModelMatches) && i === 0 ? (
|
||||
<Text color={t.color.muted} key="empty" wrap="truncate-end">
|
||||
no models listed for this provider
|
||||
{noModelMatches ? 'no models match filter' : 'no models listed for this provider'}
|
||||
</Text>
|
||||
) : (
|
||||
<Text color={t.color.muted} key={`pad-${i}`} wrap="truncate-end">
|
||||
@ -508,10 +627,10 @@ export function ModelPicker({ allowPersistGlobal = true, gw, onCancel, onSelect,
|
||||
|
||||
<Text color={t.color.muted} wrap="truncate-end">
|
||||
persist: {allowPersistGlobal ? (persistGlobal ? 'global' : 'session') : 'session'}
|
||||
{allowPersistGlobal ? ' · g toggle' : ' only'}
|
||||
{allowPersistGlobal ? ' · ^g toggle' : ' only'}
|
||||
</Text>
|
||||
<OverlayHint t={t}>
|
||||
{models.length ? '↑/↓ select · Enter switch · Esc back · q close' : 'Enter/Esc back · q close'}
|
||||
{models.length ? '↑/↓ select · Enter switch · Esc clear/back · q close' : 'Esc back · q close'}
|
||||
</OverlayHint>
|
||||
</Box>
|
||||
)
|
||||
|
||||
109
ui-tui/src/lib/fuzzy.test.ts
Normal file
109
ui-tui/src/lib/fuzzy.test.ts
Normal file
@ -0,0 +1,109 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { fuzzyRank, fuzzyScore, fuzzyScoreMulti } from './fuzzy.js'
|
||||
|
||||
describe('fuzzyScore', () => {
|
||||
it('matches a query as a subsequence (g4o → gpt-4o)', () => {
|
||||
expect(fuzzyScore('gpt-4o', 'g4o')).not.toBeNull()
|
||||
expect(fuzzyScore('gpt-4o', 'gpt')).not.toBeNull()
|
||||
expect(fuzzyScore('gpt-4o', '4o')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when characters are out of order or absent', () => {
|
||||
expect(fuzzyScore('gpt-4o', 'o4g')).toBeNull()
|
||||
expect(fuzzyScore('gpt-4o', 'xyz')).toBeNull()
|
||||
expect(fuzzyScore('gpt-4o', 'gptx')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns matched positions into the original target', () => {
|
||||
const m = fuzzyScore('gpt-4o', 'g4o')
|
||||
// g@0, 4@4, o@5
|
||||
expect(m?.positions).toEqual([0, 4, 5])
|
||||
})
|
||||
|
||||
it('treats an empty query as a zero-score match', () => {
|
||||
expect(fuzzyScore('anything', '')).toEqual({ score: 0, positions: [] })
|
||||
})
|
||||
|
||||
it('scores an exact match highest', () => {
|
||||
const exact = fuzzyScore('sonnet', 'sonnet')!.score
|
||||
const prefix = fuzzyScore('sonnet-extended', 'sonnet')!.score
|
||||
// s,o,n,n,e,t all present in order but scattered across word boundaries.
|
||||
const scattered = fuzzyScore('snorkel-online-nnet', 'sonnet')!.score
|
||||
|
||||
expect(exact).toBeGreaterThan(prefix)
|
||||
expect(prefix).toBeGreaterThan(scattered)
|
||||
})
|
||||
|
||||
it('ranks a prefix match above a scattered subsequence', () => {
|
||||
const prefix = fuzzyScore('gpt-4o-mini', 'gpt')!.score
|
||||
const scattered = fuzzyScore('a-g-p-t', 'gpt')!.score
|
||||
|
||||
expect(prefix).toBeGreaterThan(scattered)
|
||||
})
|
||||
|
||||
it('rewards word-boundary matches', () => {
|
||||
// `s4` matching the `s` of sonnet and the `4` after a dash
|
||||
const boundary = fuzzyScore('claude-sonnet-4', 'cs4')
|
||||
expect(boundary).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('fuzzyScoreMulti', () => {
|
||||
it('requires every space-separated token to match (AND)', () => {
|
||||
expect(fuzzyScoreMulti('claude-sonnet-4', 'clad snnt')).not.toBeNull()
|
||||
expect(fuzzyScoreMulti('claude-sonnet-4', 'claude haiku')).toBeNull()
|
||||
})
|
||||
|
||||
it('unions matched positions across tokens, sorted', () => {
|
||||
const m = fuzzyScoreMulti('claude-sonnet', 'son cla')
|
||||
expect(m).not.toBeNull()
|
||||
expect(m!.positions).toEqual([...m!.positions].sort((a, b) => a - b))
|
||||
})
|
||||
|
||||
it('treats whitespace-only query as a zero-score match', () => {
|
||||
expect(fuzzyScoreMulti('x', ' ')).toEqual({ score: 0, positions: [] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('fuzzyRank', () => {
|
||||
const models = ['gpt-4o', 'gpt-4o-mini', 'claude-sonnet-4', 'claude-haiku', 'o1-preview']
|
||||
|
||||
it('drops non-matching items and ranks matches by score', () => {
|
||||
const ranked = fuzzyRank(models, 'g4o', m => m)
|
||||
const ids = ranked.map(r => r.item)
|
||||
|
||||
expect(ids).toContain('gpt-4o')
|
||||
expect(ids).toContain('gpt-4o-mini')
|
||||
expect(ids).not.toContain('claude-haiku')
|
||||
// Shorter exact-ish prefix should outrank the longer variant.
|
||||
expect(ids.indexOf('gpt-4o')).toBeLessThan(ids.indexOf('gpt-4o-mini'))
|
||||
})
|
||||
|
||||
it('ranks son4 so a sonnet model surfaces', () => {
|
||||
const ranked = fuzzyRank(models, 'son4', m => m)
|
||||
expect(ranked[0]?.item).toBe('claude-sonnet-4')
|
||||
})
|
||||
|
||||
it('returns all items in original order for an empty query', () => {
|
||||
const ranked = fuzzyRank(models, '', m => m)
|
||||
expect(ranked.map(r => r.item)).toEqual(models)
|
||||
expect(ranked.every(r => r.positions.length === 0)).toBe(true)
|
||||
})
|
||||
|
||||
it('is stable for equal scores (original index tiebreak)', () => {
|
||||
const items = ['ab', 'ab', 'ab']
|
||||
const ranked = fuzzyRank(items.map((v, i) => ({ v, i })), 'ab', x => x.v)
|
||||
expect(ranked.map(r => r.item.i)).toEqual([0, 1, 2])
|
||||
})
|
||||
|
||||
it('matches across a derived key, not just the raw string', () => {
|
||||
const providers = [
|
||||
{ slug: 'openai', name: 'OpenAI' },
|
||||
{ slug: 'anthropic', name: 'Anthropic' }
|
||||
]
|
||||
|
||||
const ranked = fuzzyRank(providers, 'anth', p => `${p.name} ${p.slug}`)
|
||||
expect(ranked[0]?.item.slug).toBe('anthropic')
|
||||
})
|
||||
})
|
||||
177
ui-tui/src/lib/fuzzy.ts
Normal file
177
ui-tui/src/lib/fuzzy.ts
Normal file
@ -0,0 +1,177 @@
|
||||
// Lightweight fuzzy subsequence scorer for picker filtering.
|
||||
//
|
||||
// Matches a query as an ordered subsequence of the target (so `g4o` matches
|
||||
// `gpt-4o`) and scores by match quality so callers can rank results. Higher
|
||||
// score is a better match. Returns the matched character indices so callers
|
||||
// can highlight them.
|
||||
//
|
||||
// The scoring favours, in rough order: exact full match, prefix match, matches
|
||||
// that start on a word boundary (after `-`, `_`, `/`, `.`, space, or a
|
||||
// lower→upper case transition), contiguous runs, and earlier matches. This is
|
||||
// intentionally simple — no external dependency — but good enough to make
|
||||
// `son4` rank `claude-sonnet-4` above an incidental scattered hit.
|
||||
//
|
||||
// The WebUI ships a logically identical copy of this module at
|
||||
// web/src/lib/fuzzy.ts (only prettier formatting differs); keep the two in
|
||||
// sync. The TUI copy carries the vitest suite (the web package has no test
|
||||
// runner), so changes should be validated here.
|
||||
|
||||
export interface FuzzyMatch {
|
||||
/** Total score; higher is better. */
|
||||
score: number
|
||||
/** Indices into the original (non-lowercased) target that were matched. */
|
||||
positions: number[]
|
||||
}
|
||||
|
||||
const WORD_BOUNDARY = /[-_/.\s]/
|
||||
|
||||
function isBoundary(target: string, index: number): boolean {
|
||||
if (index === 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
const prev = target[index - 1]
|
||||
|
||||
if (WORD_BOUNDARY.test(prev)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// camelCase / lower→upper transition (e.g. the `O` in `gptO`).
|
||||
const cur = target[index]
|
||||
|
||||
return prev === prev.toLowerCase() && cur !== cur.toLowerCase() && cur === cur.toUpperCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* Score a single query token against a target. Returns null when the token is
|
||||
* not a subsequence of the target. An empty query scores 0 with no positions.
|
||||
*/
|
||||
export function fuzzyScore(target: string, query: string): FuzzyMatch | null {
|
||||
if (!query) {
|
||||
return { score: 0, positions: [] }
|
||||
}
|
||||
|
||||
const lowerTarget = target.toLowerCase()
|
||||
const lowerQuery = query.toLowerCase()
|
||||
|
||||
const positions: number[] = []
|
||||
let score = 0
|
||||
let prevIndex = -1
|
||||
let searchFrom = 0
|
||||
|
||||
for (const ch of lowerQuery) {
|
||||
const idx = lowerTarget.indexOf(ch, searchFrom)
|
||||
|
||||
if (idx < 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
positions.push(idx)
|
||||
|
||||
// Base point for the matched character.
|
||||
score += 1
|
||||
|
||||
// Contiguous with the previous match → strong bonus.
|
||||
if (prevIndex >= 0 && idx === prevIndex + 1) {
|
||||
score += 5
|
||||
} else if (prevIndex >= 0) {
|
||||
// Penalise the gap we had to skip (capped), so contiguous beats scattered.
|
||||
score -= Math.min(idx - prevIndex - 1, 3)
|
||||
}
|
||||
|
||||
// Word-boundary / start-of-string matches are meaningful.
|
||||
if (isBoundary(target, idx)) {
|
||||
score += 3
|
||||
}
|
||||
|
||||
// Matching the very first character of the target is the strongest signal.
|
||||
if (idx === 0) {
|
||||
score += 5
|
||||
}
|
||||
|
||||
prevIndex = idx
|
||||
searchFrom = idx + 1
|
||||
}
|
||||
|
||||
// Prefix bonus: the query matched a contiguous prefix of the target.
|
||||
if (positions.length && positions[0] === 0 && positions[positions.length - 1] === positions.length - 1) {
|
||||
score += 8
|
||||
}
|
||||
|
||||
// Exact full match dominates everything else.
|
||||
if (lowerTarget === lowerQuery) {
|
||||
score += 20
|
||||
}
|
||||
|
||||
// Slightly prefer shorter targets when scores are otherwise close, so a
|
||||
// query that fully prefixes a short id beats the same prefix on a long one.
|
||||
score -= lowerTarget.length * 0.01
|
||||
|
||||
return { score, positions }
|
||||
}
|
||||
|
||||
/**
|
||||
* Score a target against a whitespace-separated, multi-token query. Every token
|
||||
* must match (AND semantics); the result aggregates per-token scores and the
|
||||
* union of matched positions. Returns null if any token fails to match.
|
||||
*/
|
||||
export function fuzzyScoreMulti(target: string, query: string): FuzzyMatch | null {
|
||||
const tokens = query.trim().toLowerCase().split(/\s+/).filter(Boolean)
|
||||
|
||||
if (!tokens.length) {
|
||||
return { score: 0, positions: [] }
|
||||
}
|
||||
|
||||
let score = 0
|
||||
const positionSet = new Set<number>()
|
||||
|
||||
for (const token of tokens) {
|
||||
const match = fuzzyScore(target, token)
|
||||
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
|
||||
score += match.score
|
||||
|
||||
for (const pos of match.positions) {
|
||||
positionSet.add(pos)
|
||||
}
|
||||
}
|
||||
|
||||
return { score, positions: [...positionSet].sort((a, b) => a - b) }
|
||||
}
|
||||
|
||||
export interface RankedItem<T> {
|
||||
item: T
|
||||
score: number
|
||||
positions: number[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter + rank a list by a fuzzy query against a derived text key. Non-matching
|
||||
* items are dropped; matches are sorted by score (descending), ties broken by
|
||||
* the original index so ordering is stable for equal scores. An empty query
|
||||
* returns every item in original order with no positions.
|
||||
*/
|
||||
export function fuzzyRank<T>(items: readonly T[], query: string, toText: (item: T) => string): RankedItem<T>[] {
|
||||
const trimmed = query.trim()
|
||||
|
||||
if (!trimmed) {
|
||||
return items.map(item => ({ item, score: 0, positions: [] }))
|
||||
}
|
||||
|
||||
const ranked: Array<RankedItem<T> & { index: number }> = []
|
||||
|
||||
items.forEach((item, index) => {
|
||||
const match = fuzzyScoreMulti(toText(item), trimmed)
|
||||
|
||||
if (match) {
|
||||
ranked.push({ item, score: match.score, positions: match.positions, index })
|
||||
}
|
||||
})
|
||||
|
||||
ranked.sort((a, b) => b.score - a.score || a.index - b.index)
|
||||
|
||||
return ranked.map(({ item, score, positions }) => ({ item, score, positions }))
|
||||
}
|
||||
@ -9,6 +9,7 @@ import { Check, Search, X } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { cn, themedBody } from "@/lib/utils";
|
||||
import { fuzzyRank } from "@/lib/fuzzy";
|
||||
|
||||
/**
|
||||
* Two-stage model picker modal.
|
||||
@ -150,25 +151,30 @@ export function ModelPickerDialog(props: Props) {
|
||||
[selectedProvider],
|
||||
);
|
||||
|
||||
const needle = query.trim().toLowerCase();
|
||||
const trimmedQuery = query.trim();
|
||||
|
||||
// Fuzzy-ranked providers: match on name + slug + the provider's model ids so
|
||||
// typing a model name surfaces its provider (preserves the prior behaviour
|
||||
// where a model match also revealed its provider).
|
||||
const filteredProviders = useMemo(
|
||||
() =>
|
||||
!needle
|
||||
? providers
|
||||
: providers.filter(
|
||||
(p) =>
|
||||
p.name.toLowerCase().includes(needle) ||
|
||||
p.slug.toLowerCase().includes(needle) ||
|
||||
(p.models ?? []).some((m) => m.toLowerCase().includes(needle)),
|
||||
),
|
||||
[providers, needle],
|
||||
fuzzyRank(
|
||||
providers,
|
||||
trimmedQuery,
|
||||
(p) => `${p.name} ${p.slug} ${(p.models ?? []).join(" ")}`,
|
||||
).map((r) => r.item),
|
||||
[providers, trimmedQuery],
|
||||
);
|
||||
|
||||
// Fuzzy-ranked models carrying the matched character positions so the model
|
||||
// list can highlight why each entry matched.
|
||||
const filteredModels = useMemo(
|
||||
() =>
|
||||
!needle ? models : models.filter((m) => m.toLowerCase().includes(needle)),
|
||||
[models, needle],
|
||||
fuzzyRank(models, trimmedQuery, (m) => m).map((r) => ({
|
||||
model: r.item,
|
||||
positions: r.positions,
|
||||
})),
|
||||
[models, trimmedQuery],
|
||||
);
|
||||
|
||||
const canConfirm = !!selectedProvider && !!selectedModel && !applying;
|
||||
@ -257,7 +263,7 @@ export function ModelPickerDialog(props: Props) {
|
||||
providers={filteredProviders}
|
||||
total={providers.length}
|
||||
selectedSlug={selectedSlug}
|
||||
query={needle}
|
||||
query={trimmedQuery}
|
||||
onSelect={(slug) => {
|
||||
setSelectedSlug(slug);
|
||||
setSelectedModel("");
|
||||
@ -402,7 +408,7 @@ function ModelColumn({
|
||||
onConfirm,
|
||||
}: {
|
||||
provider: ModelOptionProvider | null;
|
||||
models: string[];
|
||||
models: { model: string; positions: number[] }[];
|
||||
allModels: string[];
|
||||
selectedModel: string;
|
||||
currentModel: string;
|
||||
@ -435,7 +441,7 @@ function ModelColumn({
|
||||
: "no models listed for this provider"}
|
||||
</div>
|
||||
) : (
|
||||
models.map((m) => {
|
||||
models.map(({ model: m, positions }) => {
|
||||
const active = m === selectedModel;
|
||||
const isCurrent =
|
||||
m === currentModel && provider.slug === currentProviderSlug;
|
||||
@ -451,7 +457,9 @@ function ModelColumn({
|
||||
<Check
|
||||
className={`h-3 w-3 shrink-0 ${active ? "text-primary" : "text-transparent"}`}
|
||||
/>
|
||||
<span className="flex-1 truncate">{m}</span>
|
||||
<span className="flex-1 truncate">
|
||||
<HighlightedText text={m} positions={positions} />
|
||||
</span>
|
||||
{isCurrent && <CurrentTag />}
|
||||
</ListItem>
|
||||
);
|
||||
@ -468,3 +476,39 @@ function CurrentTag() {
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render `text` with the characters at `positions` emphasised, so users can
|
||||
* see which characters their fuzzy query matched. Positions are indices into
|
||||
* `text`; out-of-range indices are ignored.
|
||||
*/
|
||||
function HighlightedText({
|
||||
text,
|
||||
positions,
|
||||
}: {
|
||||
text: string;
|
||||
positions: number[];
|
||||
}) {
|
||||
if (!positions.length) {
|
||||
return <>{text}</>;
|
||||
}
|
||||
|
||||
const hit = new Set(positions);
|
||||
|
||||
return (
|
||||
<>
|
||||
{Array.from(text).map((ch, i) =>
|
||||
hit.has(i) ? (
|
||||
<mark
|
||||
key={i}
|
||||
className="bg-transparent text-primary font-semibold underline underline-offset-2"
|
||||
>
|
||||
{ch}
|
||||
</mark>
|
||||
) : (
|
||||
<span key={i}>{ch}</span>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
192
web/src/lib/fuzzy.ts
Normal file
192
web/src/lib/fuzzy.ts
Normal file
@ -0,0 +1,192 @@
|
||||
// Lightweight fuzzy subsequence scorer for picker filtering.
|
||||
//
|
||||
// Matches a query as an ordered subsequence of the target (so `g4o` matches
|
||||
// `gpt-4o`) and scores by match quality so callers can rank results. Higher
|
||||
// score is a better match. Returns the matched character indices so callers
|
||||
// can highlight them.
|
||||
//
|
||||
// The scoring favours, in rough order: exact full match, prefix match, matches
|
||||
// that start on a word boundary (after `-`, `_`, `/`, `.`, space, or a
|
||||
// lower→upper case transition), contiguous runs, and earlier matches. This is
|
||||
// intentionally simple — no external dependency — but good enough to make
|
||||
// `son4` rank `claude-sonnet-4` above an incidental scattered hit.
|
||||
//
|
||||
// This is a logically identical copy of ui-tui/src/lib/fuzzy.ts (only prettier
|
||||
// formatting differs); keep the two in sync. The TUI copy carries the vitest
|
||||
// suite (this `web` package has no test runner), so behavioural changes should
|
||||
// be validated there.
|
||||
|
||||
export interface FuzzyMatch {
|
||||
/** Total score; higher is better. */
|
||||
score: number;
|
||||
/** Indices into the original (non-lowercased) target that were matched. */
|
||||
positions: number[];
|
||||
}
|
||||
|
||||
const WORD_BOUNDARY = /[-_/.\s]/;
|
||||
|
||||
function isBoundary(target: string, index: number): boolean {
|
||||
if (index === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const prev = target[index - 1];
|
||||
|
||||
if (WORD_BOUNDARY.test(prev)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// camelCase / lower→upper transition (e.g. the `O` in `gptO`).
|
||||
const cur = target[index];
|
||||
|
||||
return (
|
||||
prev === prev.toLowerCase() &&
|
||||
cur !== cur.toLowerCase() &&
|
||||
cur === cur.toUpperCase()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Score a single query token against a target. Returns null when the token is
|
||||
* not a subsequence of the target. An empty query scores 0 with no positions.
|
||||
*/
|
||||
export function fuzzyScore(target: string, query: string): FuzzyMatch | null {
|
||||
if (!query) {
|
||||
return { score: 0, positions: [] };
|
||||
}
|
||||
|
||||
const lowerTarget = target.toLowerCase();
|
||||
const lowerQuery = query.toLowerCase();
|
||||
|
||||
const positions: number[] = [];
|
||||
let score = 0;
|
||||
let prevIndex = -1;
|
||||
let searchFrom = 0;
|
||||
|
||||
for (const ch of lowerQuery) {
|
||||
const idx = lowerTarget.indexOf(ch, searchFrom);
|
||||
|
||||
if (idx < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
positions.push(idx);
|
||||
|
||||
// Base point for the matched character.
|
||||
score += 1;
|
||||
|
||||
// Contiguous with the previous match → strong bonus.
|
||||
if (prevIndex >= 0 && idx === prevIndex + 1) {
|
||||
score += 5;
|
||||
} else if (prevIndex >= 0) {
|
||||
// Penalise the gap we had to skip (capped), so contiguous beats scattered.
|
||||
score -= Math.min(idx - prevIndex - 1, 3);
|
||||
}
|
||||
|
||||
// Word-boundary / start-of-string matches are meaningful.
|
||||
if (isBoundary(target, idx)) {
|
||||
score += 3;
|
||||
}
|
||||
|
||||
// Matching the very first character of the target is the strongest signal.
|
||||
if (idx === 0) {
|
||||
score += 5;
|
||||
}
|
||||
|
||||
prevIndex = idx;
|
||||
searchFrom = idx + 1;
|
||||
}
|
||||
|
||||
// Prefix bonus: the query matched a contiguous prefix of the target.
|
||||
if (
|
||||
positions.length &&
|
||||
positions[0] === 0 &&
|
||||
positions[positions.length - 1] === positions.length - 1
|
||||
) {
|
||||
score += 8;
|
||||
}
|
||||
|
||||
// Exact full match dominates everything else.
|
||||
if (lowerTarget === lowerQuery) {
|
||||
score += 20;
|
||||
}
|
||||
|
||||
// Slightly prefer shorter targets when scores are otherwise close, so a
|
||||
// query that fully prefixes a short id beats the same prefix on a long one.
|
||||
score -= lowerTarget.length * 0.01;
|
||||
|
||||
return { score, positions };
|
||||
}
|
||||
|
||||
/**
|
||||
* Score a target against a whitespace-separated, multi-token query. Every token
|
||||
* must match (AND semantics); the result aggregates per-token scores and the
|
||||
* union of matched positions. Returns null if any token fails to match.
|
||||
*/
|
||||
export function fuzzyScoreMulti(
|
||||
target: string,
|
||||
query: string,
|
||||
): FuzzyMatch | null {
|
||||
const tokens = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
||||
|
||||
if (!tokens.length) {
|
||||
return { score: 0, positions: [] };
|
||||
}
|
||||
|
||||
let score = 0;
|
||||
const positionSet = new Set<number>();
|
||||
|
||||
for (const token of tokens) {
|
||||
const match = fuzzyScore(target, token);
|
||||
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
score += match.score;
|
||||
|
||||
for (const pos of match.positions) {
|
||||
positionSet.add(pos);
|
||||
}
|
||||
}
|
||||
|
||||
return { score, positions: [...positionSet].sort((a, b) => a - b) };
|
||||
}
|
||||
|
||||
export interface RankedItem<T> {
|
||||
item: T;
|
||||
score: number;
|
||||
positions: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter + rank a list by a fuzzy query against a derived text key. Non-matching
|
||||
* items are dropped; matches are sorted by score (descending), ties broken by
|
||||
* the original index so ordering is stable for equal scores. An empty query
|
||||
* returns every item in original order with no positions.
|
||||
*/
|
||||
export function fuzzyRank<T>(
|
||||
items: readonly T[],
|
||||
query: string,
|
||||
toText: (item: T) => string,
|
||||
): RankedItem<T>[] {
|
||||
const trimmed = query.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
return items.map((item) => ({ item, score: 0, positions: [] }));
|
||||
}
|
||||
|
||||
const ranked: Array<RankedItem<T> & { index: number }> = [];
|
||||
|
||||
items.forEach((item, index) => {
|
||||
const match = fuzzyScoreMulti(toText(item), trimmed);
|
||||
|
||||
if (match) {
|
||||
ranked.push({ item, score: match.score, positions: match.positions, index });
|
||||
}
|
||||
});
|
||||
|
||||
ranked.sort((a, b) => b.score - a.score || a.index - b.index);
|
||||
|
||||
return ranked.map(({ item, score, positions }) => ({ item, score, positions }));
|
||||
}
|
||||
Reference in New Issue
Block a user