Merge pull request #74545 from NousResearch/bb/model-picker-hotkey

2-keypress model switching: ⌘⇧M, honest search commit, match highlighting
This commit is contained in:
brooklyn! 2026-07-29 23:27:03 -05:00 committed by GitHub
commit 788126e5ab
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 565 additions and 25 deletions

View File

@ -1,12 +1,16 @@
import { afterEach, describe, expect, it } from 'vitest'
import { $hoveredTreeGroup } from '@/components/pane-shell/tree/store'
import {
blurComposerInput,
getActiveComposer,
markActiveComposer,
onComposerFocusRequest,
onComposerModelMenuRequest,
releaseActiveComposer,
requestComposerFocus
requestComposerFocus,
requestModelMenuToggle
} from './focus'
import { RICH_INPUT_SLOT } from './rich-editor'
@ -45,6 +49,7 @@ afterEach(() => {
// `activeTarget` is module-level — a case that leaves a stale claim behind
// would otherwise decide the next one.
markActiveComposer('main')
$hoveredTreeGroup.set(null)
})
describe('blurComposerInput', () => {
@ -216,3 +221,65 @@ describe('resolveActive / keep-alive tab heal', () => {
expect(getActiveComposer()).toBe('edit')
})
})
/** A chat surface inside a layout zone, mirroring ChatView-in-tree-group. */
function mountZonedSurface(target: string, zone: string, hidden = false) {
const group = document.createElement('div')
group.dataset.treeGroup = zone
const layer = document.createElement('div')
layer.toggleAttribute('data-pane-hidden', hidden)
const surface = document.createElement('div')
surface.dataset.composerTarget = target
layer.append(surface)
group.append(layer)
document.body.append(group)
return surface
}
const collectModelMenuTargets = async (): Promise<string[]> => {
const saw: string[] = []
const off = onComposerModelMenuRequest(target => saw.push(target))
await new Promise(resolve => window.setTimeout(resolve, 0))
off()
return saw
}
describe('requestModelMenuToggle', () => {
it('targets the pane under the pointer over the focused one (#74447 convention)', async () => {
mountZonedSurface('main', 'zone-a')
mountZonedSurface('tile:hovered', 'zone-b')
markActiveComposer('main')
$hoveredTreeGroup.set('zone-b')
expect(requestModelMenuToggle()).toBe(true)
expect(await collectModelMenuTargets()).toEqual(['tile:hovered'])
})
it('falls back to the active composer when the pointer is off every zone', async () => {
mountZonedSurface('main', 'zone-a')
mountZonedSurface('tile:other', 'zone-b')
markActiveComposer('tile:other')
expect(requestModelMenuToggle()).toBe(true)
expect(await collectModelMenuTargets()).toEqual(['tile:other'])
})
it('skips a hidden keep-alive tab in the hovered zone (targets its visible sibling)', async () => {
mountZonedSurface('main', 'zone-a', true)
mountZonedSurface('tile:front', 'zone-a')
markActiveComposer('main')
$hoveredTreeGroup.set('zone-a')
expect(requestModelMenuToggle()).toBe(true)
expect(await collectModelMenuTargets()).toEqual(['tile:front'])
})
it('returns false with no chat surface on screen so the caller can open the dialog', async () => {
// Settings/profiles routes: no [data-composer-target] anywhere.
expect(requestModelMenuToggle()).toBe(false)
expect(await collectModelMenuTargets()).toEqual([])
})
})

View File

@ -10,7 +10,8 @@
* steal focus from the composer effect.
*/
import { queryVisible } from '@/components/pane-shell/pane-visibility'
import { queryAllVisible, queryVisible } from '@/components/pane-shell/pane-visibility'
import { $hoveredTreeGroup } from '@/components/pane-shell/tree/store'
import type { InlineRefInput } from './inline-refs'
import { RICH_INPUT_SLOT } from './rich-editor'
@ -42,6 +43,7 @@ const INSERT_EVENT = 'hermes:composer-insert'
const INSERT_REFS_EVENT = 'hermes:composer-insert-refs'
const SUBMIT_EVENT = 'hermes:composer-submit'
const VOICE_TOGGLE_EVENT = 'hermes:composer-voice-toggle'
const MODEL_MENU_EVENT = 'hermes:composer-model-menu'
/** Inline edit composer root — mounted only while a user bubble is being edited. */
const EDIT_COMPOSER_ROOT = '[data-slot="aui_edit-composer-root"]'
@ -258,6 +260,44 @@ export const requestVoiceToggle = (target: ComposerTarget | 'active' = 'active')
export const onComposerVoiceToggleRequest = (handler: (target: ComposerTarget) => void) =>
subscribe<{ target: ComposerTarget }>(VOICE_TOGGLE_EVENT, ({ target }) => handler(target))
/** The chat surface inside the zone the pointer is over, if any. Mirrors the
* tab verbs' hover-first targeting (`tabTargetGroupId`, #74447): the model
* hotkey lands in the pane you're pointing at without clicking into it first.
* Hidden keep-alive tabs are skipped like every document-wide lookup. */
const composerTargetInHoveredZone = (): ComposerTarget | null => {
const zone = $hoveredTreeGroup.get()
if (!zone || typeof document === 'undefined') {
return null
}
const surface = queryAllVisible<HTMLElement>('[data-composer-target]').find(
el => el.closest<HTMLElement>('[data-tree-group]')?.dataset.treeGroup === zone
)
return (surface?.dataset.composerTarget as ComposerTarget | undefined) ?? null
}
/** Toggle ONE composer's model menu the `composer.modelPicker` hotkey.
* Targets the pane under the pointer first (the tab-verb convention), then
* the active composer. Returns false when no chat surface is on screen at
* all (settings, profiles), so the caller can fall back to the full
* model-picker dialog instead of dispatching into the void. */
export const requestModelMenuToggle = (): boolean => {
if (typeof document !== 'undefined' && !queryVisible('[data-composer-target]')) {
return false
}
dispatch<{ target: ComposerTarget }>(MODEL_MENU_EVENT, {
target: composerTargetInHoveredZone() ?? resolveActive()
})
return true
}
export const onComposerModelMenuRequest = (handler: (target: ComposerTarget) => void) =>
subscribe<{ target: ComposerTarget }>(MODEL_MENU_EVENT, ({ target }) => handler(target))
/**
* Focus a composer input across React commit + browser focus restore.
*

View File

@ -1,5 +1,5 @@
import { useStore } from '@nanostores/react'
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { useSessionView } from '@/app/chat/session-view'
import { ModelMenuCloseContext } from '@/app/shell/model-menu-panel'
@ -13,6 +13,8 @@ import { formatModelStatusLabel } from '@/lib/model-status-label'
import { cn } from '@/lib/utils'
import { $currentModelSource, $defaultReasoningEffort, setModelPickerOpen } from '@/store/session'
import { onComposerModelMenuRequest } from './focus'
import { useComposerScope } from './scope'
import type { ChatBarState } from './types'
const PILL = cn(
@ -51,6 +53,28 @@ export function ModelPill({
const defaultEffort = useStore($defaultReasoningEffort)
const runtimeId = useStore(view.$runtimeId)
const [open, setOpen] = useState(false)
const scope = useComposerScope()
const hasLiveMenu = Boolean(model.modelMenuContent)
// The `composer.modelPicker` hotkey, routed to exactly one surface (the pane
// under the pointer, else the active composer — see requestModelMenuToggle).
// Toggles the live dropdown; with no live menu (gateway closed) it opens the
// full picker dialog, same as clicking the pill.
useEffect(
() =>
onComposerModelMenuRequest(target => {
if (target !== scope.target || disabled) {
return
}
if (hasLiveMenu) {
setOpen(prev => !prev)
} else {
setModelPickerOpen(true)
}
}),
[scope.target, disabled, hasLiveMenu]
)
// The composer pick is sticky: a manual selection is pinned and every NEW
// chat uses it instead of the Settings → Model default — silently, which has

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

@ -52,7 +52,7 @@ import { toggleStatusbarVisible } from '@/store/statusbar-prefs'
import { openNewWindow } from '@/store/windows'
import { useTheme } from '@/themes/context'
import { requestComposerFocus, requestVoiceToggle } from '../chat/composer/focus'
import { requestComposerFocus, requestModelMenuToggle, requestVoiceToggle } from '../chat/composer/focus'
import { openSession } from '../open-session'
import {
AGENTS_ROUTE,
@ -134,7 +134,13 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
'keybinds.openPanel': () => navigate(`${SETTINGS_ROUTE}?tab=keybinds`),
'composer.focus': () => requestComposerFocus('active'),
'composer.modelPicker': () => setModelPickerOpen(true),
// Toggle the composer pill's live model dropdown (pane under the pointer,
// else active composer); no chat surface on screen → the full dialog.
'composer.modelPicker': () => {
if (!requestModelMenuToggle()) {
setModelPickerOpen(true)
}
},
'composer.voice': requestVoiceToggle,
'nav.commandPalette': toggleCommandPalette,

View File

@ -144,6 +144,120 @@ describe('ModelMenuPanel current selection', () => {
})
})
describe('ModelMenuPanel search', () => {
// The pinned current model must NOT ride along on a query it doesn't match:
// it reads like the top result, so Enter/click picks the wrong model (the
// "type grok, get fable" bug). Every surveyed picker (VS Code, Zed, Open
// WebUI, Cherry Studio) drops the pin while filtering.
// Highlighted labels are split across <mark> nodes, so single-text-node
// queries miss them — match on the row span's composed textContent.
const rowWithText = (content: ReturnType<typeof renderPanel>['content'], pattern: RegExp) =>
content.queryByText((_, element) => element?.tagName === 'SPAN' && pattern.test(element.textContent ?? ''))
it('hides the non-matching current model while a query is active', async () => {
$currentProvider.set('deepseek')
$currentModel.set('deepseek-v4-pro')
const { content } = renderPanel()
await content.findByText(/Deepseek V4 Pro/i)
const input = screen.getByRole('textbox', { name: 'Search models' })
fireEvent.change(input, { target: { value: 'gemini' } })
await vi.waitFor(() => {
expect(rowWithText(content, /Gemini 3\.1 Pro/i)).not.toBeNull()
})
expect(rowWithText(content, /Deepseek V4 Pro/i)).toBeNull()
})
it('Enter in the search field commits the first match', async () => {
const { content, onSelectModel } = renderPanel()
await content.findByText('DeepSeek')
const input = screen.getByRole('textbox', { name: 'Search models' })
fireEvent.change(input, { target: { value: 'gemini' } })
await vi.waitFor(() => {
expect(rowWithText(content, /Gemini 3\.1 Pro/i)).not.toBeNull()
})
fireEvent.keyDown(input, { key: 'Enter' })
// First matching family of the first (alphabetical) matching provider.
await vi.waitFor(() => {
expect(onSelectModel).toHaveBeenCalledWith({ model: 'gemini-3.1-pro', provider: 'google', sessionId: 'runtime-1' })
})
})
it('Enter with no matches is a no-op (menu stays put, nothing selected)', async () => {
const { content, onSelectModel } = renderPanel()
await content.findByText('DeepSeek')
const input = screen.getByRole('textbox', { name: 'Search models' })
fireEvent.change(input, { target: { value: 'zzz-no-such-model' } })
fireEvent.keyDown(input, { key: 'Enter' })
expect(onSelectModel).not.toHaveBeenCalled()
})
it('arrows move the selection without leaving the input; Enter commits the stepped row', async () => {
const { content, onSelectModel } = renderPanel()
await content.findByText('DeepSeek')
const input = screen.getByRole('textbox', { name: 'Search models' })
fireEvent.change(input, { target: { value: 'gemini' } })
await vi.waitFor(() => {
expect(rowWithText(content, /Gemini 3\.1 Pro/i)).not.toBeNull()
})
// First match auto-selected; ↓ steps to the second match.
fireEvent.keyDown(input, { key: 'ArrowDown' })
fireEvent.keyDown(input, { key: 'Enter' })
await vi.waitFor(() => {
expect(onSelectModel).toHaveBeenCalledWith({ model: 'gemini-2.5-flash', provider: 'google', sessionId: 'runtime-1' })
})
})
it('with no query the selection sits on the current model, so Enter closes without switching', async () => {
$currentProvider.set('google')
$currentModel.set('gemini-3.1-pro')
const { content, onSelectModel } = renderPanel()
await content.findByText('DeepSeek')
const input = screen.getByRole('textbox', { name: 'Search models' })
fireEvent.keyDown(input, { key: 'Enter' })
expect(onSelectModel).not.toHaveBeenCalled()
})
it('filters MoA presets by the query instead of leaving them as phantom first matches', async () => {
const { content, onSelectModel } = renderPanel()
await content.findByText('MoA: BeastMode')
const input = screen.getByRole('textbox', { name: 'Search models' })
fireEvent.change(input, { target: { value: 'beast' } })
await vi.waitFor(() => {
expect(rowWithText(content, /MoA: BeastMode/)).not.toBeNull()
})
expect(rowWithText(content, /MoA: default/)).toBeNull()
// The surviving preset IS the first row, so Enter commits it.
fireEvent.keyDown(input, { key: 'Enter' })
await vi.waitFor(() => {
expect(onSelectModel).toHaveBeenCalledWith({ model: 'BeastMode', provider: 'moa', sessionId: 'runtime-1' })
})
})
})
describe('ModelMenuPanel provider collapse', () => {
it('shows all provider models by default (none collapsed)', async () => {
const { content } = renderPanel()
@ -205,9 +319,15 @@ describe('ModelMenuPanel provider collapse', () => {
expect(input).not.toBeNull()
fireEvent.change(input, { target: { value: 'deepseek' } })
// Should show models — search bypasses collapse
// Should show models — search bypasses collapse. The matched letters render
// inside a <mark>, splitting the label across nodes, so match on the row
// span's composed textContent instead of a single text node.
await vi.waitFor(() => {
expect(content.queryByText('Deepseek V4 Pro')).not.toBeNull()
expect(
content.queryByText(
(_, element) => element?.tagName === 'SPAN' && (element.textContent ?? '').startsWith('Deepseek V4 Pro')
)
).not.toBeNull()
})
})

View File

@ -1,6 +1,6 @@
import { useStore } from '@nanostores/react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { createContext, useContext, useMemo, useState } from 'react'
import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react'
import { useSessionView } from '@/app/chat/session-view'
import { Codicon } from '@/components/ui/codicon'
@ -16,6 +16,7 @@ import {
DropdownMenuSub,
DropdownMenuSubTrigger
} from '@/components/ui/dropdown-menu'
import { HighlightMatches } from '@/components/ui/highlight-matches'
import { Skeleton } from '@/components/ui/skeleton'
import type { HermesGateway } from '@/hermes'
import { useI18n } from '@/i18n'
@ -212,9 +213,132 @@ export function ModelMenuPanel({ gateway, onSelectModel, profile = 'default', re
[pickerProviders, search, optionsModel, optionsProvider, effectiveVisibleModels]
)
const q = normalize(search)
// Presets are searchable rows like everything else — an unfiltered preset
// sitting under zero model matches would otherwise become the "first match"
// Enter commits.
const shownMoaPresets = useMemo(
() => (q ? moaPresets.filter(preset => `moa ${preset}`.toLowerCase().includes(q)) : moaPresets),
[moaPresets, q]
)
// ── Keyboard selection (cmdk semantics on a Radix menu) ───────────────────
// One flat list mirroring EXACTLY what's rendered (collapse, filter, presets),
// so the selection can never sit on a hidden row. The selected index is
// derived — current model with no query (Enter = close), first match while
// typing — with an arrow-key override that resets on every keystroke. Focus
// stays in the search input throughout: ⌘⇧M → type → ↑/↓ → Enter.
type KbRow =
| { family: ModelFamily; key: string; kind: 'family'; provider: ModelOptionProvider }
| { key: string; kind: 'moa'; preset: string }
const kbRows = useMemo<KbRow[]>(
() => [
...groups.flatMap(group =>
collapsedProviders.includes(group.provider.slug) && !search
? []
: group.families.map(
(family): KbRow => ({
family,
key: `${group.provider.slug}:${family.id}`,
kind: 'family',
provider: group.provider
})
)
),
...shownMoaPresets.map((preset): KbRow => ({ key: `moa:${preset}`, kind: 'moa', preset }))
],
[groups, collapsedProviders, search, shownMoaPresets]
)
const [kbOverride, setKbOverride] = useState<null | number>(null)
// Gates the keyboard highlight: hovering a row moves Radix's focus off the
// input, and two live highlights would fight over which row Enter means.
const [searchFocused, setSearchFocused] = useState(true)
const currentKey = optionsProvider === 'moa' ? `moa:${optionsModel}` : `${optionsProvider}:${optionsModel}`
const autoIndex = q
? kbRows.length > 0
? 0
: -1
: kbRows.findIndex(
row => row.key === currentKey || (row.kind === 'family' && row.family.fastId === optionsModel)
)
const kbIndex = kbOverride !== null && kbOverride < kbRows.length ? kbOverride : autoIndex
const kbActiveKey = searchFocused && kbIndex >= 0 ? kbRows[kbIndex].key : null
const stepKb = (delta: -1 | 1) => {
if (kbRows.length === 0) {
return
}
const from = kbIndex >= 0 ? kbIndex : delta === 1 ? -1 : 0
setKbOverride((from + delta + kbRows.length) % kbRows.length)
}
const commitKbRow = () => {
const row = kbIndex >= 0 ? kbRows[kbIndex] : undefined
if (!row) {
return
}
if (row.kind === 'moa') {
void selectMoaPreset(row.preset)
return
}
if (row.key !== currentKey && row.family.fastId !== optionsModel) {
void selectFamily(row.family, row.provider)
}
closeMenu()
}
// Keep the selected row in view while arrowing through the scrollable list.
const listRef = useRef<HTMLDivElement>(null)
useEffect(() => {
listRef.current?.querySelector('[data-kb-active]')?.scrollIntoView({ block: 'nearest' })
}, [kbActiveKey])
const kbRowProps = (key: string) =>
kbActiveKey === key
? { className: cn(dropdownMenuRow, 'bg-(--ui-control-active-background) text-foreground'), 'data-kb-active': '' }
: { className: dropdownMenuRow }
return (
<>
<DropdownMenuSearch aria-label={copy.search} onValueChange={setSearch} placeholder={copy.search} value={search} />
<DropdownMenuSearch
aria-label={copy.search}
onBlur={() => setSearchFocused(false)}
onFocus={() => setSearchFocused(true)}
onKeyDown={event => {
// Claim arrows and Enter from Radix so DOM focus stays in the input
// and Enter commits the highlighted row without a DownArrow first
// (VS Code's checked-or-first pattern).
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault()
event.stopPropagation()
stepKb(event.key === 'ArrowDown' ? 1 : -1)
} else if (event.key === 'Enter') {
event.preventDefault()
event.stopPropagation()
commitKbRow()
}
}}
onValueChange={value => {
setSearch(value)
setKbOverride(null)
}}
placeholder={copy.search}
value={search}
/>
<DropdownMenuSeparator className="mx-0" />
@ -240,7 +364,7 @@ export function ModelMenuPanel({ gateway, onSelectModel, profile = 'default', re
{copy.noModels}
</DropdownMenuItem>
) : (
<div className="max-h-[max(150px,30dvh)] overflow-y-auto py-0.5">
<div className="max-h-[max(150px,30dvh)] overflow-y-auto py-0.5" ref={listRef}>
{groups.map(group => {
const slug = group.provider.slug
@ -258,7 +382,9 @@ export function ModelMenuPanel({ gateway, onSelectModel, profile = 'default', re
}}
textValue=""
>
<span className="truncate">{group.provider.name}</span>
<span className="truncate">
<HighlightMatches query={search} text={group.provider.name} />
</span>
<DisclosureCaret
className="shrink-0 text-(--ui-text-tertiary) opacity-0 transition group-hover/label:opacity-100"
open={!collapsed}
@ -322,7 +448,6 @@ export function ModelMenuPanel({ gateway, onSelectModel, profile = 'default', re
return (
<DropdownMenuSub key={`${group.provider.slug}:${family.id}`}>
<DropdownMenuSubTrigger
className={dropdownMenuRow}
hideChevron
onClick={activate}
onKeyDown={event => {
@ -330,9 +455,10 @@ export function ModelMenuPanel({ gateway, onSelectModel, profile = 'default', re
activate()
}
}}
{...kbRowProps(`${group.provider.slug}:${family.id}`)}
>
<span className="min-w-0 flex-1 truncate">
{name}
<HighlightMatches query={search} text={name} />
{meta ? <span className="text-(--ui-text-tertiary)"> {meta}</span> : null}
</span>
{isCurrent ? (
@ -360,22 +486,24 @@ export function ModelMenuPanel({ gateway, onSelectModel, profile = 'default', re
<DropdownMenuSeparator className="mx-0" />
{moaPresets.length > 0 ? (
{shownMoaPresets.length > 0 ? (
<>
<DropdownMenuLabel className={dropdownMenuSectionLabel}>MoA presets</DropdownMenuLabel>
{moaPresets.map(preset => {
{shownMoaPresets.map(preset => {
const isCurrentMoa = optionsProvider === 'moa' && optionsModel === preset
return (
<DropdownMenuItem
className={dropdownMenuRow}
key={`moa:${preset}`}
onSelect={event => {
event.preventDefault()
void selectMoaPreset(preset)
}}
{...kbRowProps(`moa:${preset}`)}
>
<span className="min-w-0 flex-1 truncate">MoA: {preset}</span>
<span className="min-w-0 flex-1 truncate">
MoA: <HighlightMatches query={search} text={preset} />
</span>
{isCurrentMoa ? <Codicon className="ml-auto text-foreground" name="check" size="0.75rem" /> : null}
</DropdownMenuItem>
)
@ -452,9 +580,11 @@ function groupModels(
// Always include the active model — but keep every row in the provider's
// stable curated order (filter `allFamilies`, never reorder), so selecting
// a model can't shuffle the list.
// a model can't shuffle the list. While SEARCHING, the pin is skipped: a
// query means "show me matches", and a pinned non-match sitting above them
// reads like the top result (type "grok", see the current Fable first).
const activeId =
provider.slug === current.provider && current.model
!q && provider.slug === current.provider && current.model
? allFamilies.find(family => family.id === current.model || family.fastId === current.model)?.id
: undefined

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
}

View File

@ -56,7 +56,10 @@ export const KEYBIND_ACTIONS: readonly KeybindActionMeta[] = [
// ── Composer ─────────────────────────────────────────────────────────────
// Soft `/` / Enter focus (gated); other printables type-to-focus unbound.
{ id: 'composer.focus', category: 'composer', defaults: ['/', 'enter'] },
{ id: 'composer.modelPicker', category: 'composer', defaults: [] },
// ⌘⇧M — "m" for model; the convention chat apps converged on (LibreChat,
// Open WebUI, and Cherry Studio all ship the same chord). Opens the pill's
// live dropdown on the pane under the pointer, else the active composer.
{ id: 'composer.modelPicker', category: 'composer', defaults: ['mod+shift+m'] },
// Voice conversation toggle. Matches the documented `voice.record_key`
// (Ctrl+B). On macOS that's literally ⌃B — distinct from the ⌘B sidebar
// toggle. Off macOS `ctrl` folds to `mod`, which IS the ⌘B/Ctrl+B sidebar