From 0bceb219e67751b6b3d08dfa1526efd07c84e32d Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Wed, 10 Jun 2026 21:04:22 +0530 Subject: [PATCH] opentui(v6): standardize fuzzy search on fuzzysort (adapter keeps our API) --- ui-opentui/package-lock.json | 7 ++ ui-opentui/package.json | 1 + ui-opentui/src/logic/fuzzy.ts | 141 ++++++++++---------------- ui-opentui/src/test/fuzzy.test.ts | 160 ++++++++++++++++++++++-------- 4 files changed, 184 insertions(+), 125 deletions(-) diff --git a/ui-opentui/package-lock.json b/ui-opentui/package-lock.json index d12cdf15525..c074064f0c6 100644 --- a/ui-opentui/package-lock.json +++ b/ui-opentui/package-lock.json @@ -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", diff --git a/ui-opentui/package.json b/ui-opentui/package.json index b5c339b054f..aa4af6bef91 100644 --- a/ui-opentui/package.json +++ b/ui-opentui/package.json @@ -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": { diff --git a/ui-opentui/src/logic/fuzzy.ts b/ui-opentui/src/logic/fuzzy.ts index e3fed6bd3c3..38ed4d04392 100644 --- a/ui-opentui/src/logic/fuzzy.ts +++ b/ui-opentui/src/logic/fuzzy.ts @@ -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 { + 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(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[] = 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) => 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. diff --git a/ui-opentui/src/test/fuzzy.test.ts b/ui-opentui/src/test/fuzzy.test.ts index 5a0e2bf600e..09ea1b8fe9f 100644 --- a/ui-opentui/src/test/fuzzy.test.ts +++ b/ui-opentui/src/test/fuzzy.test.ts @@ -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([]) }) })