diff --git a/apps/desktop/src/app/chat/composer/focus.test.ts b/apps/desktop/src/app/chat/composer/focus.test.ts index bc92932bb8fea..4fd2108733617 100644 --- a/apps/desktop/src/app/chat/composer/focus.test.ts +++ b/apps/desktop/src/app/chat/composer/focus.test.ts @@ -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 => { + 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([]) + }) +}) diff --git a/apps/desktop/src/app/chat/composer/focus.ts b/apps/desktop/src/app/chat/composer/focus.ts index 3302d0310787f..2d403edef6ef9 100644 --- a/apps/desktop/src/app/chat/composer/focus.ts +++ b/apps/desktop/src/app/chat/composer/focus.ts @@ -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('[data-composer-target]').find( + el => el.closest('[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. * diff --git a/apps/desktop/src/app/chat/composer/model-pill.tsx b/apps/desktop/src/app/chat/composer/model-pill.tsx index 2a26db4094c9f..26fbe0266afed 100644 --- a/apps/desktop/src/app/chat/composer/model-pill.tsx +++ b/apps/desktop/src/app/chat/composer/model-pill.tsx @@ -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 diff --git a/apps/desktop/src/app/command-palette/index.tsx b/apps/desktop/src/app/command-palette/index.tsx index d53b3199dc11e..a0279c495ee0f 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/app/hooks/use-keybinds.ts b/apps/desktop/src/app/hooks/use-keybinds.ts index e1f73b0334475..07ac0384e630f 100644 --- a/apps/desktop/src/app/hooks/use-keybinds.ts +++ b/apps/desktop/src/app/hooks/use-keybinds.ts @@ -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, diff --git a/apps/desktop/src/app/shell/model-menu-panel.test.tsx b/apps/desktop/src/app/shell/model-menu-panel.test.tsx index 7da7c4af3d320..3a0e6b25750ab 100644 --- a/apps/desktop/src/app/shell/model-menu-panel.test.tsx +++ b/apps/desktop/src/app/shell/model-menu-panel.test.tsx @@ -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 nodes, so single-text-node + // queries miss them — match on the row span's composed textContent. + const rowWithText = (content: ReturnType['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 , 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() }) }) diff --git a/apps/desktop/src/app/shell/model-menu-panel.tsx b/apps/desktop/src/app/shell/model-menu-panel.tsx index 8ca63f5afd6d8..2e1ff2b3911c8 100644 --- a/apps/desktop/src/app/shell/model-menu-panel.tsx +++ b/apps/desktop/src/app/shell/model-menu-panel.tsx @@ -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( + () => [ + ...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) + // 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(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 ( <> - + 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} + /> @@ -240,7 +364,7 @@ export function ModelMenuPanel({ gateway, onSelectModel, profile = 'default', re {copy.noModels} ) : ( -
+
{groups.map(group => { const slug = group.provider.slug @@ -258,7 +382,9 @@ export function ModelMenuPanel({ gateway, onSelectModel, profile = 'default', re }} textValue="" > - {group.provider.name} + + + { @@ -330,9 +455,10 @@ export function ModelMenuPanel({ gateway, onSelectModel, profile = 'default', re activate() } }} + {...kbRowProps(`${group.provider.slug}:${family.id}`)} > - {name} + {meta ? {meta} : null} {isCurrent ? ( @@ -360,22 +486,24 @@ export function ModelMenuPanel({ gateway, onSelectModel, profile = 'default', re - {moaPresets.length > 0 ? ( + {shownMoaPresets.length > 0 ? ( <> MoA presets - {moaPresets.map(preset => { + {shownMoaPresets.map(preset => { const isCurrentMoa = optionsProvider === 'moa' && optionsModel === preset return ( { event.preventDefault() void selectMoaPreset(preset) }} + {...kbRowProps(`moa:${preset}`)} > - MoA: {preset} + + MoA: + {isCurrentMoa ? : null} ) @@ -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 diff --git a/apps/desktop/src/components/model-picker.tsx b/apps/desktop/src/components/model-picker.tsx index b38630d7e9b03..c013bf9b4c37b 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 1ab38d51bd479..cc4d58bd11d82 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 0000000000000..2d05ebc06678c --- /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 +} diff --git a/apps/desktop/src/lib/keybinds/actions.ts b/apps/desktop/src/lib/keybinds/actions.ts index 799d2d282c316..f4a609c202f6a 100644 --- a/apps/desktop/src/lib/keybinds/actions.ts +++ b/apps/desktop/src/lib/keybinds/actions.ts @@ -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