1import {
2 ANTIGRAVITY_DEFAULT_MODEL,
3 type ProviderInstanceId,
4 type EnvironmentId,
5 type ProviderDriverKind,
6 type ResolvedKeybindingsConfig,
7} from "@t3tools/contracts";
8import { resolveSelectableModel } from "@t3tools/shared/model";
9import { useAtomValue } from "@effect/atom-react";
10import { formatProviderUpdateRequiredNotice } from "@t3tools/client-runtime/providerUpdateRequiredModels";
11import { useNavigate } from "@tanstack/react-router";
12import { LegendList, type LegendListRef } from "@legendapp/list/react";
13import { memo, useMemo, useState, useCallback, useEffect, useLayoutEffect, useRef } from "react";
14import { ChevronRightIcon } from "lucide-react";
15import { ModelListRow } from "./ModelListRow";
16import { ModelPickerSidebar } from "./ModelPickerSidebar";
17import { getProviderStatusMessage, hasProviderSetup } from "./ProviderStatusBanner";
18import {
19 modelPickerLegacySectionKey,
20 modelPickerModelKey,
21 parseModelPickerLegacySectionKey,
22 parseModelPickerModelKey,
23} from "./modelPickerKeys";
24import { buildModelPickerSearchText, scoreModelPickerSearch } from "./modelPickerSearch";
25import {
26 Combobox,
27 ComboboxEmpty,
28 ComboboxSearchInput,
29 ComboboxItem,
30 ComboboxListVirtualized,
31} from "../ui/combobox";
32import { ModelEsque } from "./providerIconUtils";
33import { isCommandPaletteOpen } from "../../commandPaletteBus";
34import { primaryServerKeybindingsAtom } from "../../state/server";
35import {
36 modelPickerJumpCommandForIndex,
37 modelPickerJumpIndexFromCommand,
38 resolveShortcutCommand,
39 shortcutLabelForCommand,
40} from "../../keybindings";
41import {
42 useEnvironmentModelPreferences,
43 useToggleEnvironmentModelFavorite,
44} from "~/hooks/useSettings";
45import { cn } from "~/lib/utils";
46import { getVirtualizedScrollFadeClassName } from "../ui/scroll-area";
47import { TooltipProvider } from "../ui/tooltip";
48import { InlineButton } from "../ui/button";
49import {
50 isProviderInstancePickerReady,
51 isProviderInstancePickerVisible,
52 type ProviderInstanceEntry,
53} from "../../providerInstances";
54import { providerModelKey, sortProviderModelItems } from "../../modelOrdering";
55
56type ModelPickerItem = {
57 slug: string;
58 name: string;
59 shortName?: string;
60 subProvider?: string;
61 badge?: "new";
62 instanceId: ProviderInstanceId;
63 driverKind: ProviderDriverKind;
64 instanceDisplayName: string;
65 instanceAccentColor?: string | undefined;
66 acpRegistryAgentId?: string | undefined;
67 acpRegistryIconUrl?: string | undefined;
68 continuationGroupKey?: string | undefined;
69 isLegacy?: boolean | undefined;
70 isUnavailable?: boolean | undefined;
71};
72
73export function resolveModelPickerSelectedModel(input: {
74 driverKind: ProviderDriverKind | undefined;
75 model: string;
76 options: ReadonlyArray<ModelEsque>;
77}) {
78 if (input.driverKind === "antigravity" && input.model === ANTIGRAVITY_DEFAULT_MODEL) {
79 const availableModels = input.options.filter(
80 (option) => option.slug !== ANTIGRAVITY_DEFAULT_MODEL && !option.isUnavailable,
81 );
82 return (
83 availableModels.find((option) => option.aliases?.includes(ANTIGRAVITY_DEFAULT_MODEL)) ??
84 availableModels.find((option) => option.isDefault)
85 );
86 }
87 return input.options.find((option) => option.slug === input.model);
88}
89
90export function shouldIncludeModelPickerOption(input: {
91 readonly entry: ProviderInstanceEntry;
92 readonly option: ModelEsque;
93 readonly activeInstanceId: ProviderInstanceId;
94 readonly activeModel: string;
95}): boolean {
96 if (input.entry.driverKind === "antigravity" && input.option.slug === ANTIGRAVITY_DEFAULT_MODEL) {
97 return false;
98 }
99 if (isProviderInstancePickerReady(input.entry)) return true;
100 return (
101 input.entry.enabled &&
102 (input.entry.driverKind === "opencode" || input.entry.driverKind === "antigravity") &&
103 input.entry.instanceId === input.activeInstanceId &&
104 input.option.slug === input.activeModel &&
105 input.option.isUnavailable === true
106 );
107}
108
109export function shouldOfferModelPickerSetup(
110 entry: ProviderInstanceEntry,
111 options: ReadonlyArray<ModelEsque>,
112): boolean {
113 return (
114 entry.enabled &&
115 entry.status !== "disabled" &&
116 hasProviderSetup(entry.snapshot) &&
117 (!isProviderInstancePickerReady(entry) ||
118 !entry.installed ||
119 entry.snapshot.auth.status === "unauthenticated" ||
120 !options.some((option) => !option.isUnavailable))
121 );
122}
123
124export function adjacentModelPickerProvider(input: {
125 entries: ReadonlyArray<ProviderInstanceEntry>;
126 selectedInstanceId: ProviderInstanceId | "favorites";
127 direction: 1 | -1;
128 disabledInstanceIds: ReadonlySet<ProviderInstanceId> | undefined;
129 selectableUnavailableInstanceIds: ReadonlySet<ProviderInstanceId> | undefined;
130}) {
131 const providers: Array<ProviderInstanceId | "favorites"> = [
132 "favorites",
133 ...input.entries
134 .filter(
135 (entry) =>
136 !input.disabledInstanceIds?.has(entry.instanceId) &&
137 (isProviderInstancePickerReady(entry) ||
138 input.selectableUnavailableInstanceIds?.has(entry.instanceId)),
139 )
140 .map((entry) => entry.instanceId),
141 ];
142 const index = providers.indexOf(input.selectedInstanceId);
143 return providers[
144 index < 0
145 ? input.direction === 1
146 ? 0
147 : providers.length - 1
148 : (index + input.direction + providers.length) % providers.length
149 ]!;
150}
151
152const EMPTY_MODEL_JUMP_LABELS = new Map<string, string>();
153const MODEL_LIST_ESTIMATED_ITEM_SIZE = 52;
154
155function ModelListSeparator() {
156 return <div className="h-0.5" />;
157}
158
159export const ModelPickerContent = memo(function ModelPickerContent(props: {
160 environmentId: EnvironmentId | null;
161 /** The instance currently selected in the composer (combobox "value"). */
162 activeInstanceId: ProviderInstanceId;
163 model: string;
164 selectedModels?: ReadonlyArray<{ instanceId: ProviderInstanceId; model: string }>;
165 onToggleModel?: (instanceId: ProviderInstanceId, model: string) => void;
166 /**
167 * When set, the picker is locked to the given driver kind — typically
168 * because the user is editing a previously-sent message and can't change
169 * which driver served the turn. Multiple instances of the same kind
170 * remain selectable (e.g. locked to `codex` still lets the user switch
171 * between the default Codex and a custom Codex Personal).
172 */
173 lockedProvider: ProviderDriverKind | null;
174 lockedContinuationGroupKey?: string | null;
175 /**
176 * All configured provider instances in display order. Used to render
177 * the sidebar (one button per instance) and to resolve display names
178 * for the locked-mode header.
179 */
180 instanceEntries: ReadonlyArray<ProviderInstanceEntry>;
181 keybindings?: ResolvedKeybindingsConfig;
182 /**
183 * Model options per instance. Keyed by `ProviderInstanceId` so the
184 * default Codex instance and any custom Codex instances each have their
185 * own list (custom instances typically start with the same built-in
186 * model set but are free to diverge via customModels).
187 */
188 modelOptionsByInstance: ReadonlyMap<ProviderInstanceId, ReadonlyArray<ModelEsque>>;
189 terminalOpen: boolean;
190 onRequestClose?: () => void;
191 onOpenProviderSetup?: (instanceId: ProviderInstanceId) => void;
192 getModelDisabledReason?: (instanceId: ProviderInstanceId, model: string) => string | null;
193 onInstanceModelChange: (instanceId: ProviderInstanceId, model: string) => void;
194}) {
195 const {
196 keybindings: providedKeybindings,
197 modelOptionsByInstance,
198 instanceEntries,
199 getModelDisabledReason,
200 onInstanceModelChange,
201 onToggleModel,
202 } = props;
203 const [searchQuery, setSearchQuery] = useState("");
204 const [showTopScrollFade, setShowTopScrollFade] = useState(false);
205 const [showBottomScrollFade, setShowBottomScrollFade] = useState(false);
206 const searchInputRef = useRef<HTMLInputElement>(null);
207 const modelListRef = useRef<LegendListRef | null>(null);
208 const pickerContentRef = useRef<HTMLDivElement>(null);
209 const highlightedModelKeyRef = useRef<string | null>(null);
210 const { favorites } = useEnvironmentModelPreferences(props.environmentId);
211 const activeEntry = props.instanceEntries.find(
212 (entry) => entry.instanceId === props.activeInstanceId,
213 );
214 const activeModel = resolveModelPickerSelectedModel({
215 driverKind: activeEntry?.driverKind,
216 model: props.model,
217 options: modelOptionsByInstance.get(props.activeInstanceId) ?? [],
218 });
219 const activeModelSlug =
220 activeModel?.slug ?? (props.model === ANTIGRAVITY_DEFAULT_MODEL ? "" : props.model);
221 const activeModelKey = activeModelSlug
222 ? modelPickerModelKey(props.activeInstanceId, activeModelSlug)
223 : null;
224 const selectedModelKeys = useMemo(
225 () =>
226 props.selectedModels?.map((selection) => {
227 const entry = instanceEntries.find((entry) => entry.instanceId === selection.instanceId);
228 const model = resolveModelPickerSelectedModel({
229 driverKind: entry?.driverKind,
230 model: selection.model,
231 options: modelOptionsByInstance.get(selection.instanceId) ?? [],
232 });
233 return modelPickerModelKey(selection.instanceId, model?.slug ?? selection.model);
234 }),
235 [instanceEntries, modelOptionsByInstance, props.selectedModels],
236 );
237 const selectedModelKeySet = useMemo(
238 () => new Set(selectedModelKeys ?? (activeModelKey ? [activeModelKey] : [])),
239 [selectedModelKeys, activeModelKey],
240 );
241 const activeInstanceHasSelectableUnavailableModel =
242 activeEntry !== undefined &&
243 (modelOptionsByInstance.get(props.activeInstanceId) ?? []).some((option) =>
244 shouldIncludeModelPickerOption({
245 entry: activeEntry,
246 option,
247 activeInstanceId: props.activeInstanceId,
248 activeModel: activeModelSlug,
249 }),
250 ) &&
251 !isProviderInstancePickerReady(activeEntry);
252 const activeInstanceNeedsSetup =
253 props.onOpenProviderSetup !== undefined &&
254 activeEntry !== undefined &&
255 shouldOfferModelPickerSetup(
256 activeEntry,
257 modelOptionsByInstance.get(props.activeInstanceId) ?? [],
258 );
259 const [selectedInstanceId, setSelectedInstanceId] = useState<ProviderInstanceId | "favorites">(
260 () => {
261 if (
262 props.lockedProvider !== null ||
263 activeInstanceHasSelectableUnavailableModel ||
264 activeInstanceNeedsSetup
265 ) {
266 // Keep the active instance visible when it is locked or needs setup.
267 return props.activeInstanceId;
268 }
269 return favorites.length > 0 ? "favorites" : props.activeInstanceId;
270 },
271 );
272 const [expandedLegacyInstances, setExpandedLegacyInstances] = useState(
273 () =>
274 new Set<ProviderInstanceId>(
275 modelOptionsByInstance
276 .get(props.activeInstanceId)
277 ?.some((model) => model.slug === activeModelSlug && model.isLegacy)
278 ? [props.activeInstanceId]
279 : [],
280 ),
281 );
282 const serverKeybindings = useAtomValue(primaryServerKeybindingsAtom);
283 const keybindings = providedKeybindings ?? serverKeybindings;
284 const navigate = useNavigate();
285 const toggleFavorite = useToggleEnvironmentModelFavorite(props.environmentId);
286
287 const focusSearchInput = useCallback(() => {
288 searchInputRef.current?.focus({ preventScroll: true });
289 }, []);
290
291 const handleSelectInstance = useCallback(
292 (instanceId: ProviderInstanceId | "favorites") => {
293 setSelectedInstanceId(instanceId);
294 window.requestAnimationFrame(() => {
295 focusSearchInput();
296 });
297 },
298 [focusSearchInput],
299 );
300
301 useLayoutEffect(() => {
302 focusSearchInput();
303 const frame = window.requestAnimationFrame(() => {
304 focusSearchInput();
305 });
306 const timeout = window.setTimeout(() => {
307 focusSearchInput();
308 }, 0);
309 return () => {
310 window.cancelAnimationFrame(frame);
311 window.clearTimeout(timeout);
312 };
313 }, [focusSearchInput]);
314
315 // Create a Set for efficient lookup. Favorites are keyed by
316 // `${instanceId}:${slug}`; the storage schema widened from ProviderDriverKind
317 // to ProviderInstanceId so pre-migration favorites keyed by driver slugs
318 // (e.g. `"codex:gpt-5"`) still resolve — the default instance id equals
319 // the driver slug.
320 const favoritesSet = useMemo(() => {
321 return new Set(favorites.map((fav) => providerModelKey(fav.provider, fav.model)));
322 }, [favorites]);
323
324 /**
325 * Lookup table keyed by `instanceId`. Used for display name + driver
326 * kind enrichment and for `ready`/enabled filtering before flattening
327 * models into the search list.
328 */
329 const entryByInstanceId = useMemo(
330 () => new Map(instanceEntries.map((entry) => [entry.instanceId, entry])),
331 [instanceEntries],
332 );
333 const matchesLockedProvider = useCallback(
334 (entry: Pick<ProviderInstanceEntry, "driverKind" | "continuationGroupKey">): boolean => {
335 if (props.lockedProvider === null) return true;
336 if (entry.driverKind !== props.lockedProvider) return false;
337 if (!props.lockedContinuationGroupKey) return true;
338 return entry.continuationGroupKey === props.lockedContinuationGroupKey;
339 },
340 [props.lockedContinuationGroupKey, props.lockedProvider],
341 );
342
343 const selectableUnavailableInstanceIds = useMemo(() => {
344 const instanceIds = new Set<ProviderInstanceId>();
345 if (activeInstanceHasSelectableUnavailableModel) {
346 instanceIds.add(props.activeInstanceId);
347 }
348 if (props.onOpenProviderSetup) {
349 for (const entry of instanceEntries) {
350 if (
351 shouldOfferModelPickerSetup(entry, modelOptionsByInstance.get(entry.instanceId) ?? [])
352 ) {
353 instanceIds.add(entry.instanceId);
354 }
355 }
356 }
357 return instanceIds.size > 0 ? instanceIds : undefined;
358 }, [
359 activeInstanceHasSelectableUnavailableModel,
360 instanceEntries,
361 modelOptionsByInstance,
362 props.activeInstanceId,
363 props.onOpenProviderSetup,
364 ]);
365
366 // Flatten models into a searchable array. One pass over the
367 // instance-keyed map; each model carries its instance id + driver kind
368 // so the list row can render the right icon and display name without
369 // another lookup.
370 const flatModels = useMemo(() => {
371 const out: ModelPickerItem[] = [];
372 for (const [instanceId, models] of modelOptionsByInstance) {
373 const entry = entryByInstanceId.get(instanceId);
374 if (!entry) {
375 // Instance disappeared between renders (configuration change). Skip
376 // its models — stale options shouldn't appear in the picker.
377 continue;
378 }
379 for (const model of models) {
380 if (
381 !shouldIncludeModelPickerOption({
382 entry,
383 option: model,
384 activeInstanceId: props.activeInstanceId,
385 activeModel: activeModelSlug,
386 })
387 ) {
388 continue;
389 }
390 out.push({
391 slug: model.slug,
392 name: model.name,
393 ...(model.shortName ? { shortName: model.shortName } : {}),
394 ...(model.subProvider ? { subProvider: model.subProvider } : {}),
395 ...(model.badge ? { badge: model.badge } : {}),
396 ...(model.isLegacy ? { isLegacy: true } : {}),
397 ...(model.isUnavailable ? { isUnavailable: true } : {}),
398 instanceId,
399 driverKind: entry.driverKind,
400 instanceDisplayName: entry.displayName,
401 ...(entry.accentColor ? { instanceAccentColor: entry.accentColor } : {}),
402 ...(entry.acpRegistryAgentId ? { acpRegistryAgentId: entry.acpRegistryAgentId } : {}),
403 ...(entry.acpRegistryIconUrl ? { acpRegistryIconUrl: entry.acpRegistryIconUrl } : {}),
404 ...(entry.continuationGroupKey
405 ? { continuationGroupKey: entry.continuationGroupKey }
406 : {}),
407 });
408 }
409 }
410 return out;
411 }, [modelOptionsByInstance, entryByInstanceId, props.activeInstanceId, activeModelSlug]);
412
413 const isLocked = props.lockedProvider !== null;
414 const isSearching = searchQuery.trim().length > 0;
415 const lockedDisabledInstanceIds = useMemo(() => {
416 if (!isLocked) {
417 return undefined;
418 }
419 const disabled = new Set<ProviderInstanceId>();
420 for (const entry of instanceEntries) {
421 if (!matchesLockedProvider(entry)) {
422 disabled.add(entry.instanceId);
423 }
424 }
425 return disabled;
426 }, [instanceEntries, isLocked, matchesLockedProvider]);
427 const sidebarInstanceEntries = useMemo(() => {
428 const enabledEntries = instanceEntries.filter(isProviderInstancePickerVisible);
429 if (!isLocked) {
430 return enabledEntries;
431 }
432 const available: ProviderInstanceEntry[] = [];
433 const disabled: ProviderInstanceEntry[] = [];
434 for (const entry of enabledEntries) {
435 if (matchesLockedProvider(entry)) {
436 available.push(entry);
437 } else {
438 disabled.push(entry);
439 }
440 }
441 return [...available, ...disabled];
442 }, [instanceEntries, isLocked, matchesLockedProvider]);
443 const showSidebar = !isSearching && sidebarInstanceEntries.length > 0;
444 const instanceOrder = useMemo(
445 () => instanceEntries.map((entry) => entry.instanceId),
446 [instanceEntries],
447 );
448
449 // Filter models based on search query and selected instance
450 const filteredModels = useMemo(() => {
451 let result = flatModels;
452
453 // Apply tokenized fuzzy search across the combined provider/model search fields.
454 if (searchQuery.trim()) {
455 const rankedMatches = result
456 .map((model) => ({
457 model,
458 score: scoreModelPickerSearch(
459 {
460 name: model.name,
461 ...(model.shortName ? { shortName: model.shortName } : {}),
462 ...(model.subProvider ? { subProvider: model.subProvider } : {}),
463 driverKind: model.driverKind,
464 providerDisplayName: model.instanceDisplayName,
465 isFavorite: favoritesSet.has(providerModelKey(model.instanceId, model.slug)),
466 },
467 searchQuery,
468 ),
469 isFavorite: favoritesSet.has(providerModelKey(model.instanceId, model.slug)),
470 tieBreaker: buildModelPickerSearchText({
471 name: model.name,
472 ...(model.shortName ? { shortName: model.shortName } : {}),
473 ...(model.subProvider ? { subProvider: model.subProvider } : {}),
474 driverKind: model.driverKind,
475 providerDisplayName: model.instanceDisplayName,
476 }),
477 }))
478 .filter(
479 (
480 rankedModel,
481 ): rankedModel is {
482 model: ModelPickerItem;
483 score: number;
484 isFavorite: boolean;
485 tieBreaker: string;
486 } => rankedModel.score !== null,
487 );
488
489 // When searching, we only respect locked provider (by driver kind),
490 // ignoring sidebar selection so account-scoped searches can find a
491 // model before the user chooses a specific instance rail item.
492 if (props.lockedProvider !== null) {
493 const lockedProviderMatches: Array<(typeof rankedMatches)[number]> = [];
494 for (const rankedModel of rankedMatches) {
495 if (matchesLockedProvider(rankedModel.model)) {
496 lockedProviderMatches.push(rankedModel);
497 }
498 }
499 return lockedProviderMatches
500 .toSorted((a, b) => {
501 const scoreDelta = a.score - b.score;
502 if (scoreDelta !== 0) {
503 return scoreDelta;
504 }
505 if (a.isFavorite !== b.isFavorite) {
506 return a.isFavorite ? -1 : 1;
507 }
508 return a.tieBreaker.localeCompare(b.tieBreaker);
509 })
510 .map((rankedModel) => rankedModel.model);
511 }
512
513 return rankedMatches
514 .toSorted((a, b) => {
515 const scoreDelta = a.score - b.score;
516 if (scoreDelta !== 0) {
517 return scoreDelta;
518 }
519 if (a.isFavorite !== b.isFavorite) {
520 return a.isFavorite ? -1 : 1;
521 }
522 return a.tieBreaker.localeCompare(b.tieBreaker);
523 })
524 .map((rankedModel) => rankedModel.model);
525 }
526
527 if (props.lockedProvider !== null) {
528 result = result.filter((m) => matchesLockedProvider(m));
529 if (selectedInstanceId === "favorites") {
530 result = result.filter((m) => favoritesSet.has(providerModelKey(m.instanceId, m.slug)));
531 } else {
532 result = result.filter((m) => m.instanceId === selectedInstanceId);
533 }
534 } else if (selectedInstanceId === "favorites") {
535 result = result.filter((m) => favoritesSet.has(providerModelKey(m.instanceId, m.slug)));
536 } else {
537 result = result.filter((m) => m.instanceId === selectedInstanceId);
538 }
539
540 return sortProviderModelItems(result, {
541 favoriteModelKeys: favoritesSet,
542 groupFavorites: selectedInstanceId !== "favorites",
543 instanceOrder: selectedInstanceId === "favorites" ? instanceOrder : [],
544 });
545 }, [
546 favoritesSet,
547 flatModels,
548 instanceOrder,
549 matchesLockedProvider,
550 props.lockedProvider,
551 searchQuery,
552 selectedInstanceId,
553 ]);
554
555 const legacySection = useMemo(() => {
556 if (isSearching || selectedInstanceId === "favorites") {
557 return null;
558 }
559 const currentModels = filteredModels.filter((model) => !model.isLegacy);
560 const legacyModels = filteredModels.filter((model) => model.isLegacy);
561 if (legacyModels.length === 0) {
562 return null;
563 }
564 return {
565 key: modelPickerLegacySectionKey(selectedInstanceId),
566 currentModels,
567 legacyModels,
568 isExpanded: expandedLegacyInstances.has(selectedInstanceId),
569 };
570 }, [expandedLegacyInstances, filteredModels, isSearching, selectedInstanceId]);
571
572 const visibleModels = useMemo(() => {
573 if (!legacySection) {
574 return filteredModels;
575 }
576 return [
577 ...legacySection.currentModels,
578 ...(legacySection.isExpanded ? legacySection.legacyModels : []),
579 ];
580 }, [filteredModels, legacySection]);
581
582 const selectedEntry =
583 selectedInstanceId === "favorites" ? undefined : entryByInstanceId.get(selectedInstanceId);
584 const providerSetupEntries =
585 !isSearching && props.onOpenProviderSetup
586 ? instanceEntries.filter(
587 (entry) =>
588 matchesLockedProvider(entry) &&
589 shouldOfferModelPickerSetup(
590 entry,
591 modelOptionsByInstance.get(entry.instanceId) ?? [],
592 ) &&
593 (selectedEntry
594 ? entry.instanceId === selectedEntry.instanceId
595 : filteredModels.length === 0),
596 )
597 : [];
598
599 // Models the manifest announces that this provider's installed CLI is too
600 // old to run. Without this, a new model just seems missing until the user
601 // happens to update the CLI.
602 // A search spans every instance, so it can explain gated matches from any of them.
603 const updateRequiredNotices = (
604 isSearching
605 ? instanceEntries.filter(matchesLockedProvider)
606 : selectedEntry
607 ? [selectedEntry]
608 : []
609 ).flatMap((entry) => {
610 const notice = formatProviderUpdateRequiredNotice(entry.snapshot, searchQuery);
611 return notice ? [{ instanceId: entry.instanceId, notice }] : [];
612 });
613
614 const toggleLegacySection = useCallback((instanceId: ProviderInstanceId) => {
615 setExpandedLegacyInstances((expanded) => {
616 const next = new Set(expanded);
617 if (next.has(instanceId)) {
618 next.delete(instanceId);
619 } else {
620 next.add(instanceId);
621 }
622 return next;
623 });
624 }, []);
625
626 const handleModelSelect = useCallback(
627 (modelSlug: string, instanceId: ProviderInstanceId, additive = false) => {
628 if (getModelDisabledReason?.(instanceId, modelSlug)) {
629 return;
630 }
631 const options = modelOptionsByInstance.get(instanceId);
632 if (!options) {
633 return;
634 }
635 const entry = entryByInstanceId.get(instanceId);
636 if (!entry) {
637 return;
638 }
639 // `resolveSelectableModel` uses the driver kind for normalization
640 // (slug casing etc.). Custom instances share their driver's
641 // normalization rules, so pass the driver kind here.
642 const resolvedModel = resolveSelectableModel(entry.driverKind, modelSlug, options);
643 if (resolvedModel) {
644 if (additive && onToggleModel) {
645 onToggleModel(instanceId, resolvedModel);
646 } else {
647 onInstanceModelChange(instanceId, resolvedModel);
648 }
649 }
650 },
651 [
652 entryByInstanceId,
653 getModelDisabledReason,
654 modelOptionsByInstance,
655 onInstanceModelChange,
656 onToggleModel,
657 ],
658 );
659
660 const modelJumpCommandByKey = useMemo(() => {
661 const mapping = new Map<
662 string,
663 NonNullable<ReturnType<typeof modelPickerJumpCommandForIndex>>
664 >();
665 let selectableModelIndex = 0;
666 for (const model of visibleModels) {
667 if (getModelDisabledReason?.(model.instanceId, model.slug)) {
668 continue;
669 }
670 const jumpCommand = modelPickerJumpCommandForIndex(selectableModelIndex);
671 if (!jumpCommand) {
672 return mapping;
673 }
674 mapping.set(modelPickerModelKey(model.instanceId, model.slug), jumpCommand);
675 selectableModelIndex += 1;
676 }
677 return mapping;
678 }, [getModelDisabledReason, visibleModels]);
679 const modelJumpModelKeys = useMemo(
680 () => [...modelJumpCommandByKey.keys()],
681 [modelJumpCommandByKey],
682 );
683 const allItemKeys = useMemo(
684 (): string[] => [
685 ...flatModels.map((model) => modelPickerModelKey(model.instanceId, model.slug)),
686 ...new Set(
687 flatModels
688 .filter((model) => model.isLegacy)
689 .map((model) => modelPickerLegacySectionKey(model.instanceId)),
690 ),
691 ],
692 [flatModels],
693 );
694 const filteredItemKeys = useMemo((): string[] => {
695 const modelKeys = visibleModels.map((model) =>
696 modelPickerModelKey(model.instanceId, model.slug),
697 );
698 if (!legacySection) {
699 return modelKeys;
700 }
701 modelKeys.splice(legacySection.currentModels.length, 0, legacySection.key);
702 return modelKeys;
703 }, [legacySection, visibleModels]);
704 const filteredModelByKey = useMemo(
705 (): ReadonlyMap<string, ModelPickerItem> =>
706 new Map(
707 visibleModels.map(
708 (model) => [modelPickerModelKey(model.instanceId, model.slug), model] as const,
709 ),
710 ),
711 [visibleModels],
712 );
713 const [modelListContentSize, setModelListContentSize] = useState(
714 () => filteredItemKeys.length * MODEL_LIST_ESTIMATED_ITEM_SIZE,
715 );
716 const [searchHeight, setSearchHeight] = useState(0);
717 useLayoutEffect(
718 () => modelListRef.current?.getState().listen("totalSize", setModelListContentSize),
719 [],
720 );
721 // Fit the list to its rows plus the combobox list `py-1` and LegendList `py-1.5`.
722 const modelListHeight =
723 filteredItemKeys.length === 0 ? 0 : `calc(${modelListContentSize}px + var(--spacing) * 5)`;
724 const updateModelListScrollFades = useCallback(() => {
725 const scrollElement = modelListRef.current?.getScrollableNode();
726 if (!(scrollElement instanceof HTMLElement)) {
727 return;
728 }
729 const maxScrollOffset = Math.max(0, scrollElement.scrollHeight - scrollElement.clientHeight);
730 setShowTopScrollFade(scrollElement.scrollTop > 1);
731 setShowBottomScrollFade(maxScrollOffset - scrollElement.scrollTop > 1);
732 }, []);
733 const modelJumpShortcutContext = useMemo(
734 () =>
735 ({
736 terminalFocus: false,
737 terminalOpen: props.terminalOpen,
738 modelPickerOpen: true,
739 }) as const,
740 [props.terminalOpen],
741 );
742 const modelJumpLabelByKey = useMemo((): ReadonlyMap<string, string> => {
743 if (modelJumpCommandByKey.size === 0) {
744 return EMPTY_MODEL_JUMP_LABELS;
745 }
746 const shortcutLabelOptions = {
747 platform: navigator.platform,
748 context: modelJumpShortcutContext,
749 };
750 const mapping = new Map<string, string>();
751 for (const [modelKey, command] of modelJumpCommandByKey) {
752 const label = shortcutLabelForCommand(keybindings, command, shortcutLabelOptions);
753 if (label) {
754 mapping.set(modelKey, label);
755 }
756 }
757 return mapping.size > 0 ? mapping : EMPTY_MODEL_JUMP_LABELS;
758 }, [keybindings, modelJumpCommandByKey, modelJumpShortcutContext]);
759 const modelListExtraData = useMemo(
760 () => ({ favoritesSet, modelJumpLabelByKey, activeModelKey, selectedModelKeySet }),
761 [favoritesSet, modelJumpLabelByKey, activeModelKey, selectedModelKeySet],
762 );
763
764 useEffect(() => {
765 const onWindowKeyDown = (event: globalThis.KeyboardEvent) => {
766 if (event.defaultPrevented || event.repeat || isCommandPaletteOpen()) {
767 return;
768 }
769
770 const command = resolveShortcutCommand(event, keybindings, {
771 platform: navigator.platform,
772 context: modelJumpShortcutContext,
773 });
774 if (command === "modelPicker.previousProvider" || command === "modelPicker.nextProvider") {
775 event.preventDefault();
776 event.stopPropagation();
777 const next = adjacentModelPickerProvider({
778 entries: sidebarInstanceEntries,
779 selectedInstanceId,
780 direction: command === "modelPicker.nextProvider" ? 1 : -1,
781 disabledInstanceIds: lockedDisabledInstanceIds,
782 selectableUnavailableInstanceIds,
783 });
784 setSearchQuery("");
785 handleSelectInstance(next);
786 return;
787 }
788 const jumpIndex = modelPickerJumpIndexFromCommand(command ?? "");
789 if (jumpIndex === null) {
790 return;
791 }
792 event.preventDefault();
793 event.stopPropagation();
794
795 const targetModelKey = modelJumpModelKeys[jumpIndex];
796 if (!targetModelKey) {
797 return;
798 }
799 const model = parseModelPickerModelKey(targetModelKey);
800 if (!model) {
801 return;
802 }
803 handleModelSelect(model.slug, model.instanceId);
804 };
805
806 window.addEventListener("keydown", onWindowKeyDown, true);
807
808 return () => {
809 window.removeEventListener("keydown", onWindowKeyDown, true);
810 };
811 }, [
812 handleModelSelect,
813 handleSelectInstance,
814 keybindings,
815 lockedDisabledInstanceIds,
816 modelJumpModelKeys,
817 modelJumpShortcutContext,
818 selectableUnavailableInstanceIds,
819 selectedInstanceId,
820 sidebarInstanceEntries,
821 ]);
822
823 useLayoutEffect(() => {
824 setShowTopScrollFade(false);
825 setShowBottomScrollFade(filteredItemKeys.length > 5);
826 let nestedFrame = 0;
827 const frame = window.requestAnimationFrame(() => {
828 updateModelListScrollFades();
829 nestedFrame = window.requestAnimationFrame(updateModelListScrollFades);
830 });
831 return () => {
832 window.cancelAnimationFrame(frame);
833 window.cancelAnimationFrame(nestedFrame);
834 };
835 }, [filteredItemKeys, updateModelListScrollFades]);
836
837 return (
838 <TooltipProvider delay={0}>
839 <div
840 ref={pickerContentRef}
841 className="relative flex max-h-86.5 w-screen max-w-90 flex-row overflow-hidden"
842 // Hold the height from when the search started; results scroll instead of resizing.
843 style={isSearching ? { height: searchHeight } : undefined}
844 data-model-picker-content="true"
845 >
846 {/* Sidebar */}
847 {showSidebar && (
848 <ModelPickerSidebar
849 selectedInstanceId={selectedInstanceId}
850 onSelectInstance={handleSelectInstance}
851 onFocusSearch={focusSearchInput}
852 instanceEntries={sidebarInstanceEntries}
853 showFavorites
854 {...(selectableUnavailableInstanceIds ? { selectableUnavailableInstanceIds } : {})}
855 {...(lockedDisabledInstanceIds
856 ? {
857 disabledInstanceIds: lockedDisabledInstanceIds,
858 getDisabledInstanceTooltip: (entry: ProviderInstanceEntry) =>
859 `${entry.displayName} is unavailable in this thread. Start a new thread to switch providers.`,
860 }
861 : {})}
862 />
863 )}
864
865 {/* Main content area */}
866 <Combobox<string, boolean>
867 inline
868 items={allItemKeys}
869 filteredItems={filteredItemKeys}
870 filter={null}
871 autoHighlight
872 open
873 virtualized
874 multiple={onToggleModel !== undefined}
875 value={onToggleModel ? [...selectedModelKeySet] : activeModelKey}
876 onItemHighlighted={(modelKey, eventDetails) => {
877 highlightedModelKeyRef.current = typeof modelKey === "string" ? modelKey : null;
878 if (eventDetails.reason === "keyboard" && eventDetails.index >= 0) {
879 void modelListRef.current?.scrollIndexIntoView?.({
880 index: eventDetails.index,
881 animated: false,
882 });
883 }
884 }}
885 onValueChange={(value, details) => {
886 const modelKey = Array.isArray(value)
887 ? (value.find((key) => !selectedModelKeySet.has(key)) ??
888 [...selectedModelKeySet].find((key) => !value.includes(key)))
889 : value;
890 if (typeof modelKey !== "string") {
891 return;
892 }
893 const legacyInstanceId = parseModelPickerLegacySectionKey(modelKey);
894 if (legacyInstanceId) {
895 toggleLegacySection(legacyInstanceId);
896 return;
897 }
898 const model = parseModelPickerModelKey(modelKey);
899 if (model) {
900 handleModelSelect(
901 model.slug,
902 model.instanceId,
903 "shiftKey" in details.event && details.event.shiftKey === true,
904 );
905 }
906 }}
907 >
908 <div
909 className={cn(
910 "flex min-h-0 flex-1 flex-col overflow-hidden bg-muted/40",
911 showSidebar && "border-l border-border/70",
912 )}
913 >
914 <ComboboxSearchInput
915 ref={searchInputRef}
916 placeholder="Search models..."
917 value={searchQuery}
918 onChange={(e) => {
919 if (!isSearching) setSearchHeight(pickerContentRef.current?.offsetHeight ?? 0);
920 setSearchQuery(e.target.value);
921 }}
922 onKeyDown={(e) => {
923 if (
924 showSidebar &&
925 !e.altKey &&
926 !e.ctrlKey &&
927 !e.metaKey &&
928 ((e.key === "ArrowLeft" && !e.shiftKey && searchQuery.length === 0) ||
929 (e.key === "Tab" && e.shiftKey))
930 ) {
931 const sidebar = e.currentTarget
932 .closest("[data-model-picker-content]")
933 ?.querySelector("[data-model-picker-sidebar]");
934 const button =
935 sidebar?.querySelector<HTMLButtonElement>(
936 'button[aria-pressed="true"]:not(:disabled)',
937 ) ?? sidebar?.querySelector<HTMLButtonElement>("button:not(:disabled)");
938 if (button) {
939 e.preventDefault();
940 e.stopPropagation();
941 button.focus();
942 return;
943 }
944 }
945 if (e.key === "Escape") {
946 e.preventDefault();
947 e.stopPropagation();
948 props.onRequestClose?.();
949 return;
950 }
951 if (e.key === "Enter" && highlightedModelKeyRef.current) {
952 (e as typeof e & { preventBaseUIHandler?: () => void }).preventBaseUIHandler?.();
953 e.preventDefault();
954 e.stopPropagation();
955 const legacyInstanceId = parseModelPickerLegacySectionKey(
956 highlightedModelKeyRef.current,
957 );
958 if (legacyInstanceId) {
959 toggleLegacySection(legacyInstanceId);
960 return;
961 }
962 const model = parseModelPickerModelKey(highlightedModelKeyRef.current);
963 if (model) {
964 handleModelSelect(model.slug, model.instanceId, e.shiftKey);
965 }
966 return;
967 }
968 e.stopPropagation();
969 }}
970 onMouseDown={(e) => e.stopPropagation()}
971 onTouchStart={(e) => e.stopPropagation()}
972 />
973
974 {/* Model list */}
975 <div
976 className="relative min-h-0 overflow-hidden pr-px"
977 style={{ height: modelListHeight }}
978 >
979 <ComboboxListVirtualized>
980 <LegendList<string>
981 ref={modelListRef}
982 data={filteredItemKeys}
983 extraData={modelListExtraData}
984 keyExtractor={(modelKey) => modelKey}
985 renderItem={({ item: modelKey, index }) => {
986 if (legacySection?.key === modelKey) {
987 return (
988 <ComboboxItem
989 hideIndicator
990 index={index}
991 value={modelKey}
992 aria-expanded={legacySection.isExpanded}
993 className="group w-full cursor-pointer"
994 >
995 <div className="min-w-0 flex-1 text-left">
996 <div className="text-xs font-medium leading-snug">Legacy models</div>
997 <div className="mt-1 text-xs font-normal leading-snug text-muted-foreground/70">
998 {legacySection.legacyModels.length} models
999 </div>
1000 </div>
1001 <ChevronRightIcon
1002 className={cn(
1003 "size-4 transition-transform",
1004 legacySection.isExpanded && "rotate-90",
1005 )}
1006 />
1007 </ComboboxItem>
1008 );
1009 }
1010 const model = filteredModelByKey.get(modelKey);
1011 if (!model) {
1012 return null;
1013 }
1014 const disabledReason =
1015 getModelDisabledReason?.(model.instanceId, model.slug) ?? null;
1016 return (
1017 <ModelListRow
1018 key={modelKey}
1019 index={index}
1020 model={model}
1021 instanceId={model.instanceId}
1022 driverKind={model.driverKind}
1023 providerDisplayName={model.instanceDisplayName}
1024 providerAccentColor={model.instanceAccentColor}
1025 acpRegistryAgentId={model.acpRegistryAgentId}
1026 acpRegistryIconUrl={model.acpRegistryIconUrl}
1027 isFavorite={favoritesSet.has(
1028 providerModelKey(model.instanceId, model.slug),
1029 )}
1030 isSelected={
1031 selectedModelKeys !== undefined
1032 ? selectedModelKeySet.has(modelKey)
1033 : modelKey === activeModelKey
1034 }
1035 showSelection={selectedModelKeys !== undefined}
1036 showProvider
1037 preferShortName={!isLocked}
1038 useTriggerLabel={false}
1039 showNewBadge={model.badge === "new"}
1040 unavailable={model.isUnavailable === true}
1041 jumpLabel={modelJumpLabelByKey.get(modelKey) ?? null}
1042 disabledReason={disabledReason}
1043 onToggleFavorite={() => toggleFavorite(model.instanceId, model.slug)}
1044 />
1045 );
1046 }}
1047 estimatedItemSize={MODEL_LIST_ESTIMATED_ITEM_SIZE}
1048 drawDistance={480}
1049 recycleItems
1050 contentContainerClassName="pl-2 pr-px"
1051 ItemSeparatorComponent={ModelListSeparator}
1052 onLayout={updateModelListScrollFades}
1053 onScroll={updateModelListScrollFades}
1054 className={cn(
1055 "scrollbar-gutter-stable h-full overflow-x-hidden overscroll-y-contain py-1.5 [&::-webkit-scrollbar-track]:my-2",
1056 getVirtualizedScrollFadeClassName({
1057 top: showTopScrollFade,
1058 bottom: showBottomScrollFade,
1059 }),
1060 )}
1061 />
1062 </ComboboxListVirtualized>
1063 </div>
1064 {providerSetupEntries.length > 0 ? (
1065 <div className="max-h-44 shrink-0 overflow-y-auto border-t border-border/70 p-2">
1066 {providerSetupEntries.map((entry) => (
1067 <div key={entry.instanceId} className="px-1 py-1.5 text-xs leading-snug">
1068 <p className="line-clamp-3 text-muted-foreground">
1069 {getProviderStatusMessage(entry.snapshot)}
1070 </p>
1071 <InlineButton
1072 className="mt-1"
1073 onClick={() => {
1074 props.onRequestClose?.();
1075 props.onOpenProviderSetup?.(entry.instanceId);
1076 }}
1077 >
1078 {providerSetupEntries.length > 1
1079 ? `Set up ${entry.displayName}`
1080 : "Open provider setup"}
1081 </InlineButton>
1082 </div>
1083 ))}
1084 </div>
1085 ) : (
1086 <ComboboxEmpty className="empty:h-0">No models found</ComboboxEmpty>
1087 )}
1088 {updateRequiredNotices.map(({ instanceId, notice }) => (
1089 <p
1090 key={instanceId}
1091 className="shrink-0 border-t border-border/70 px-3 py-2 text-xs leading-snug text-muted-foreground"
1092 >
1093 {notice}{" "}
1094 <InlineButton
1095 onClick={() => {
1096 props.onRequestClose?.();
1097 void navigate({ to: "/settings/providers" });
1098 }}
1099 >
1100 Provider settings
1101 </InlineButton>
1102 </p>
1103 ))}
1104 </div>
1105 </Combobox>
1106 </div>
1107 </TooltipProvider>
1108 );
1109});