Files
hermes-agent/apps/desktop/src/lib/text.ts
T
DavidMetcalfe afcd5ea86a fix(desktop): fold separators in model search so filters and highlights agree
Model ids use hyphens/underscores, display names use spaces, versions use
dots. The pickers' filter haystacks contained both the raw id and the
display name, but the highlight only ever saw the display label — so
'qwen3.8-flash' revealed the row (via the id segment) while lighting up
nothing, and model-picker.tsx had the inverse polarity (spaces matched,
hyphens didn't highlight). This violated HighlightMatches' own documented
contract: the query must mirror the filter's semantics or the emphasis
lies.

One length-preserving searchFold ([-_.] -> space, 1 char in / 1 char out)
now runs on both sides of every model-search filter AND inside
HighlightMatches' range finder. Length preservation keeps mark ranges
valid against the original text, so <mark> rendering is untouched. The
fold is a per-character substitution applied to both sides, so any query
that matched before still matches — only coverage grows.

- lib/text.ts: searchFold + foldIncludes, the one matcher for all pickers
- highlight-matches.tsx: ranges computed on folded text, marks slice original
- model-catalog-menu.tsx: family, MoA, and download haystacks use foldIncludes
- model-visibility-dialog.tsx, model-picker.tsx: same (fixes inverse polarity)
- dropdown-menu.tsx: DropdownMenuSearch sets spellCheck={false} — squiggles
  under model ids are noise; composer/settings inputs already disable it

Tests: fold primitives (equivalence, 1:1 length, superset), highlighter
fold behavior + index fidelity, end-to-end menu behavior (hyphen query
marks the spaced label; space query finds the hyphenated id without
over-matching), and an updated hidden-model-search test whose id-style
query now legitimately highlights.
2026-09-07 06:21:44 -07:00

46 lines
2.0 KiB
TypeScript

// Canonical text micro-helpers. Do not redefine these per-page.
export const asText = (v: unknown): string => (typeof v === 'string' ? v : v == null ? '' : String(v))
export const includesQuery = (v: unknown, q: string) => asText(v).toLowerCase().includes(q)
export const prettyName = (v: string) => v.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
/** Search-key normalization: the exact `value.trim().toLowerCase()` idiom that
* was hand-written at ~30 filter/lookup sites. */
export const normalize = (v: unknown): string => asText(v).trim().toLowerCase()
/** Uppercase the first character, leave the rest. Matches the
* `s.charAt(0).toUpperCase() + s.slice(1)` idiom (empty-safe). */
export const capitalize = (v: string): string => (v ? v.charAt(0).toUpperCase() + v.slice(1) : v)
/** First non-empty string among `keys`, trimmed. For reading tool args and
* results, where the key carrying the interesting value varies by tool. */
export const firstStringField = (record: Record<string, unknown>, keys: readonly string[]): string => {
for (const key of keys) {
const value = record[key]
if (typeof value === 'string' && value.trim()) {
return value.trim()
}
}
return ''
}
/** Search-equivalence fold: separators (`-`, `_`, `.`) and case stop matter —
* `qwen3.8-flash`, `Qwen3.8 Flash` and `qwen3 8_flash` all fold to the same
* string. One char in, one char out (length preserved), so highlight ranges
* computed on folded text index the ORIGINAL text unchanged. Model ids use
* hyphens, display names use spaces, quants use underscores — a picker search
* must not care. Filter and highlight MUST call this on BOTH sides; a fold on
* only one is what made hyphen queries match without ever highlighting. */
export function searchFold(v: unknown): string {
return asText(v).toLowerCase().replace(/[-_.]/g, ' ')
}
/** `searchFold` + substring: the one matcher every searchable picker uses. */
export function foldIncludes(text: unknown, query: unknown): boolean {
return searchFold(text).includes(searchFold(query))
}