feat(desktop): highlight matched letters in searchable pickers

Typing in the model picker dialog, edit-models dialog, or command
palette now marks WHY each row matched: every occurrence of the query
(per-term for the palette's AND matcher) renders as an accent-colored
semantic <mark>. cmdk's scorer exposes no match ranges, so the shared
HighlightMatches primitive mirrors each surface's own filter semantics
instead: literal substring for the pickers, split-on-whitespace terms
with merged overlapping ranges for the palette.
This commit is contained in:
Brooklyn Nicholson 2026-07-29 21:26:10 -05:00
parent 36f885573c
commit 611c6aab76
5 changed files with 155 additions and 5 deletions

View file

@ -7,6 +7,7 @@ import { useNavigate } from 'react-router-dom'
import { HUD_HEADING, HUD_ITEM, HUD_POSITION, HUD_SURFACE, HUD_TEXT } from '@/app/floating-hud'
import { setTerminalTakeover } from '@/app/right-sidebar/store'
import { Command, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
import { HighlightMatches } from '@/components/ui/highlight-matches'
import { KbdCombo } from '@/components/ui/kbd'
import { getHermesConfigRecord, listAllProfileSessions } from '@/hermes'
import { useI18n } from '@/i18n'
@ -233,12 +234,14 @@ const PaletteRow = memo(function PaletteRow({
bindings,
item,
onSelectMods,
onSelectItem
onSelectItem,
search
}: {
bindings: Record<string, string[]>
item: PaletteItem
onSelectMods: (event: { ctrlKey: boolean; metaKey: boolean; shiftKey: boolean }) => void
onSelectItem: (item: PaletteItem) => void
search: string
}) {
const Icon = item.icon
const combo = item.action ? bindings[item.action]?.[0] : undefined
@ -252,7 +255,11 @@ const PaletteRow = memo(function PaletteRow({
value={paletteValue(item)}
>
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
<span className="truncate">{item.label}</span>
<span className="truncate">
{/* Same per-term split as scoreItem's AND matcher, so the emphasis
shows exactly which words earned the row its rank. */}
<HighlightMatches query={search.split(/\s+/)} text={item.label} />
</span>
{item.detail && <span className="truncate text-muted-foreground/80">{item.detail}</span>}
{combo && <KbdCombo className="ml-auto opacity-55" combo={combo} size="sm" />}
{item.to && <ChevronRight className={cn('size-3.5 shrink-0 text-muted-foreground/70', !combo && 'ml-auto')} />}
@ -1078,6 +1085,7 @@ export function CommandPalette() {
key={item.id}
onSelectItem={handleSelect}
onSelectMods={noteSelectMods}
search={search}
/>
))}
</CommandGroup>

View file

@ -16,6 +16,7 @@ import { InlineNotice } from './notifications'
import { Button } from './ui/button'
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from './ui/command'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from './ui/dialog'
import { HighlightMatches } from './ui/highlight-matches'
import { Skeleton } from './ui/skeleton'
interface ModelPickerDialogProps {
@ -225,7 +226,9 @@ function ModelResults({
}}
value={`${provider.slug}:${model}`}
>
<span className="min-w-0 flex-1 truncate">{model}</span>
<span className="min-w-0 flex-1 truncate">
<HighlightMatches query={search} text={model} />
</span>
{locked && (
<span className="shrink-0 text-[0.62rem] uppercase tracking-wide opacity-80">{copy.pro}</span>
)}

View file

@ -7,6 +7,7 @@ import { Checkbox } from '@/components/ui/checkbox'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
import { HighlightMatches } from '@/components/ui/highlight-matches'
import { Switch } from '@/components/ui/switch'
import type { HermesGateway } from '@/hermes'
import { useI18n } from '@/i18n'
@ -125,7 +126,9 @@ export function ModelVisibilityDialog({
onClick={() => toggleCollapsedProvider(provider.slug)}
type="button"
>
<span className="min-w-0 truncate">{provider.name}</span>
<span className="min-w-0 truncate">
<HighlightMatches query={search} text={provider.name} />
</span>
<DisclosureCaret
className="shrink-0 opacity-0 transition group-hover/label:opacity-100"
open={!collapsed}
@ -145,7 +148,7 @@ export function ModelVisibilityDialog({
key={key}
>
<span className="min-w-0 flex-1 truncate">
{name}
<HighlightMatches query={search} text={name} />
{tag ? <span className="text-(--ui-text-tertiary)"> {tag}</span> : null}
</span>
<Switch

View file

@ -0,0 +1,45 @@
import { render } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { HighlightMatches } from './highlight-matches'
const marksOf = (container: HTMLElement) => Array.from(container.querySelectorAll('mark')).map(m => m.textContent)
describe('HighlightMatches', () => {
it('wraps every case-insensitive occurrence in a <mark> without altering the text', () => {
const { container } = render(<HighlightMatches query="ro" text="Grok 4.5 Retro" />)
expect(container.textContent).toBe('Grok 4.5 Retro')
expect(marksOf(container)).toEqual(['ro', 'ro'])
})
it('renders plain text when the query is empty or does not occur in this label', () => {
// Non-occurrence matters: rows can match the filter on id/slug while the
// display label contains no occurrence — must not crash or mis-mark.
for (const query of ['', ' ', 'zzz']) {
const { container } = render(<HighlightMatches query={query} text="Fable 5" />)
expect(container.textContent).toBe('Fable 5')
expect(container.querySelector('mark')).toBeNull()
}
})
it('normalizes the query the same way the pickers filter (trim + lowercase)', () => {
const { container } = render(<HighlightMatches query=" GROK " text="grok-4.5" />)
expect(marksOf(container)).toEqual(['grok'])
})
it('marks every term of a multi-term query (palette AND semantics)', () => {
const { container } = render(<HighlightMatches query={['open', 'set']} text="Open Settings" />)
expect(container.textContent).toBe('Open Settings')
expect(marksOf(container)).toEqual(['Open', 'Set'])
})
it('merges overlapping and adjacent term ranges into one mark', () => {
const { container } = render(<HighlightMatches query={['gro', 'rok']} text="Grok" />)
expect(marksOf(container)).toEqual(['Grok'])
})
})

View file

@ -0,0 +1,91 @@
import type { ReactNode } from 'react'
import { normalize } from '@/lib/text'
import { cn } from '@/lib/utils'
/**
* Emphasize every case-insensitive occurrence of `query` inside `text` the
* "why is this row in my filtered list" affordance for searchable pickers.
* Renders semantic `<mark>`s (screen readers announce them as highlighted)
* restyled to the accent token instead of the browser's yellow slab, so the
* emphasis reads as text hierarchy, not a highlighter pen.
*
* The query must mirror the surface's OWN filter semantics, or the emphasis
* lies about why a row matched:
* - a string for literal-substring filters (the model pickers) spaces and
* all, exactly what `.includes()` saw;
* - a string[] for per-term AND matchers (the command palette) every term
* is marked wherever it occurs, overlapping/adjacent ranges merged.
*/
export function HighlightMatches({
className,
query,
text
}: {
className?: string
query: string | string[]
text: string
}) {
const terms = (Array.isArray(query) ? query : [query]).map(normalize).filter(Boolean)
if (terms.length === 0) {
return <>{text}</>
}
const ranges = matchRanges(text.toLowerCase(), terms)
if (ranges.length === 0) {
// No occurrence (the row matched on its id/slug/keywords, not this label).
return <>{text}</>
}
const parts: ReactNode[] = []
let cursor = 0
for (const [start, end] of ranges) {
if (start > cursor) {
parts.push(text.slice(cursor, start))
}
parts.push(
<mark className={cn('bg-transparent font-medium text-(--ui-accent)', className)} key={start}>
{text.slice(start, end)}
</mark>
)
cursor = end
}
if (cursor < text.length) {
parts.push(text.slice(cursor))
}
return <>{parts}</>
}
/** All occurrences of every term in `lower`, as sorted, merged [start, end). */
function matchRanges(lower: string, terms: string[]): Array<[number, number]> {
const raw: Array<[number, number]> = []
for (const term of terms) {
for (let index = lower.indexOf(term); index >= 0; index = lower.indexOf(term, index + 1)) {
raw.push([index, index + term.length])
}
}
raw.sort((a, b) => a[0] - b[0] || a[1] - b[1])
const merged: Array<[number, number]> = []
for (const [start, end] of raw) {
const last = merged[merged.length - 1]
if (last && start <= last[1]) {
last[1] = Math.max(last[1], end)
} else {
merged.push([start, end])
}
}
return merged
}