mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-30 19:09:28 +00:00
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:
parent
36f885573c
commit
611c6aab76
5 changed files with 155 additions and 5 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
45
apps/desktop/src/components/ui/highlight-matches.test.tsx
Normal file
45
apps/desktop/src/components/ui/highlight-matches.test.tsx
Normal 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'])
|
||||
})
|
||||
})
|
||||
91
apps/desktop/src/components/ui/highlight-matches.tsx
Normal file
91
apps/desktop/src/components/ui/highlight-matches.tsx
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue