opentui(v6): standardize fuzzy search on fuzzysort (adapter keeps our API)
This commit is contained in:
parent
c9c6cfc0ee
commit
8c3060342f
7
ui-opentui/package-lock.json
generated
7
ui-opentui/package-lock.json
generated
@ -12,6 +12,7 @@
|
|||||||
"@opentui/keymap": "0.4.0",
|
"@opentui/keymap": "0.4.0",
|
||||||
"@opentui/solid": "0.4.0",
|
"@opentui/solid": "0.4.0",
|
||||||
"effect": "4.0.0-beta.78",
|
"effect": "4.0.0-beta.78",
|
||||||
|
"fuzzysort": "^3.1.0",
|
||||||
"solid-js": "1.9.12"
|
"solid-js": "1.9.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@ -3118,6 +3119,12 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/gensync": {
|
||||||
"version": "1.0.0-beta.2",
|
"version": "1.0.0-beta.2",
|
||||||
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
|
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
|
||||||
|
|||||||
@ -21,6 +21,7 @@
|
|||||||
"@opentui/keymap": "0.4.0",
|
"@opentui/keymap": "0.4.0",
|
||||||
"@opentui/solid": "0.4.0",
|
"@opentui/solid": "0.4.0",
|
||||||
"effect": "4.0.0-beta.78",
|
"effect": "4.0.0-beta.78",
|
||||||
|
"fuzzysort": "^3.1.0",
|
||||||
"solid-js": "1.9.12"
|
"solid-js": "1.9.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@ -1,16 +1,26 @@
|
|||||||
/**
|
/**
|
||||||
* fuzzy.ts — pure fuzzy filtering + grouped presentation for picker overlays
|
* fuzzy.ts — pure fuzzy filtering + grouped presentation for picker overlays
|
||||||
* (Epic 7 model picker v2). No deps: a ~subsequence scorer in the spirit of
|
* (Epic 7 model picker v2; resume-session picker; skills hub). Matching/ranking
|
||||||
* opencode's fuzzysort usage (multi-key scoring, title weighted 2×, grouped
|
* is delegated to `fuzzysort` (the library opencode uses in production, see its
|
||||||
* headers) at the scale we need (≤ a few hundred catalog rows).
|
* 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
|
* Adapter semantics on top of fuzzysort:
|
||||||
* where consecutive runs, string-prefix and word-boundary starts score high and
|
* - Multi-key scoring à la opencode: each field is a fuzzysort key; the final
|
||||||
* gaps/late starts are penalized — so for `son`: `sonnet` (prefix) >
|
* score is the weight-multiplied SUM of per-key scores (label conventionally
|
||||||
* `claude-sonnet` (boundary) > `meson` (scattered). A query is split on
|
* ×2, opencode's `r[0].score * 2 + r[1].score`), so label hits outrank
|
||||||
* whitespace; EVERY term must match at least one field (best field wins, its
|
* equal-quality group/slug hits.
|
||||||
* weight applied), so `anthropic son` works across provider+model fields.
|
* - 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). */
|
/** One searchable field of an item (e.g. model id ×2, provider slug, lab name). */
|
||||||
export interface FuzzyField {
|
export interface FuzzyField {
|
||||||
@ -19,91 +29,50 @@ export interface FuzzyField {
|
|||||||
weight?: number
|
weight?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Word-boundary characters inside catalog-ish ids/names. */
|
/** Pool entry: the item plus its precomputed fields, catalog position and the
|
||||||
const BOUNDARY = new Set([' ', '-', '_', '.', '/', ':', '@', '(', ')'])
|
* per-term accumulated score. */
|
||||||
|
interface Entry<T> {
|
||||||
/** Cap on alternative start positions tried for the first term char. */
|
item: T
|
||||||
const MAX_STARTS = 8
|
at: number
|
||||||
|
fields: FuzzyField[]
|
||||||
/** Greedy subsequence score from a fixed start index; null when it can't match. */
|
total: number
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Filter + rank items by query. Empty query → the items in catalog order;
|
* Filter + rank items by query. Empty query → the items in catalog order;
|
||||||
* otherwise matches sorted by score (descending), ties keeping 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[] {
|
export function fuzzyFilter<T>(query: string, items: readonly T[], fieldsOf: (item: T) => FuzzyField[]): T[] {
|
||||||
if (!query.trim()) return [...items]
|
const terms = query.trim().split(/\s+/).filter(Boolean)
|
||||||
const scored: Array<{ item: T; score: number; at: number }> = []
|
if (!terms.length) return [...items]
|
||||||
for (let i = 0; i < items.length; i++) {
|
|
||||||
const item = items[i] as T
|
let pool: Entry<T>[] = items.map((item, at) => ({ at, fields: fieldsOf(item), item, total: 0 }))
|
||||||
const score = scoreFields(query, fieldsOf(item))
|
// Items may carry different field counts (description/haystacks optional):
|
||||||
if (score !== null) scored.push({ at: i, item, score })
|
// 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
|
||||||
}
|
}
|
||||||
scored.sort((a, b) => b.score - a.score || a.at - b.at)
|
})
|
||||||
return scored.map(s => s.item)
|
if (!results.length) return []
|
||||||
|
pool = results.map(r => {
|
||||||
|
r.obj.total += r.score
|
||||||
|
return r.obj
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
/** 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
|
* fuzzy.ts tests (Epic 7) — the fuzzysort-backed filter + grouped-rows helpers
|
||||||
* behind the model picker v2: subsequence matching, ranking (prefix >
|
* behind the picker overlays: subsequence matching, ranking (prefix >
|
||||||
* word-boundary > scattered), multi-field (provider/model/lab), empty query =
|
* word-boundary > scattered), multi-field (provider/model/lab), multi-term AND,
|
||||||
* catalog order, no-match = empty, header rows non-selectable, and the
|
* empty query = catalog order, no-match = empty, header rows non-selectable,
|
||||||
* flat arrow-traversal order across groups.
|
* 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 { 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', () => {
|
/** Filter plain labels (the single-field degenerate case). */
|
||||||
test('matches subsequences (case-insensitive), null when not a subsequence', () => {
|
const byLabel = (query: string, labels: string[]): string[] => fuzzyFilter(query, labels, l => [{ text: l, weight: 2 }])
|
||||||
expect(scoreTerm('son', 'claude-sonnet-4')).not.toBeNull()
|
|
||||||
expect(scoreTerm('son4', 'claude-sonnet-4')).not.toBeNull() // the complaint's example
|
describe('fuzzyFilter — subsequence matching', () => {
|
||||||
expect(scoreTerm('SON', 'claude-sonnet-4')).not.toBeNull()
|
test('matches subsequences (case-insensitive), drops non-subsequences', () => {
|
||||||
expect(scoreTerm('xyz', 'claude-sonnet-4')).toBeNull()
|
expect(byLabel('son', ['claude-sonnet-4'])).toEqual(['claude-sonnet-4'])
|
||||||
expect(scoreTerm('sonn5', 'claude-sonnet-4')).toBeNull() // 5 not present after sonn
|
expect(byLabel('son4', ['claude-sonnet-4'])).toEqual(['claude-sonnet-4']) // the complaint's example
|
||||||
expect(scoreTerm('', 'anything')).toBe(0) // empty term matches everything
|
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', () => {
|
test('ranking: prefix > word-boundary > scattered', () => {
|
||||||
const prefix = scoreTerm('son', 'sonnet')!
|
// catalog order is deliberately worst-first; ranking must invert it.
|
||||||
const boundary = scoreTerm('son', 'claude-sonnet')!
|
expect(byLabel('son', ['meson', 'claude-sonnet', 'sonnet'])).toEqual(['sonnet', 'claude-sonnet', 'meson'])
|
||||||
const scattered = scoreTerm('son', 'meson')!
|
|
||||||
expect(prefix).toBeGreaterThan(boundary)
|
|
||||||
expect(boundary).toBeGreaterThan(scattered)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('anchors at the BEST occurrence, not greedily at the first', () => {
|
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
|
// greedy-from-first-char would match saturn's s@0 then o/n far away; the
|
||||||
// anchor at the second `s` (start of "sonnet") must win.
|
// boundary anchor at the second `s` (start of "sonnet") must win over a
|
||||||
expect(scoreTerm('son', 'saturn-sonnet')!).toBeGreaterThanOrEqual(scoreTerm('son', 'claude-sonnet')!)
|
// genuinely scattered match.
|
||||||
|
expect(byLabel('son', ['meson', 'saturn-sonnet'])).toEqual(['saturn-sonnet', 'meson'])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('scoreFields — multi-field, multi-term', () => {
|
describe('fuzzyFilter — multi-field, multi-term', () => {
|
||||||
const fields: FuzzyField[] = [
|
const row = { lab: 'Anthropic', label: 'claude-sonnet-4', provider: 'anthropic' }
|
||||||
{ text: 'claude-sonnet-4', weight: 2 }, // model id (label ×2)
|
const fieldsOf = (r: typeof row): FuzzyField[] => [
|
||||||
{ text: 'anthropic' }, // provider slug
|
{ text: r.label, weight: 2 },
|
||||||
{ text: 'Anthropic' } // lab/display name
|
{ text: r.provider },
|
||||||
|
{ text: r.lab }
|
||||||
]
|
]
|
||||||
|
|
||||||
test('a term may match ANY field (provider/model/lab)', () => {
|
test('a term may match ANY field (provider/model/lab)', () => {
|
||||||
expect(scoreFields('son4', fields)).not.toBeNull() // via the model id
|
expect(fuzzyFilter('son4', [row], fieldsOf)).toHaveLength(1) // via the model id
|
||||||
expect(scoreFields('anthro', fields)).not.toBeNull() // via the provider
|
expect(fuzzyFilter('anthro', [row], fieldsOf)).toHaveLength(1) // via the provider
|
||||||
expect(scoreFields('nope', fields)).toBeNull()
|
expect(fuzzyFilter('nope', [row], fieldsOf)).toHaveLength(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('every whitespace term must match some field (anthropic son works)', () => {
|
test('every whitespace term must match some field (anthropic son works)', () => {
|
||||||
expect(scoreFields('anthropic son', fields)).not.toBeNull()
|
expect(fuzzyFilter('anthropic son', [row], fieldsOf)).toHaveLength(1)
|
||||||
expect(scoreFields('anthropic zzz', fields)).toBeNull()
|
expect(fuzzyFilter('anthropic zzz', [row], fieldsOf)).toHaveLength(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('label matches outrank same-quality group matches (weight 2×)', () => {
|
test('label matches outrank same-quality secondary-field matches (weight 2×)', () => {
|
||||||
const labelHit = scoreFields('claude', fields)!
|
const labelHit = { label: 'claude-sonnet-4', provider: 'anthropic' }
|
||||||
const providerHit = scoreFields('claude', [{ text: 'other-model', weight: 2 }, { text: 'claude' }])
|
const providerHit = { label: 'other-model', provider: 'claude' }
|
||||||
expect(providerHit).not.toBeNull()
|
const fields = (r: typeof labelHit): FuzzyField[] => [{ text: r.label, weight: 2 }, { text: r.provider }]
|
||||||
expect(labelHit).toBeGreaterThan(providerHit!)
|
// 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')
|
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)
|
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…
x
Reference in New Issue
Block a user