afcd5ea86a
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.
46 lines
2.0 KiB
TypeScript
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))
|
|
}
|