mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-11 13:41:53 +00:00
opentui(v6): standardize fuzzy search on fuzzysort (adapter keeps our API)
This commit is contained in:
parent
c9c6cfc0ee
commit
8c3060342f
4 changed files with 184 additions and 125 deletions
7
ui-opentui/package-lock.json
generated
7
ui-opentui/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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([])
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue