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:
commit
788126e5ab
|
|
@ -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([])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'])
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue