Unified · Split
| @@ -43,6 +43,10 @@ type ModelPickerItem = { · 42 unmodified lines | ||
| 43 | 43 | |
| 44 | 44 | const EMPTY_MODEL_JUMP_LABELS = new Map<string, string>(); |
| 45 | 45 | |
| 46 | +function normalizeModelPickerSearchQuery(value: string): string { | |
| 47 | + return value.trim(); | |
| 48 | +} | |
| 49 | + | |
| 46 | 50 | // Split a `${instanceId}:${slug}` combobox key back into its pieces. Slugs |
| 47 | 51 | // can contain colons (e.g. some vendor model ids), so we only split on the |
| 48 | 52 | // first colon — anything after that is the slug. |
| @@ -57,6 +61,115 @@ function splitInstanceModelKey(key: string): { instanceId: ProviderInstanceId; s · 8 unmodified lines | ||
| 57 | 61 | }; |
| 58 | 62 | } |
| 59 | 63 | |
| 64 | +function useModelPickerJumpShortcuts(input: { | |
| 65 | + readonly keybindings: ResolvedKeybindingsConfig; | |
| 66 | + readonly modelJumpModelKeys: ReadonlyArray<string>; | |
| 67 | + readonly modelJumpShortcutContext: { | |
| 68 | + readonly terminalFocus: false; | |
| 69 | + readonly terminalOpen: boolean; | |
| 70 | + readonly modelPickerOpen: true; | |
| 71 | + }; | |
| 72 | + readonly onModelSelect: (modelSlug: string, instanceId: ProviderInstanceId) => void; | |
| 73 | +}) { | |
| 74 | + const { keybindings, modelJumpModelKeys, modelJumpShortcutContext, onModelSelect } = input; | |
| 75 | + | |
| 76 | + useEffect(() => { | |
| 77 | + const onWindowKeyDown = (event: globalThis.KeyboardEvent) => { | |
| 78 | + if (event.defaultPrevented || event.repeat) { | |
| 79 | + return; | |
| 80 | + } | |
| 81 | + | |
| 82 | + const command = resolveShortcutCommand(event, keybindings, { | |
| 83 | + platform: navigator.platform, | |
| 84 | + context: modelJumpShortcutContext, | |
| 85 | + }); | |
| 86 | + const jumpIndex = modelPickerJumpIndexFromCommand(command ?? ""); | |
| 87 | + if (jumpIndex === null) { | |
| 88 | + return; | |
| 89 | + } | |
| 90 | + | |
| 91 | + const targetModelKey = modelJumpModelKeys[jumpIndex]; | |
| 92 | + if (!targetModelKey) { | |
| 93 | + return; | |
| 94 | + } | |
| 95 | + const { instanceId, slug } = splitInstanceModelKey(targetModelKey); | |
| 96 | + event.preventDefault(); | |
| 97 | + event.stopPropagation(); | |
| 98 | + onModelSelect(slug, instanceId); | |
| 99 | + }; | |
| 100 | + | |
| 101 | + window.addEventListener("keydown", onWindowKeyDown, true); | |
| 102 | + | |
| 103 | + return () => { | |
| 104 | + window.removeEventListener("keydown", onWindowKeyDown, true); | |
| 105 | + }; | |
| 106 | + }, [keybindings, modelJumpModelKeys, modelJumpShortcutContext, onModelSelect]); | |
| 107 | +} | |
| 108 | + | |
| 109 | +function ModelPickerSearchInput(props: { | |
| 110 | + readonly focusRequest: number; | |
| 111 | + readonly onSearchQueryChange: (query: string) => void; | |
| 112 | + readonly onRequestClose: (() => void) | undefined; | |
| 113 | + readonly onHighlightedModelSelect: () => boolean; | |
| 114 | +}) { | |
| 115 | + const [rawSearchQuery, setRawSearchQuery] = useState(""); | |
| 116 | + const searchInputRef = useRef<HTMLInputElement>(null); | |
| 117 | + const normalizedSearchQueryRef = useRef(""); | |
| 118 | + | |
| 119 | + useLayoutEffect(() => { | |
| 120 | + searchInputRef.current?.focus({ preventScroll: true }); | |
| 121 | + const frame = window.requestAnimationFrame(() => { | |
| 122 | + searchInputRef.current?.focus({ preventScroll: true }); | |
| 123 | + }); | |
| 124 | + return () => { | |
| 125 | + window.cancelAnimationFrame(frame); | |
| 126 | + }; | |
| 127 | + }, [props.focusRequest]); | |
| 128 | + | |
| 129 | + return ( | |
| 130 | + <ComboboxInput | |
| 131 | + ref={searchInputRef} | |
| 132 | + className="[&_input]:h-6.5 [&_input]:font-sans [&_input]:leading-6.5" | |
| 133 | + inputClassName="rounded-none bg-transparent text-sm" | |
| 134 | + placeholder="Search models..." | |
| 135 | + showTrigger={false} | |
| 136 | + startAddon={ | |
| 137 | + <SearchIcon className="-translate-x-0.5 size-4 shrink-0 text-muted-foreground/55" /> | |
| 138 | + } | |
| 139 | + value={rawSearchQuery} | |
| 140 | + onChange={(event) => { | |
| 141 | + const nextRawSearchQuery = event.target.value; | |
| 142 | + setRawSearchQuery(nextRawSearchQuery); | |
| 143 | + const nextNormalizedSearchQuery = normalizeModelPickerSearchQuery(nextRawSearchQuery); | |
| 144 | + if (nextNormalizedSearchQuery === normalizedSearchQueryRef.current) { | |
| 145 | + return; | |
| 146 | + } | |
| 147 | + normalizedSearchQueryRef.current = nextNormalizedSearchQuery; | |
| 148 | + props.onSearchQueryChange(nextNormalizedSearchQuery); | |
| 149 | + }} | |
| 150 | + onKeyDown={(event) => { | |
| 151 | + if (event.key === "Escape") { | |
| 152 | + event.preventDefault(); | |
| 153 | + event.stopPropagation(); | |
| 154 | + props.onRequestClose?.(); | |
| 155 | + return; | |
| 156 | + } | |
| 157 | + if (event.key === "Enter" && props.onHighlightedModelSelect()) { | |
| 158 | + (event as typeof event & { preventBaseUIHandler?: () => void }).preventBaseUIHandler?.(); | |
| 159 | + event.preventDefault(); | |
| 160 | + event.stopPropagation(); | |
| 161 | + return; | |
| 162 | + } | |
| 163 | + event.stopPropagation(); | |
| 164 | + }} | |
| 165 | + onMouseDown={(event) => event.stopPropagation()} | |
| 166 | + onTouchStart={(event) => event.stopPropagation()} | |
| 167 | + size="sm" | |
| 168 | + unstyled | |
| 169 | + /> | |
| 170 | + ); | |
| 171 | +} | |
| 172 | + | |
| 60 | 173 | export const ModelPickerContent = memo(function ModelPickerContent(props: { |
| 61 | 174 | /** The instance currently selected in the composer (combobox "value"). */ |
| 62 | 175 | activeInstanceId: ProviderInstanceId; |
| @@ -99,7 +212,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { · 36 unmodified lines | ||
| 99 | 212 | const [searchQuery, setSearchQuery] = useState(""); |
| 100 | 213 | const [showTopScrollFade, setShowTopScrollFade] = useState(false); |
| 101 | 214 | const [showBottomScrollFade, setShowBottomScrollFade] = useState(false); |
| 102 | − const searchInputRef = useRef<HTMLInputElement>(null); | |
| 215 | + const [searchFocusRequest, setSearchFocusRequest] = useState(0); | |
| 103 | 216 | const modelListRef = useRef<LegendListRef | null>(null); |
| 104 | 217 | const highlightedModelKeyRef = useRef<string | null>(null); |
| 105 | 218 | const favorites = useClientSettings((s) => s.favorites ?? []); |
| @@ -119,33 +232,10 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { · 13 unmodified lines | ||
| 119 | 232 | ); |
| 120 | 233 | const updateSettings = useUpdateClientSettings(); |
| 121 | 234 | |
| 122 | − const focusSearchInput = useCallback(() => { | |
| 123 | − searchInputRef.current?.focus({ preventScroll: true }); | |
| 124 | − }, []); | |
| 125 | − | |
| 126 | − const handleSelectInstance = useCallback( | |
| 127 | − (instanceId: ProviderInstanceId | "favorites") => { | |
| 128 | − setSelectedInstanceId(instanceId); | |
| 129 | − window.requestAnimationFrame(() => { | |
| 130 | − focusSearchInput(); | |
| 131 | − }); | |
| 132 | − }, | |
| 133 | − [focusSearchInput], | |
| 134 | − ); | |
| 135 | − | |
| 136 | − useLayoutEffect(() => { | |
| 137 | − focusSearchInput(); | |
| 138 | − const frame = window.requestAnimationFrame(() => { | |
| 139 | − focusSearchInput(); | |
| 140 | − }); | |
| 141 | − const timeout = window.setTimeout(() => { | |
| 142 | − focusSearchInput(); | |
| 143 | − }, 0); | |
| 144 | − return () => { | |
| 145 | − window.cancelAnimationFrame(frame); | |
| 146 | − window.clearTimeout(timeout); | |
| 147 | − }; | |
| 148 | − }, [focusSearchInput]); | |
| 235 | + const handleSelectInstance = (instanceId: ProviderInstanceId | "favorites") => { | |
| 236 | + setSelectedInstanceId(instanceId); | |
| 237 | + setSearchFocusRequest((request) => request + 1); | |
| 238 | + }; | |
| 149 | 239 | |
| 150 | 240 | // Create a Set for efficient lookup. Favorites are keyed by |
| 151 | 241 | // `${instanceId}:${slug}`; the storage schema widened from ProviderDriverKind |
| @@ -221,7 +311,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { · 69 unmodified lines | ||
| 221 | 311 | }, [modelOptionsByInstance, entryByInstanceId, readyInstanceSet]); |
| 222 | 312 | |
| 223 | 313 | const isLocked = props.lockedProvider !== null; |
| 224 | − const isSearching = searchQuery.trim().length > 0; | |
| 314 | + const isSearching = searchQuery.length > 0; | |
| 225 | 315 | const lockedDisabledInstanceIds = useMemo(() => { |
| 226 | 316 | if (!isLocked) { |
| 227 | 317 | return undefined; |
| @@ -261,7 +351,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { · 33 unmodified lines | ||
| 261 | 351 | let result = flatModels; |
| 262 | 352 | |
| 263 | 353 | // Apply tokenized fuzzy search across the combined provider/model search fields. |
| 264 | − if (searchQuery.trim()) { | |
| 354 | + if (searchQuery) { | |
| 265 | 355 | const rankedMatches = result |
| 266 | 356 | .map((model) => ({ |
| 267 | 357 | model, |
| @@ -385,6 +475,15 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { · 117 unmodified lines | ||
| 385 | 475 | }, |
| 386 | 476 | [entryByInstanceId, getModelDisabledReason, modelOptionsByInstance, onInstanceModelChange], |
| 387 | 477 | ); |
| 478 | + const selectHighlightedModel = () => { | |
| 479 | + const highlightedModelKey = highlightedModelKeyRef.current; | |
| 480 | + if (!highlightedModelKey) { | |
| 481 | + return false; | |
| 482 | + } | |
| 483 | + const { instanceId, slug } = splitInstanceModelKey(highlightedModelKey); | |
| 484 | + handleModelSelect(slug, instanceId); | |
| 485 | + return true; | |
| 486 | + }; | |
| 388 | 487 | |
| 389 | 488 | const toggleFavorite = useCallback( |
| 390 | 489 | (instanceId: ProviderInstanceId, model: string) => { |
| @@ -472,41 +571,14 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { · 81 unmodified lines | ||
| 472 | 571 | return mapping.size > 0 ? mapping : EMPTY_MODEL_JUMP_LABELS; |
| 473 | 572 | }, [keybindings, modelJumpCommandByKey, modelJumpShortcutContext]); |
| 474 | 573 | |
| 475 | − useEffect(() => { | |
| 476 | − const onWindowKeyDown = (event: globalThis.KeyboardEvent) => { | |
| 477 | − if (event.defaultPrevented || event.repeat) { | |
| 478 | − return; | |
| 479 | − } | |
| 480 | − | |
| 481 | − const command = resolveShortcutCommand(event, keybindings, { | |
| 482 | − platform: navigator.platform, | |
| 483 | − context: modelJumpShortcutContext, | |
| 484 | − }); | |
| 485 | − const jumpIndex = modelPickerJumpIndexFromCommand(command ?? ""); | |
| 486 | − if (jumpIndex === null) { | |
| 487 | − return; | |
| 488 | − } | |
| 489 | − | |
| 490 | − const targetModelKey = modelJumpModelKeys[jumpIndex]; | |
| 491 | − if (!targetModelKey) { | |
| 492 | − return; | |
| 493 | − } | |
| 494 | − const { instanceId, slug } = splitInstanceModelKey(targetModelKey); | |
| 495 | − event.preventDefault(); | |
| 496 | − event.stopPropagation(); | |
| 497 | − handleModelSelect(slug, instanceId); | |
| 498 | − }; | |
| 499 | − | |
| 500 | − window.addEventListener("keydown", onWindowKeyDown, true); | |
| 501 | − | |
| 502 | − return () => { | |
| 503 | − window.removeEventListener("keydown", onWindowKeyDown, true); | |
| 504 | − }; | |
| 505 | − }, [handleModelSelect, keybindings, modelJumpModelKeys, modelJumpShortcutContext]); | |
| 574 | + useModelPickerJumpShortcuts({ | |
| 575 | + keybindings, | |
| 576 | + modelJumpModelKeys, | |
| 577 | + modelJumpShortcutContext, | |
| 578 | + onModelSelect: handleModelSelect, | |
| 579 | + }); | |
| 506 | 580 | |
| 507 | 581 | useLayoutEffect(() => { |
| 508 | − setShowTopScrollFade(false); | |
| 509 | − setShowBottomScrollFade(filteredModelKeys.length > 5); | |
| 510 | 582 | let nestedFrame = 0; |
| 511 | 583 | const frame = window.requestAnimationFrame(() => { |
| 512 | 584 | updateModelListScrollFades(); |
| @@ -577,42 +649,11 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { · 64 unmodified lines | ||
| 577 | 649 | {/* Search bar */} |
| 578 | 650 | <div className="px-4 pt-2.5"> |
| 579 | 651 | <div className="-translate-y-px border-b border-border/70 pb-2.5 transition-colors focus-within:border-ring"> |
| 580 | − <ComboboxInput | |
| 581 | − ref={searchInputRef} | |
| 582 | − className="[&_input]:h-6.5 [&_input]:font-sans [&_input]:leading-6.5" | |
| 583 | − inputClassName="rounded-none bg-transparent text-sm" | |
| 584 | − placeholder="Search models..." | |
| 585 | − showTrigger={false} | |
| 586 | − startAddon={ | |
| 587 | − <SearchIcon className="-translate-x-0.5 size-4 shrink-0 text-muted-foreground/55" /> | |
| 588 | − } | |
| 589 | − value={searchQuery} | |
| 590 | − onChange={(e) => setSearchQuery(e.target.value)} | |
| 591 | − onKeyDown={(e) => { | |
| 592 | − if (e.key === "Escape") { | |
| 593 | − e.preventDefault(); | |
| 594 | − e.stopPropagation(); | |
| 595 | − props.onRequestClose?.(); | |
| 596 | − return; | |
| 597 | − } | |
| 598 | − if (e.key === "Enter" && highlightedModelKeyRef.current) { | |
| 599 | − ( | |
| 600 | − e as typeof e & { preventBaseUIHandler?: () => void } | |
| 601 | − ).preventBaseUIHandler?.(); | |
| 602 | − e.preventDefault(); | |
| 603 | − e.stopPropagation(); | |
| 604 | − const { instanceId, slug } = splitInstanceModelKey( | |
| 605 | − highlightedModelKeyRef.current, | |
| 606 | − ); | |
| 607 | − handleModelSelect(slug, instanceId); | |
| 608 | − return; | |
| 609 | − } | |
| 610 | − e.stopPropagation(); | |
| 611 | − }} | |
| 612 | − onMouseDown={(e) => e.stopPropagation()} | |
| 613 | − onTouchStart={(e) => e.stopPropagation()} | |
| 614 | − size="sm" | |
| 615 | − unstyled | |
| 652 | + <ModelPickerSearchInput | |
| 653 | + focusRequest={searchFocusRequest} | |
| 654 | + onSearchQueryChange={setSearchQuery} | |
| 655 | + onRequestClose={props.onRequestClose} | |
| 656 | + onHighlightedModelSelect={selectHighlightedModel} | |
| 616 | 657 | /> |
| 617 | 658 | </div> |
| 618 | 659 | </div> |