diff --git a/apps/desktop/src/app/command-palette/index.tsx b/apps/desktop/src/app/command-palette/index.tsx index d53b3199dc1..a0279c495ee 100644 --- a/apps/desktop/src/app/command-palette/index.tsx +++ b/apps/desktop/src/app/command-palette/index.tsx @@ -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 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)} > - {item.label} + + {/* Same per-term split as scoreItem's AND matcher, so the emphasis + shows exactly which words earned the row its rank. */} + + {item.detail && {item.detail}} {combo && } {item.to && } @@ -1078,6 +1085,7 @@ export function CommandPalette() { key={item.id} onSelectItem={handleSelect} onSelectMods={noteSelectMods} + search={search} /> ))} diff --git a/apps/desktop/src/components/model-picker.tsx b/apps/desktop/src/components/model-picker.tsx index b38630d7e9b..c013bf9b4c3 100644 --- a/apps/desktop/src/components/model-picker.tsx +++ b/apps/desktop/src/components/model-picker.tsx @@ -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}`} > - {model} + + + {locked && ( {copy.pro} )} diff --git a/apps/desktop/src/components/model-visibility-dialog.tsx b/apps/desktop/src/components/model-visibility-dialog.tsx index 1ab38d51bd4..cc4d58bd11d 100644 --- a/apps/desktop/src/components/model-visibility-dialog.tsx +++ b/apps/desktop/src/components/model-visibility-dialog.tsx @@ -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" > - {provider.name} + + + - {name} + {tag ? {tag} : null} Array.from(container.querySelectorAll('mark')).map(m => m.textContent) + +describe('HighlightMatches', () => { + it('wraps every case-insensitive occurrence in a without altering the text', () => { + const { container } = render() + + 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() + + 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() + + expect(marksOf(container)).toEqual(['grok']) + }) + + it('marks every term of a multi-term query (palette AND semantics)', () => { + const { container } = render() + + 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() + + expect(marksOf(container)).toEqual(['Grok']) + }) +}) diff --git a/apps/desktop/src/components/ui/highlight-matches.tsx b/apps/desktop/src/components/ui/highlight-matches.tsx new file mode 100644 index 00000000000..2d05ebc0667 --- /dev/null +++ b/apps/desktop/src/components/ui/highlight-matches.tsx @@ -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 ``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( + + {text.slice(start, end)} + + ) + + 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 +}