opentui(v6): standardize fuzzy search on fuzzysort (adapter keeps our API)

This commit is contained in:
alt-glitch 2026-06-10 21:04:22 +05:30
parent c9c6cfc0ee
commit 8c3060342f
4 changed files with 184 additions and 125 deletions

View file

@ -12,6 +12,7 @@
"@opentui/keymap": "0.4.0",
"@opentui/solid": "0.4.0",
"effect": "4.0.0-beta.78",
"fuzzysort": "^3.1.0",
"solid-js": "1.9.12"
},
"devDependencies": {
@ -3118,6 +3119,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/fuzzysort": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/fuzzysort/-/fuzzysort-3.1.0.tgz",
"integrity": "sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ==",
"license": "MIT"
},
"node_modules/gensync": {
"version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",

View file

@ -21,6 +21,7 @@
"@opentui/keymap": "0.4.0",
"@opentui/solid": "0.4.0",
"effect": "4.0.0-beta.78",
"fuzzysort": "^3.1.0",
"solid-js": "1.9.12"
},
"devDependencies": {

View file

@ -1,16 +1,26 @@
/**
* fuzzy.ts pure fuzzy filtering + grouped presentation for picker overlays
* (Epic 7 model picker v2). No deps: a ~subsequence scorer in the spirit of
* opencode's fuzzysort usage (multi-key scoring, title weighted 2×, grouped
* headers) at the scale we need ( a few hundred catalog rows).
* (Epic 7 model picker v2; resume-session picker; skills hub). Matching/ranking
* is delegated to `fuzzysort` (the library opencode uses in production, see its
* dialog-select.tsx) through a thin adapter that preserves this module's API:
* call sites pass weighted `FuzzyField[]` haystacks and get back a ranked list.
*
* Scoring model (per term, per field): a case-insensitive subsequence match
* where consecutive runs, string-prefix and word-boundary starts score high and
* gaps/late starts are penalized so for `son`: `sonnet` (prefix) >
* `claude-sonnet` (boundary) > `meson` (scattered). A query is split on
* whitespace; EVERY term must match at least one field (best field wins, its
* weight applied), so `anthropic son` works across provider+model fields.
* Adapter semantics on top of fuzzysort:
* - Multi-key scoring à la opencode: each field is a fuzzysort key; the final
* score is the weight-multiplied SUM of per-key scores (label conventionally
* ×2, opencode's `r[0].score * 2 + r[1].score`), so label hits outrank
* equal-quality group/slug hits.
* - Multi-term AND (a feature of the old hand-rolled scorer that fuzzysort
* lacks natively): the query is whitespace-split and fuzzysort runs once per
* term over the progressively-filtered pool every term must match at least
* one field; per-term scores accumulate. Chosen over a joined single needle
* because it keeps `anthropic son` / `copilot son` matching ACROSS fields.
* - Empty/blank query all items in catalog order (fuzzysort returns nothing
* for an empty needle; the old all-rows behavior is preserved here).
* - Equal final scores keep catalog order (fuzzysort's sort is not stable; the
* adapter re-sorts with the original index as tie-break).
*/
import fuzzysort from 'fuzzysort'
/** One searchable field of an item (e.g. model id ×2, provider slug, lab name). */
export interface FuzzyField {
@ -19,91 +29,50 @@ export interface FuzzyField {
weight?: number
}
/** Word-boundary characters inside catalog-ish ids/names. */
const BOUNDARY = new Set([' ', '-', '_', '.', '/', ':', '@', '(', ')'])
/** Cap on alternative start positions tried for the first term char. */
const MAX_STARTS = 8
/** Greedy subsequence score from a fixed start index; null when it can't match. */
function scoreFrom(term: string, hay: string, start: number): number | null {
let score = 0
let prev = -1
let from = start
for (let qi = 0; qi < term.length; qi++) {
const idx = hay.indexOf(term.charAt(qi), from)
if (idx === -1) return null
let charScore = 1
if (prev !== -1 && idx === prev + 1) charScore += 3 // consecutive run
if (idx === 0)
charScore += 6 // string prefix
else if (BOUNDARY.has(hay.charAt(idx - 1))) charScore += 4 // word boundary
if (prev !== -1 && idx > prev + 1) charScore -= Math.min(idx - prev - 1, 3) // gap
if (prev === -1) charScore -= Math.min(idx, 4) // late start
score += charScore
prev = idx
from = idx + 1
}
return score
}
/**
* Score one term against one text. Null = the term is not a subsequence.
* Greedy from the first occurrence is order-sensitive (`son` in `saturn-sonnet`
* must anchor at the second `s`), so try each occurrence of the first term char
* (capped) and keep the best.
*/
export function scoreTerm(term: string, text: string): number | null {
const needle = term.toLowerCase()
if (!needle) return 0
const hay = text.toLowerCase()
let best: number | null = null
let start = hay.indexOf(needle.charAt(0))
for (let tries = 0; start !== -1 && tries < MAX_STARTS; tries++) {
const s = scoreFrom(needle, hay, start)
if (s !== null && (best === null || s > best)) best = s
start = hay.indexOf(needle.charAt(0), start + 1)
}
return best
}
/**
* Score a whitespace-split query against an item's fields. Every term must
* match at least one field; each term contributes its best weighted field
* score. Empty/blank query scores 0 (matches everything catalog order).
*/
export function scoreFields(query: string, fields: readonly FuzzyField[]): number | null {
const terms = query.trim().split(/\s+/).filter(Boolean)
if (!terms.length) return 0
let total = 0
for (const term of terms) {
let best: number | null = null
for (const field of fields) {
const s = scoreTerm(term, field.text)
if (s === null) continue
const weighted = s * (field.weight ?? 1)
if (best === null || weighted > best) best = weighted
}
if (best === null) return null
total += best
}
return total
/** Pool entry: the item plus its precomputed fields, catalog position and the
* per-term accumulated score. */
interface Entry<T> {
item: T
at: number
fields: FuzzyField[]
total: number
}
/**
* Filter + rank items by query. Empty query the items in catalog order;
* otherwise matches sorted by score (descending), ties keeping catalog order.
* Every whitespace-split term must fuzzy-match at least one field.
*/
export function fuzzyFilter<T>(query: string, items: readonly T[], fieldsOf: (item: T) => FuzzyField[]): T[] {
if (!query.trim()) return [...items]
const scored: Array<{ item: T; score: number; at: number }> = []
for (let i = 0; i < items.length; i++) {
const item = items[i] as T
const score = scoreFields(query, fieldsOf(item))
if (score !== null) scored.push({ at: i, item, score })
const terms = query.trim().split(/\s+/).filter(Boolean)
if (!terms.length) return [...items]
let pool: Entry<T>[] = items.map((item, at) => ({ at, fields: fieldsOf(item), item, total: 0 }))
// Items may carry different field counts (description/haystacks optional):
// one key per field slot, missing slots read as '' (never match).
const keyCount = pool.reduce((max, e) => Math.max(max, e.fields.length), 0)
const keys = Array.from({ length: keyCount }, (_, i) => (e: Entry<T>) => e.fields[i]?.text ?? '')
for (const term of terms) {
const results = fuzzysort.go(term, pool, {
keys,
// Weighted sum of per-key scores (unmatched keys score 0). Inclusion is
// decided by fuzzysort (≥1 key must match); this only ranks.
scoreFn: r => {
let sum = 0
for (let i = 0; i < r.length; i++) sum += (r[i]?.score ?? 0) * (r.obj.fields[i]?.weight ?? 1)
return sum
}
})
if (!results.length) return []
pool = results.map(r => {
r.obj.total += r.score
return r.obj
})
}
scored.sort((a, b) => b.score - a.score || a.at - b.at)
return scored.map(s => s.item)
pool.sort((a, b) => b.total - a.total || a.at - b.at)
return pool.map(e => e.item)
}
/** A render row of a grouped picker: a non-selectable group header or an item.

View file

@ -1,62 +1,70 @@
/**
* fuzzy.ts tests (Epic 7) the pure scorer + filter + grouped-rows helpers
* behind the model picker v2: subsequence matching, ranking (prefix >
* word-boundary > scattered), multi-field (provider/model/lab), empty query =
* catalog order, no-match = empty, header rows non-selectable, and the
* flat arrow-traversal order across groups.
* fuzzy.ts tests (Epic 7) the fuzzysort-backed filter + grouped-rows helpers
* behind the picker overlays: subsequence matching, ranking (prefix >
* word-boundary > scattered), multi-field (provider/model/lab), multi-term AND,
* empty query = catalog order, no-match = empty, header rows non-selectable,
* the flat arrow-traversal order across groups, and long/messy haystacks shaped
* like the resume-session picker (titles + cwd paths + sources).
*
* Matching/ranking comes from `fuzzysort` via the adapter in logic/fuzzy.ts
* all matching assertions go through the public `fuzzyFilter` (the old
* hand-rolled scorer internals `scoreTerm`/`scoreFields` are gone).
*/
import { describe, expect, test } from 'vitest'
import { buildPickerRows, fuzzyFilter, scoreFields, scoreTerm, visibleRows, type FuzzyField } from '../logic/fuzzy.ts'
import { buildPickerRows, fuzzyFilter, visibleRows, type FuzzyField } from '../logic/fuzzy.ts'
describe('scoreTerm — subsequence matching', () => {
test('matches subsequences (case-insensitive), null when not a subsequence', () => {
expect(scoreTerm('son', 'claude-sonnet-4')).not.toBeNull()
expect(scoreTerm('son4', 'claude-sonnet-4')).not.toBeNull() // the complaint's example
expect(scoreTerm('SON', 'claude-sonnet-4')).not.toBeNull()
expect(scoreTerm('xyz', 'claude-sonnet-4')).toBeNull()
expect(scoreTerm('sonn5', 'claude-sonnet-4')).toBeNull() // 5 not present after sonn
expect(scoreTerm('', 'anything')).toBe(0) // empty term matches everything
/** Filter plain labels (the single-field degenerate case). */
const byLabel = (query: string, labels: string[]): string[] => fuzzyFilter(query, labels, l => [{ text: l, weight: 2 }])
describe('fuzzyFilter — subsequence matching', () => {
test('matches subsequences (case-insensitive), drops non-subsequences', () => {
expect(byLabel('son', ['claude-sonnet-4'])).toEqual(['claude-sonnet-4'])
expect(byLabel('son4', ['claude-sonnet-4'])).toEqual(['claude-sonnet-4']) // the complaint's example
expect(byLabel('SON', ['claude-sonnet-4'])).toEqual(['claude-sonnet-4'])
expect(byLabel('xyz', ['claude-sonnet-4'])).toEqual([])
expect(byLabel('sonn5', ['claude-sonnet-4'])).toEqual([]) // 5 not present after sonn
expect(byLabel('', ['anything'])).toEqual(['anything']) // empty query matches everything
})
test('ranking: prefix > word-boundary > scattered', () => {
const prefix = scoreTerm('son', 'sonnet')!
const boundary = scoreTerm('son', 'claude-sonnet')!
const scattered = scoreTerm('son', 'meson')!
expect(prefix).toBeGreaterThan(boundary)
expect(boundary).toBeGreaterThan(scattered)
// catalog order is deliberately worst-first; ranking must invert it.
expect(byLabel('son', ['meson', 'claude-sonnet', 'sonnet'])).toEqual(['sonnet', 'claude-sonnet', 'meson'])
})
test('anchors at the BEST occurrence, not greedily at the first', () => {
// greedy-from-first-char would match s@0 then o/n far away; the boundary
// anchor at the second `s` (start of "sonnet") must win.
expect(scoreTerm('son', 'saturn-sonnet')!).toBeGreaterThanOrEqual(scoreTerm('son', 'claude-sonnet')!)
// greedy-from-first-char would match saturn's s@0 then o/n far away; the
// boundary anchor at the second `s` (start of "sonnet") must win over a
// genuinely scattered match.
expect(byLabel('son', ['meson', 'saturn-sonnet'])).toEqual(['saturn-sonnet', 'meson'])
})
})
describe('scoreFields — multi-field, multi-term', () => {
const fields: FuzzyField[] = [
{ text: 'claude-sonnet-4', weight: 2 }, // model id (label ×2)
{ text: 'anthropic' }, // provider slug
{ text: 'Anthropic' } // lab/display name
describe('fuzzyFilter — multi-field, multi-term', () => {
const row = { lab: 'Anthropic', label: 'claude-sonnet-4', provider: 'anthropic' }
const fieldsOf = (r: typeof row): FuzzyField[] => [
{ text: r.label, weight: 2 },
{ text: r.provider },
{ text: r.lab }
]
test('a term may match ANY field (provider/model/lab)', () => {
expect(scoreFields('son4', fields)).not.toBeNull() // via the model id
expect(scoreFields('anthro', fields)).not.toBeNull() // via the provider
expect(scoreFields('nope', fields)).toBeNull()
expect(fuzzyFilter('son4', [row], fieldsOf)).toHaveLength(1) // via the model id
expect(fuzzyFilter('anthro', [row], fieldsOf)).toHaveLength(1) // via the provider
expect(fuzzyFilter('nope', [row], fieldsOf)).toHaveLength(0)
})
test('every whitespace term must match some field (anthropic son works)', () => {
expect(scoreFields('anthropic son', fields)).not.toBeNull()
expect(scoreFields('anthropic zzz', fields)).toBeNull()
expect(fuzzyFilter('anthropic son', [row], fieldsOf)).toHaveLength(1)
expect(fuzzyFilter('anthropic zzz', [row], fieldsOf)).toHaveLength(0)
})
test('label matches outrank same-quality group matches (weight 2×)', () => {
const labelHit = scoreFields('claude', fields)!
const providerHit = scoreFields('claude', [{ text: 'other-model', weight: 2 }, { text: 'claude' }])
expect(providerHit).not.toBeNull()
expect(labelHit).toBeGreaterThan(providerHit!)
test('label matches outrank same-quality secondary-field matches (weight 2×)', () => {
const labelHit = { label: 'claude-sonnet-4', provider: 'anthropic' }
const providerHit = { label: 'other-model', provider: 'claude' }
const fields = (r: typeof labelHit): FuzzyField[] => [{ text: r.label, weight: 2 }, { text: r.provider }]
// providerHit comes FIRST in catalog order; the ×2 label hit must beat it.
expect(fuzzyFilter('claude', [providerHit, labelHit], fields)[0]).toBe(labelHit)
})
})
@ -92,9 +100,83 @@ describe('fuzzyFilter', () => {
expect(hits.map(h => h.label)).toContain('gpt-5')
})
test('ties keep catalog order (stable)', () => {
test('equal-quality prefix matches rank the shorter label first; true ties keep catalog order', () => {
// DELIBERATE expectation change with the fuzzysort adapter: the old scorer
// scored both `claude-*` labels identically and fell back to catalog order
// (sonnet first). fuzzysort additionally rewards how much of the target the
// match covers, so the SHORTER claude-opus-4 now outranks claude-sonnet-4 —
// better for a user: the closer-to-exact label surfaces first.
const hits = fuzzyFilter('claude', CATALOG, rowFields)
expect(hits.map(h => h.label)).toEqual(['claude-sonnet-4', 'claude-opus-4'])
expect(hits.map(h => h.label)).toEqual(['claude-opus-4', 'claude-sonnet-4'])
// genuinely equal scores (same-length labels, same match shape) stay stable
// in catalog order — fuzzysort's own sort is unstable; the adapter re-ties.
expect(byLabel('son', ['claude-sonnet', 'saturn-sonnet'])).toEqual(['claude-sonnet', 'saturn-sonnet'])
expect(byLabel('son', ['saturn-sonnet', 'claude-sonnet'])).toEqual(['saturn-sonnet', 'claude-sonnet'])
})
})
/** Rows shaped like the upcoming resume-session picker: long human titles,
* deep cwd paths and a source tag as secondary haystacks. */
interface Session {
title: string
cwd: string
source: string
}
const SESSIONS: Session[] = [
{
cwd: '/home/daimon/github/worktrees/hermes-agent/lively-thrush',
source: 'tui',
title: 'Adopt OpenTUI paradigm for UI implementation'
},
{ cwd: '/home/daimon/github/opentui', source: 'tui', title: 'Fix memory leak in Ink renderer' },
{ cwd: '/home/daimon/github/daimon-nous', source: 'discord', title: 'Triage daimon-nous webhook reviewer pipeline' },
{ cwd: '/home/daimon/github/worktrees/hermes-agent/quiet-finch', source: 'tui', title: 'Parser cleanup pass' },
{ cwd: '/home/daimon/notes', source: 'telegram', title: 'Resume-session picker design notes' }
]
const sessionFields = (s: Session): FuzzyField[] => [{ text: s.title, weight: 2 }, { text: s.cwd }, { text: s.source }]
describe('fuzzyFilter — long/messy haystacks (resume-session shape)', () => {
test('`opentui par` ANDs across one long title (word-boundary terms)', () => {
const hits = fuzzyFilter('opentui par', SESSIONS, sessionFields)
expect(hits.map(h => h.title)).toEqual(['Adopt OpenTUI paradigm for UI implementation'])
})
test('`lively` matches via the cwd-path haystack alone', () => {
const hits = fuzzyFilter('lively', SESSIONS, sessionFields)
expect(hits.map(h => h.title)).toEqual(['Adopt OpenTUI paradigm for UI implementation'])
})
test('`worktr herm` ANDs across deep path segments, keeps ONLY worktree sessions', () => {
const hits = fuzzyFilter('worktr herm', SESSIONS, sessionFields)
expect(hits.map(h => h.title).sort()).toEqual([
'Adopt OpenTUI paradigm for UI implementation',
'Parser cleanup pass'
])
})
test('a title hit outranks a path-only hit for the same query', () => {
// 'Fix memory leak…' matches `opentui` ONLY via its cwd; the title hit
// (label ×2) must come first even though the path row is earlier in catalog.
const hits = fuzzyFilter('opentui', SESSIONS, sessionFields)
expect(hits.map(h => h.title)).toEqual([
'Adopt OpenTUI paradigm for UI implementation',
'Fix memory leak in Ink renderer'
])
})
test('a noisy shared path prefix does not drown a title match', () => {
// every github row shares /home/daimon/…; the title containing `daimon`
// (the daimon-nous session) must outrank the rows matching only via cwd.
const hits = fuzzyFilter('daimon', SESSIONS, sessionFields)
expect(hits[0]?.title).toBe('Triage daimon-nous webhook reviewer pipeline')
expect(hits.length).toBe(SESSIONS.length) // all rows match somewhere (path/source)
})
test('multi-term over title words: `resume pick` pins the picker-design session; junk → empty', () => {
expect(fuzzyFilter('resume pick', SESSIONS, sessionFields).map(h => h.title)).toEqual([
'Resume-session picker design notes'
])
expect(fuzzyFilter('github.zzz', SESSIONS, sessionFields)).toEqual([])
})
})