From 7527e7aeac1743de948d41d963af52334ba57184 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Mon, 1 Jun 2026 23:05:11 +0530 Subject: [PATCH] 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 . - 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 --- ui-tui/src/components/modelPicker.tsx | 195 ++++++++++++++++++----- ui-tui/src/lib/fuzzy.test.ts | 109 +++++++++++++ ui-tui/src/lib/fuzzy.ts | 177 ++++++++++++++++++++ web/src/components/ModelPickerDialog.tsx | 76 +++++++-- web/src/lib/fuzzy.ts | 192 ++++++++++++++++++++++ 5 files changed, 695 insertions(+), 54 deletions(-) create mode 100644 ui-tui/src/lib/fuzzy.test.ts create mode 100644 ui-tui/src/lib/fuzzy.ts create mode 100644 web/src/lib/fuzzy.ts diff --git a/ui-tui/src/components/modelPicker.tsx b/ui-tui/src/components/modelPicker.tsx index 07e3f22b9..c18fbe9f0 100644 --- a/ui-tui/src/components/modelPicker.tsx +++ b/ui-tui/src/components/modelPicker.tsx @@ -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 ( @@ -407,6 +513,9 @@ export function ModelPicker({ allowPersistGlobal = true, gw, onCancel, onSelect, Current: {currentModel || '(unknown)'} + + {filter ? `filter: ${filter}▎` : 'type to filter · ↑/↓ select'} + {provider?.warning ? `warning: ${provider.warning}` : ' '} @@ -414,29 +523,35 @@ export function ModelPicker({ allowPersistGlobal = true, gw, onCancel, onSelect, {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 + {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} - - ) : ( - - {' '} - - ) - })} + return row ? ( + + {providerIdx === idx ? '▸ ' : ' '} + {idx + 1}. {row} + + ) : ( + + {' '} + + ) + }) + )} {offset + VISIBLE < rows.length ? ` ↓ ${rows.length - offset - VISIBLE} more` : ' '} @@ -444,15 +559,16 @@ export function ModelPicker({ allowPersistGlobal = true, gw, onCancel, onSelect, persist: {allowPersistGlobal ? (persistGlobal ? 'global' : 'session') : 'session'} - {allowPersistGlobal ? ' · g toggle' : ' only'} + {allowPersistGlobal ? ' · ^g toggle' : ' only'} - ↑/↓ select · Enter choose · d disconnect · Esc/q cancel + ↑/↓ 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 ( @@ -461,7 +577,10 @@ export function ModelPicker({ allowPersistGlobal = true, gw, onCancel, onSelect, - {names[providerIdx] || '(unknown provider)'} · Esc back + {filteredProviderRows[providerIdx]?.name || '(unknown provider)'} · Esc back + + + {filter ? `filter: ${filter}▎` : 'type to filter · ↑/↓ select'} {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 ? ( - no models listed for this provider + {noModelMatches ? 'no models match filter' : 'no models listed for this provider'} ) : ( @@ -508,10 +627,10 @@ export function ModelPicker({ allowPersistGlobal = true, gw, onCancel, onSelect, persist: {allowPersistGlobal ? (persistGlobal ? 'global' : 'session') : 'session'} - {allowPersistGlobal ? ' · g toggle' : ' only'} + {allowPersistGlobal ? ' · ^g toggle' : ' only'} - {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'} ) diff --git a/ui-tui/src/lib/fuzzy.test.ts b/ui-tui/src/lib/fuzzy.test.ts new file mode 100644 index 000000000..10292c495 --- /dev/null +++ b/ui-tui/src/lib/fuzzy.test.ts @@ -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') + }) +}) diff --git a/ui-tui/src/lib/fuzzy.ts b/ui-tui/src/lib/fuzzy.ts new file mode 100644 index 000000000..513ebf8fb --- /dev/null +++ b/ui-tui/src/lib/fuzzy.ts @@ -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() + + 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 { + 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(items: readonly T[], query: string, toText: (item: T) => string): RankedItem[] { + const trimmed = query.trim() + + if (!trimmed) { + return items.map(item => ({ item, score: 0, positions: [] })) + } + + const ranked: Array & { 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 })) +} diff --git a/web/src/components/ModelPickerDialog.tsx b/web/src/components/ModelPickerDialog.tsx index 94b5d3e5f..54489dd1f 100644 --- a/web/src/components/ModelPickerDialog.tsx +++ b/web/src/components/ModelPickerDialog.tsx @@ -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"} ) : ( - 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({ - {m} + + + {isCurrent && } ); @@ -468,3 +476,39 @@ function CurrentTag() { ); } + +/** + * 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) ? ( + + {ch} + + ) : ( + {ch} + ), + )} + + ); +} diff --git a/web/src/lib/fuzzy.ts b/web/src/lib/fuzzy.ts new file mode 100644 index 000000000..979424857 --- /dev/null +++ b/web/src/lib/fuzzy.ts @@ -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(); + + 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 { + 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( + items: readonly T[], + query: string, + toText: (item: T) => string, +): RankedItem[] { + const trimmed = query.trim(); + + if (!trimmed) { + return items.map((item) => ({ item, score: 0, positions: [] })); + } + + const ranked: Array & { 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 })); +}