From 7e093163044a39d7d2aaf853e5c2350f733e9b63 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 1/3] 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 | 197 ++++++++++++++++++----- 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, 696 insertions(+), 55 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 07e3f22b9c8c4..c18fbe9f0583f 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 - - return row ? ( - - {providerIdx === idx ? '▸ ' : ' '} - {idx + 1}. {row} - - ) : ( - - {' '} - - ) - })} + {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} + + ) : ( + + {' '} + + ) + }) + )} {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 0000000000000..10292c495c4ce --- /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 0000000000000..513ebf8fb8bcc --- /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 94b5d3e5fe444..54489dd1f056a 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 0000000000000..9794248572181 --- /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 })); +} From d192579d4739d07bf95c76f15840cbfe8c00883a Mon Sep 17 00:00:00 2001 From: Harish Kukreja Date: Mon, 1 Jun 2026 23:10:03 +0530 Subject: [PATCH 2/3] feat(cli): add fuzzy search helpers for curses pickers Pure, refactor-independent helpers for type-to-filter search in the curses single-/radio-select menus: subsequence matching, filtered-index mapping, cursor reconciliation, scroll clamping, and an active-search key handler, plus unit tests. Salvaged from #22758 (the curses event loop was since refactored into a shared driver on main, so the integration is rebuilt in a follow-up commit; these pure helpers and their tests carry over unchanged). --- hermes_cli/curses_ui.py | 109 ++++++++++++++++++++++ tests/hermes_cli/test_curses_ui_search.py | 68 ++++++++++++++ 2 files changed, 177 insertions(+) create mode 100644 tests/hermes_cli/test_curses_ui_search.py diff --git a/hermes_cli/curses_ui.py b/hermes_cli/curses_ui.py index ee31183b71250..a9bd4626d4bb1 100644 --- a/hermes_cli/curses_ui.py +++ b/hermes_cli/curses_ui.py @@ -5,11 +5,120 @@ text-based numbered fallback for terminals without curses support. """ import sys +from dataclasses import dataclass from typing import Callable, List, Optional, Set from hermes_cli.colors import Colors, color +def _query_matches(label: str, query: str) -> bool: + """Return True when every query token is a case-insensitive subsequence.""" + normalized = label.lower() + tokens = query.lower().split() + + if not tokens: + return True + + for token in tokens: + pos = 0 + + for ch in token: + pos = normalized.find(ch, pos) + + if pos < 0: + return False + + pos += 1 + + return True + + +def _filter_indices(items: List[str], query: str) -> List[int]: + """Return original item indices matching *query*, preserving list order.""" + q = query.strip() + + if not q: + return list(range(len(items))) + + return [i for i, label in enumerate(items) if _query_matches(label, q)] + + +@dataclass +class _SearchState: + """Mutable search state shared by curses picker loops.""" + + active: bool = False + query: str = "" + + +def _reconcile_cursor(filtered: List[int], cursor: int) -> tuple[int, int]: + """Return ``(cursor, cursor_pos)`` inside the filtered index list.""" + if not filtered: + return cursor, 0 + + if cursor not in filtered: + cursor = filtered[0] + + return cursor, filtered.index(cursor) + + +def _move_filtered_cursor( + filtered: List[int], cursor: int, cursor_pos: int, delta: int +) -> int: + """Move through the filtered index list, wrapping like the legacy menus.""" + if not filtered: + return cursor + + return filtered[(cursor_pos + delta) % len(filtered)] + + +def _scroll_for_cursor( + scroll_offset: int, cursor_pos: int, visible_rows: int, total_rows: int +) -> int: + """Clamp scroll offset so the cursor remains visible.""" + visible_rows = max(1, visible_rows) + + if cursor_pos < scroll_offset: + scroll_offset = cursor_pos + elif cursor_pos >= scroll_offset + visible_rows: + scroll_offset = cursor_pos - visible_rows + 1 + + return max(0, min(scroll_offset, max(0, total_rows - visible_rows))) + + +def _handle_active_search_key( + curses_mod, key: int, search: _SearchState +) -> tuple[bool, bool, bool]: + """Handle a key while the search prompt is active. + + Returns ``(handled, confirm, changed)``. Active search consumes query + editing keys, but leaves navigation keys for the menu loop to handle. + """ + if not search.active: + return False, False, False + + if key == 27: + search.active = False + return True, False, False + + if key in (curses_mod.KEY_BACKSPACE, 127, 8): + search.query = search.query[:-1] + return True, False, True + + if key == 21: # Ctrl+U + search.query = "" + return True, False, True + + if key in (curses_mod.KEY_ENTER, 10, 13): + return True, True, False + + if 0 <= key < 256 and chr(key).isprintable(): + search.query += chr(key) + return True, False, True + + return False, False, False + + def flush_stdin() -> None: """Flush any stray bytes from the stdin input buffer. diff --git a/tests/hermes_cli/test_curses_ui_search.py b/tests/hermes_cli/test_curses_ui_search.py new file mode 100644 index 0000000000000..877240fc33051 --- /dev/null +++ b/tests/hermes_cli/test_curses_ui_search.py @@ -0,0 +1,68 @@ +from hermes_cli.curses_ui import ( + _SearchState, + _filter_indices, + _handle_active_search_key, + _move_filtered_cursor, + _reconcile_cursor, +) + + +class _FakeCurses: + KEY_BACKSPACE = 263 + KEY_DOWN = 258 + KEY_ENTER = 343 + + +def test_filter_indices_keeps_all_items_for_blank_query(): + assert _filter_indices(["Anthropic", "OpenAI"], "") == [0, 1] + assert _filter_indices(["Anthropic", "OpenAI"], " ") == [0, 1] + + +def test_filter_indices_matches_subsequences(): + items = ["claude-opus-4-7", "gpt-5.4-codex", "deepseek-v4"] + + assert _filter_indices(items, "co47") == [0] + assert _filter_indices(items, "gpt5") == [1] + + +def test_filter_indices_requires_all_tokens(): + items = ["OpenAI Codex", "OpenAI Chat Completions", "Anthropic Claude"] + + assert _filter_indices(items, "open cod") == [0] + + +def test_reconcile_cursor_moves_to_first_visible_match(): + assert _reconcile_cursor([2, 4], 0) == (2, 0) + assert _reconcile_cursor([2, 4], 4) == (4, 1) + + +def test_move_filtered_cursor_wraps_within_matches(): + filtered = [2, 4, 7] + + assert _move_filtered_cursor(filtered, 2, 0, -1) == 7 + assert _move_filtered_cursor(filtered, 7, 2, 1) == 2 + + +def test_active_search_allows_navigation_keys_to_reach_menu_loop(): + search = _SearchState(active=True, query="opus") + + assert _handle_active_search_key(_FakeCurses, _FakeCurses.KEY_DOWN, search) == ( + False, + False, + False, + ) + assert search.active is True + assert search.query == "opus" + + +def test_active_search_consumes_query_editing_and_confirm_keys(): + search = _SearchState(active=True, query="op") + + assert _handle_active_search_key(_FakeCurses, ord("u"), search) == (True, False, True) + assert search.query == "opu" + + assert _handle_active_search_key(_FakeCurses, _FakeCurses.KEY_ENTER, search) == ( + True, + True, + False, + ) From 4215ad9581e9415f4be2221a0eb815a7826d51df Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Mon, 1 Jun 2026 23:18:25 +0530 Subject: [PATCH 3/3] feat(cli): ranked fuzzy search in the curses model picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the salvaged search helpers into the shared curses menu driver and turns on type-to-filter for the CLI model pickers (the 100+ model lists that previously required scrolling). - Search lives in the shared `_run_curses_menu` driver behind a `searchable` flag + `search_labels`, so both `curses_radiolist` and `curses_single_select` get it without per-menu duplication. `/` opens the filter, BACKSPACE edits, Ctrl+U clears, ESC clears the filter then cancels. Returned values are always original item indices. - `_filter_indices` RANKS matches (best-first) via a Python port of the TS scorer in ui-tui/src/lib/fuzzy.ts and web/src/lib/fuzzy.ts. The port is byte-identical in score: same per-char bonuses, prefix (+8) and exact (+20) bonuses, camelCase/word-boundary detection (matching on the lowercased target, boundary on the original case), and the -len*0.01 length tiebreak — so the CLI, TUI, and WebUI rank results identically. A cross-language parity test pins the exact scores. - `_prompt_model_selection` (the canonical picker across the model flows) and the custom-provider model list pass `searchable=True`. - Split `_decode_menu_key` out of `read_menu_key` so the search loop can peek the raw key (catch `/`) before nav decoding. - ESC during active search now clears the query (restores the full list) so a no-match filter can't strand the user; printable-key capture is restricted to ASCII to avoid Latin-1 mojibake. - Update two setup-menu tests whose mock signatures predate the new `searchable` kwarg; add ranked-scorer + parity + state-machine tests. --- hermes_cli/auth.py | 1 + hermes_cli/curses_ui.py | 279 +++++++++++++++--- hermes_cli/main.py | 1 + tests/hermes_cli/test_curses_ui_fuzzy_rank.py | 127 ++++++++ .../test_setup_menu_curses_migration.py | 4 +- 5 files changed, 377 insertions(+), 35 deletions(-) create mode 100644 tests/hermes_cli/test_curses_ui_fuzzy_rank.py diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 506e7499999e7..97f51886f2b43 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -6165,6 +6165,7 @@ def _label(mid): selected=default_idx, cancel_returns=-1, description=description, + searchable=True, ) if idx < 0: return None diff --git a/hermes_cli/curses_ui.py b/hermes_cli/curses_ui.py index a9bd4626d4bb1..acaa614b0673a 100644 --- a/hermes_cli/curses_ui.py +++ b/hermes_cli/curses_ui.py @@ -33,14 +33,131 @@ def _query_matches(label: str, query: str) -> bool: return True +_WORD_BOUNDARY = frozenset("-_/. ") + + +def _is_boundary(target: str, index: int) -> bool: + """True if position ``index`` in ``target`` starts a word. + + Mirrors ``isBoundary`` in the TS scorer: start-of-string, after a + separator char, or a lower->upper camelCase transition. + """ + if index == 0: + return True + + prev = target[index - 1] + + if prev in _WORD_BOUNDARY: + return True + + # camelCase / lower->upper transition (e.g. the `O` in `gptO`). + cur = target[index] + + return prev == prev.lower() and cur != cur.lower() and cur == cur.upper() + + +def _token_score(orig: str, lower: str, token: str) -> float | None: + """Score one token against a target. None if the token isn't a subsequence. + + A faithful port of ``fuzzyScore`` in ui-tui/src/lib/fuzzy.ts and + web/src/lib/fuzzy.ts so all three surfaces rank model ids identically: + contiguous runs, word-boundary / first-char starts, prefix matches, and + exact matches all score higher than scattered subsequence hits. + + ``lower`` is ``orig`` lowercased; matching is done against ``lower`` while + boundary detection uses ``orig`` (so the camelCase rule works), exactly as + in the TS scorer. + """ + score = 0.0 + prev = -1 + search_from = 0 + positions: list[int] = [] + + for ch in token: + idx = lower.find(ch, search_from) + + if idx < 0: + return None + + positions.append(idx) + score += 1 + + if prev >= 0 and idx == prev + 1: + score += 5 + elif prev >= 0: + score -= min(idx - prev - 1, 3) + + if _is_boundary(orig, idx): + score += 3 + + if idx == 0: + score += 5 + + prev = idx + search_from = idx + 1 + + # Prefix bonus: the token matched a contiguous prefix of the target. + if positions and positions[0] == 0 and positions[-1] == len(positions) - 1: + score += 8 + + # Exact full match dominates everything else. + if lower == token: + score += 20 + + # Slightly prefer shorter targets when scores are otherwise close. + score -= len(lower) * 0.01 + + return score + + +def _fuzzy_score(label: str, query: str) -> float | None: + """Aggregate score for a multi-token query (AND). None if any token fails. + + Mirrors ``fuzzyScoreMulti`` in the TS scorer: every whitespace-separated + token must match; per-token scores are summed. + """ + lower = label.lower() + tokens = query.lower().split() + + if not tokens: + return 0.0 + + total = 0.0 + + for token in tokens: + token_score = _token_score(label, lower, token) + + if token_score is None: + return None + + total += token_score + + return total + + def _filter_indices(items: List[str], query: str) -> List[int]: - """Return original item indices matching *query*, preserving list order.""" + """Return item indices matching *query*, ranked best-first. + + An empty query keeps every item in original order. Otherwise items are + filtered to fuzzy matches and sorted by score descending, ties broken by + original index so equal-scoring rows keep their catalog order. + """ q = query.strip() if not q: return list(range(len(items))) - return [i for i, label in enumerate(items) if _query_matches(label, q)] + scored = [] + + for i, label in enumerate(items): + score = _fuzzy_score(label, q) + + if score is not None: + scored.append((i, score)) + + scored.sort(key=lambda pair: (-pair[1], pair[0])) + + return [i for i, _ in scored] @dataclass @@ -98,8 +215,13 @@ def _handle_active_search_key( return False, False, False if key == 27: + # Esc stops search AND clears the query, restoring the full list (so a + # no-match filter can't strand the user on an empty list). Signals + # `changed` when there was a query so the driver resets scroll/cursor. + had_query = bool(search.query) search.active = False - return True, False, False + search.query = "" + return True, False, had_query if key in (curses_mod.KEY_BACKSPACE, 127, 8): search.query = search.query[:-1] @@ -112,7 +234,7 @@ def _handle_active_search_key( if key in (curses_mod.KEY_ENTER, 10, 13): return True, True, False - if 0 <= key < 256 and chr(key).isprintable(): + if 32 <= key < 127: # printable ASCII; avoids Latin-1 mojibake from 128-255 search.query += chr(key) return True, False, True @@ -167,9 +289,16 @@ def read_menu_key(stdscr) -> str: the escape path; ``q`` also cancels. Unknown sequences map to ``NAV_NONE`` so the caller simply ignores them rather than misfiring. """ - import curses + return _decode_menu_key(stdscr, stdscr.getch()) - key = stdscr.getch() + +def _decode_menu_key(stdscr, key: int) -> str: + """Normalize an already-read keypress to a menu action. + + Split out from ``read_menu_key`` so search-aware loops can peek the raw + key (e.g. to catch ``/``) before falling back to nav decoding. + """ + import curses if key in (curses.KEY_UP, ord("k")): return NAV_UP @@ -230,6 +359,8 @@ def _run_curses_menu( extra_color_pairs=False, fallback, cancel_value, + searchable=False, + search_labels=None, ): """Shared curses single-/multi-select event loop. @@ -244,9 +375,12 @@ def _run_curses_menu( Callbacks / params: draw_header(stdscr, max_y, max_x) -> int Draw the title/hint/description rows. Returns the first screen row - index where the scrollable item list should start. + index where the scrollable item list should start. When search is + active it receives the live ``_SearchState`` via the optional + ``search`` keyword (drawn by the menu so the hint line can show it). draw_row(stdscr, y, idx, is_cursor, max_x) -> None - Draw one item row. + Draw one item row. ``idx`` is always the ORIGINAL item index, so + per-menu rendering is unchanged whether or not a filter is active. on_action(action, cursor) -> value Reducer for SELECT/TOGGLE/CANCEL. Return ``_KEEP`` to continue the loop; return anything else to resolve the menu with that value. @@ -260,6 +394,10 @@ def _run_curses_menu( fallback() -> value Called when curses errors out on a real TTY (curses unavailable). cancel_value: returned on non-TTY stdin, ESC/cancel, or KeyboardInterrupt. + searchable: when true, ``/`` opens a type-to-filter prompt over + ``search_labels``. Returned values are always ORIGINAL item indices. + search_labels: per-item text used for filtering (required when + ``searchable`` is true; length must equal ``item_count``). """ # Non-TTY (piped/redirected stdin): curses and input() both hang or spin, # so return the cancel value directly — matching the pre-refactor guard in @@ -267,6 +405,8 @@ def _run_curses_menu( if not sys.stdin.isatty(): return cancel_value + use_search = searchable and search_labels is not None and len(search_labels) == item_count + try: import curses result_holder = [_KEEP] @@ -284,22 +424,46 @@ def _draw(stdscr): ) cursor = initial_cursor scroll_offset = 0 + search = _SearchState() + # Non-None labels for filtering; empty when search is disabled so + # _filter_indices stays a cheap identity range. + labels: List[str] = ( + search_labels if (use_search and search_labels is not None) else [] + ) while True: stdscr.clear() max_y, max_x = stdscr.getmaxyx() - items_start = draw_header(stdscr, max_y, max_x) - - visible_rows = max_y - items_start - reserve_bottom - if cursor < scroll_offset: - scroll_offset = cursor - elif cursor >= scroll_offset + visible_rows: - scroll_offset = cursor - visible_rows + 1 - - for draw_i, i in enumerate( - range(scroll_offset, min(item_count, scroll_offset + visible_rows)) + filtered = ( + _filter_indices(labels, search.query) + if use_search + else list(range(item_count)) + ) + cursor, cursor_pos = _reconcile_cursor(filtered, cursor) + + # draw_header accepts an optional `search` kwarg when the menu + # wants to render the live filter; tolerate headers that don't. + try: + items_start = draw_header(stdscr, max_y, max_x, search=search) + except TypeError: + items_start = draw_header(stdscr, max_y, max_x) + + visible_rows = max(1, max_y - items_start - reserve_bottom) + scroll_offset = _scroll_for_cursor( + scroll_offset, cursor_pos, visible_rows, len(filtered) + ) + + if use_search and search.query and not filtered: + try: + stdscr.addnstr(items_start, 0, " No matches", max_x - 1, curses.A_DIM) + except curses.error: + pass + + for draw_i, filtered_pos in enumerate( + range(scroll_offset, min(len(filtered), scroll_offset + visible_rows)) ): + i = filtered[filtered_pos] y = draw_i + items_start if y >= max_y - reserve_bottom: break @@ -309,13 +473,46 @@ def _draw(stdscr): draw_footer(stdscr, max_y, max_x) stdscr.refresh() - action = read_menu_key(stdscr) + + if use_search: + key = stdscr.getch() + + if search.active: + # Active search consumes query-editing keys; nav keys + # fall through to be decoded below. + handled, confirm, changed = _handle_active_search_key( + curses, key, search + ) + if changed: + scroll_offset = 0 + cursor, cursor_pos = _reconcile_cursor( + _filter_indices(search_labels, search.query), cursor + ) + if confirm: + if filtered: + outcome = on_action(NAV_SELECT, cursor) + if outcome is not _KEEP: + result_holder[0] = outcome + return + continue + if handled: + continue + action = _decode_menu_key(stdscr, key) + elif key == ord("/"): + search.active = True + continue + else: + action = _decode_menu_key(stdscr, key) + else: + action = read_menu_key(stdscr) if action == NAV_UP: - cursor = (cursor - 1) % item_count + cursor = _move_filtered_cursor(filtered, cursor, cursor_pos, -1) elif action == NAV_DOWN: - cursor = (cursor + 1) % item_count + cursor = _move_filtered_cursor(filtered, cursor, cursor_pos, 1) elif action in (NAV_SELECT, NAV_TOGGLE, NAV_CANCEL): + if action == NAV_SELECT and use_search and not filtered: + continue outcome = on_action(action, cursor) if outcome is not _KEEP: result_holder[0] = outcome @@ -429,6 +626,7 @@ def curses_radiolist( *, cancel_returns: int | None = None, description: str | None = None, + searchable: bool = False, ) -> int: """Curses single-select radio list. Returns the selected index. @@ -440,6 +638,9 @@ def curses_radiolist( description: Optional multi-line text shown between the title and the item list. Useful for context that should survive the curses screen clear. + searchable: When true, ``/`` opens a type-to-filter prompt. The + returned value is always the original item index, not a filtered + row position. """ if cancel_returns is None: cancel_returns = selected @@ -448,7 +649,7 @@ def curses_radiolist( if description: desc_lines = description.splitlines() - def _draw_header(stdscr, max_y, max_x): + def _draw_header(stdscr, max_y, max_x, search=None): import curses row = 0 try: @@ -465,11 +666,13 @@ def _draw_header(stdscr, max_y, max_x): stdscr.addnstr(row, 0, dline, max_x - 1, curses.A_NORMAL) row += 1 - stdscr.addnstr( - row, 0, - " \u2191\u2193 navigate ENTER/SPACE select ESC cancel", - max_x - 1, curses.A_DIM, - ) + if searchable and search is not None and search.active: + hint = f" Search: {search.query}\u258e BACKSPACE edit Ctrl+U clear ESC stop" + elif searchable: + hint = " \u2191\u2193 navigate ENTER/SPACE select / search ESC cancel" + else: + hint = " \u2191\u2193 navigate ENTER/SPACE select ESC cancel" + stdscr.addnstr(row, 0, hint, max_x - 1, curses.A_DIM) row += 1 except curses.error: pass @@ -505,6 +708,8 @@ def _on_action(action, cursor): reserve_bottom=1, fallback=lambda: _radio_numbered_fallback(title, items, selected, cancel_returns), cancel_value=cancel_returns, + searchable=searchable, + search_labels=list(items) if searchable else None, ) @@ -540,27 +745,33 @@ def curses_single_select( default_index: int = 0, *, cancel_label: str = "Cancel", + searchable: bool = False, ) -> int | None: """Curses single-select menu. Returns selected index or None on cancel. Works inside prompt_toolkit because curses.wrapper() restores the terminal safely, unlike simple_term_menu which conflicts with /dev/tty. + + When ``searchable`` is true, ``/`` opens a type-to-filter prompt; the + returned value is always the original item index (or None for cancel). """ all_items = list(items) + [cancel_label] cancel_idx = len(items) - def _draw_header(stdscr, max_y, max_x): + def _draw_header(stdscr, max_y, max_x, search=None): import curses try: hattr = curses.A_BOLD if curses.has_colors(): hattr |= curses.color_pair(2) stdscr.addnstr(0, 0, title, max_x - 1, hattr) - stdscr.addnstr( - 1, 0, - " ↑↓ navigate ENTER confirm ESC/q cancel", - max_x - 1, curses.A_DIM, - ) + if searchable and search is not None and search.active: + hint = f" Search: {search.query}\u258e BACKSPACE edit Ctrl+U clear ESC stop" + elif searchable: + hint = " ↑↓ navigate ENTER confirm / search ESC/q cancel" + else: + hint = " ↑↓ navigate ENTER confirm ESC/q cancel" + stdscr.addnstr(1, 0, hint, max_x - 1, curses.A_DIM) except curses.error: pass return 3 @@ -597,6 +808,8 @@ def _on_action(action, cursor): reserve_bottom=1, fallback=lambda: _numbered_single_fallback(title, all_items, cancel_idx), cancel_value=None, + searchable=searchable, + search_labels=list(all_items) if searchable else None, ) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 13d8277528978..a83dbff4d18d0 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -4575,6 +4575,7 @@ def _model_flow_named_custom(config, provider_info): menu_items, selected=default_idx, cancel_returns=-1, + searchable=True, ) print() if idx < 0 or idx >= len(models): diff --git a/tests/hermes_cli/test_curses_ui_fuzzy_rank.py b/tests/hermes_cli/test_curses_ui_fuzzy_rank.py new file mode 100644 index 0000000000000..dbcc920c070ce --- /dev/null +++ b/tests/hermes_cli/test_curses_ui_fuzzy_rank.py @@ -0,0 +1,127 @@ +"""Tests for the ranked fuzzy scorer used by the searchable curses pickers.""" +from hermes_cli.curses_ui import ( + _SearchState, + _filter_indices, + _fuzzy_score, + _handle_active_search_key, + _is_boundary, + _token_score, +) + + +class _FakeCurses: + KEY_BACKSPACE = 263 + KEY_DOWN = 258 + KEY_ENTER = 343 + + +def test_fuzzy_score_matches_subsequence(): + assert _fuzzy_score("gpt-4o", "g4o") is not None + assert _fuzzy_score("gpt-4o", "4o") is not None + assert _fuzzy_score("gpt-4o", "o4g") is None + assert _fuzzy_score("gpt-4o", "xyz") is None + + +def test_scorer_matches_typescript_reference(): + """Score parity with ui-tui/web fuzzy.ts. These exact values are produced + by the TS fuzzyScoreMulti for the same inputs (verified via a cross-language + harness); keep the Python port byte-identical so all three surfaces rank + consistently. If you change the scoring constants, update the TS copies too. + """ + cases = { + ("gpt-4o", "g4o"): 15.94, + ("gpt-4o", "gpt"): 28.94, + ("claude-sonnet-4", "sonnet"): 33.85, + ("claude-sonnet-4", "clad snnt"): 30.70, + ("GptO", "gpto"): 57.96, # camelCase boundary on the original-case 'O' + } + for (label, query), expected in cases.items(): + score = _fuzzy_score(label, query) + assert score is not None + assert round(score, 2) == expected, f"{label!r}/{query!r}: {score} != {expected}" + + +def test_is_boundary_camelcase_and_separators(): + assert _is_boundary("gpt-4o", 0) is True # start + assert _is_boundary("gpt-4o", 4) is True # after '-' + assert _is_boundary("gpt-4o", 2) is False # mid-word + assert _is_boundary("GptO", 3) is True # lower->upper transition + + +def test_token_score_takes_orig_and_lower(): + # Exact match (lower == token) earns the +20 bonus over a prefix. + exact = _token_score("sonnet", "sonnet", "sonnet") + prefix = _token_score("sonnet-x", "sonnet-x", "sonnet") + assert exact is not None and prefix is not None + assert exact > prefix + + +def test_esc_clears_query_and_signals_changed(): + # Esc during active search clears the filter (restores full list) and + # signals `changed` so the driver resets scroll/cursor. + search = _SearchState(active=True, query="gpt") + handled, confirm, changed = _handle_active_search_key(_FakeCurses, 27, search) + assert (handled, confirm, changed) == (True, False, True) + assert search.active is False + assert search.query == "" + + # Esc with no query: still stops search, but nothing changed. + search2 = _SearchState(active=True, query="") + assert _handle_active_search_key(_FakeCurses, 27, search2) == (True, False, False) + + +def test_high_byte_keys_ignored(): + # Bytes 128-255 must NOT append Latin-1 mojibake to the query. + search = _SearchState(active=True, query="ab") + handled, _, changed = _handle_active_search_key(_FakeCurses, 200, search) + assert (handled, changed) == (False, False) + assert search.query == "ab" + + +def test_fuzzy_score_empty_query_is_zero(): + assert _fuzzy_score("anything", "") == 0 + assert _fuzzy_score("anything", " ") == 0 + + +def test_fuzzy_score_prefix_beats_scattered(): + prefix = _fuzzy_score("gpt-4o-mini", "gpt") + scattered = _fuzzy_score("a-g-p-t", "gpt") + assert prefix is not None and scattered is not None + assert prefix > scattered + + +def test_fuzzy_score_exact_and_shorter_rank_higher(): + exact = _fuzzy_score("sonnet", "sonnet") + longer = _fuzzy_score("sonnet-extended", "sonnet") + assert exact is not None and longer is not None + # Same prefix match, but the shorter id wins on the length tiebreak. + assert exact > longer + + +def test_filter_indices_ranks_best_first(): + models = ["gpt-4o", "gpt-4o-mini", "claude-sonnet-4", "claude-haiku", "o1-preview"] + + # g4o matches both gpt-4o variants; the shorter exact-ish one ranks first. + ranked = _filter_indices(models, "g4o") + assert [models[i] for i in ranked] == ["gpt-4o", "gpt-4o-mini"] + + # son4 surfaces the sonnet model. + assert [models[i] for i in _filter_indices(models, "son4")] == ["claude-sonnet-4"] + + # Multi-token AND. + assert [models[i] for i in _filter_indices(models, "clad snnt")] == ["claude-sonnet-4"] + + # No match drops everything. + assert _filter_indices(models, "zzz") == [] + + +def test_filter_indices_blank_query_preserves_order(): + models = ["b", "a", "c"] + assert _filter_indices(models, "") == [0, 1, 2] + assert _filter_indices(models, " ") == [0, 1, 2] + + +def test_filter_indices_stable_for_equal_scores(): + # Identical labels score identically; original order is the tiebreak. + items = ["ab", "ab", "ab"] + assert _filter_indices(items, "ab") == [0, 1, 2] diff --git a/tests/hermes_cli/test_setup_menu_curses_migration.py b/tests/hermes_cli/test_setup_menu_curses_migration.py index 9f6560b1e62d4..46b1515bf79e8 100644 --- a/tests/hermes_cli/test_setup_menu_curses_migration.py +++ b/tests/hermes_cli/test_setup_menu_curses_migration.py @@ -13,7 +13,7 @@ def test_prompt_model_selection_uses_curses_radiolist(): seen = {} - def _fake(title, items, *, selected=0, cancel_returns=None, description=None): + def _fake(title, items, *, selected=0, cancel_returns=None, description=None, searchable=False): seen["title"] = title seen["items"] = items return 1 # pick second model @@ -67,7 +67,7 @@ def test_model_selection_with_pricing_passes_description(): seen = {} - def _fake(title, items, *, selected=0, cancel_returns=None, description=None): + def _fake(title, items, *, selected=0, cancel_returns=None, description=None, searchable=False): seen["description"] = description return len(items) - 1 # Skip