// 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 })) }