1"use client";
2
3import { ArrowDownIcon, ArrowUpIcon, PencilIcon, PlusIcon, StarIcon, XIcon } from "lucide-react";
4import { useEffect, useMemo, useRef, useState } from "react";
5import {
6 ProviderDriverKind,
7 type ProviderInstanceId,
8 type ServerProviderModel,
9} from "@t3tools/contracts";
10import { type CustomModelDefinition, normalizeCustomModelSlug } from "@t3tools/shared/model";
11
12import { cn } from "../../lib/utils";
13import { sortModelsForProviderInstance } from "../../modelOrdering";
14import { MAX_CUSTOM_MODEL_LENGTH } from "../../modelSelection";
15import { Button } from "../ui/button";
16import { Input } from "../ui/input";
17import { Switch } from "../ui/switch";
18import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
19import { CustomModelEditor } from "./CustomModelEditor";
20
21/**
22 * Placeholder text for the "add a custom model" input, keyed by driver
23 * kind. Mirrors the prior hardcoded switch in `SettingsPanels.tsx` so the
24 * UX is unchanged — only the owning component has moved.
25 */
26const CUSTOM_MODEL_PLACEHOLDER_BY_KIND: Partial<Record<ProviderDriverKind, string>> = {
27 [ProviderDriverKind.make("codex")]: "gpt-6.7-codex-ultra-preview",
28 [ProviderDriverKind.make("claudeAgent")]: "claude-sonnet-5",
29 [ProviderDriverKind.make("cursor")]: "claude-sonnet-4-6",
30 [ProviderDriverKind.make("opencode")]: "openai/gpt-5",
31 [ProviderDriverKind.make("pi")]: "anthropic/claude-sonnet-5",
32};
33
34/** Above this many models the list gets a filter input. */
35const FILTER_THRESHOLD = 8;
36
37/**
38 * Short capability words shown after a model's slug. Claude and Cursor report
39 * fast mode as a boolean `fastMode` option; Codex reports it as a
40 * `serviceTier` select whose fast tier is labelled "Fast" (catalog id
41 * `priority`, or `fast` from the speed-tier fallback), matching the composer.
42 */
43function describeModelCapabilities(model: ServerProviderModel): string[] {
44 const descriptors = model.capabilities?.optionDescriptors ?? [];
45 const labels: string[] = [];
46 const hasFastMode = descriptors.some(
47 (descriptor) =>
48 descriptor.id === "fastMode" ||
49 (descriptor.id === "serviceTier" &&
50 descriptor.type === "select" &&
51 descriptor.options.some((option) => option.id === "fast" || option.label === "Fast")),
52 );
53 if (hasFastMode) labels.push("Fast mode");
54 if (descriptors.some((descriptor) => descriptor.id === "thinking")) labels.push("Thinking");
55 if (
56 descriptors.some(
57 (descriptor) =>
58 descriptor.type === "select" &&
59 (descriptor.id === "reasoningEffort" ||
60 descriptor.id === "effort" ||
61 descriptor.id === "reasoning" ||
62 descriptor.id === "variant"),
63 )
64 ) {
65 labels.push("Reasoning");
66 }
67 return labels;
68}
69
70/**
71 * Display order for the models list: favorites first (in user order), then
72 * visible models, then hidden ones. Hidden models sink so the list reads
73 * top-down as "what the picker shows"; moves only swap rows within the same
74 * group, and the resulting display order is what gets persisted as
75 * `modelOrder`.
76 */
77export function groupModelsForDisplay<
78 T extends { readonly slug: string; readonly isCustom: boolean },
79>(
80 models: ReadonlyArray<T>,
81 options: {
82 readonly favoriteModels: ReadonlySet<string>;
83 readonly hiddenModels: ReadonlySet<string>;
84 readonly modelOrder: ReadonlyArray<string>;
85 },
86): T[] {
87 const ordered = sortModelsForProviderInstance(models, {
88 favoriteModels: options.favoriteModels,
89 groupFavorites: true,
90 modelOrder: options.modelOrder,
91 });
92 const isHidden = (model: T) => !model.isCustom && options.hiddenModels.has(model.slug);
93 return [
94 ...ordered.filter((model) => options.favoriteModels.has(model.slug)),
95 ...ordered.filter((model) => !options.favoriteModels.has(model.slug) && !isHidden(model)),
96 ...ordered.filter((model) => !options.favoriteModels.has(model.slug) && isHidden(model)),
97 ];
98}
99
100export function nextHiddenModelsForBulkToggle(
101 models: ReadonlyArray<Pick<ServerProviderModel, "slug" | "isCustom">>,
102 hiddenModels: ReadonlyArray<string>,
103): string[] {
104 const builtInSlugs = models.filter((model) => !model.isCustom).map((model) => model.slug);
105 const builtInSlugSet = new Set(builtInSlugs);
106 const allBuiltInModelsHidden = builtInSlugs.every((slug) => hiddenModels.includes(slug));
107
108 if (allBuiltInModelsHidden) {
109 return hiddenModels.filter((slug) => !builtInSlugSet.has(slug));
110 }
111
112 return [...new Set([...hiddenModels, ...builtInSlugs])];
113}
114
115interface ProviderModelsSectionProps {
116 /** Identifier used to namespace input ids within the DOM. */
117 readonly instanceId: ProviderInstanceId;
118 /**
119 * Driver kind for slug normalization + input placeholder. `null` when
120 * the section is rendered without enough provider metadata.
121 */
122 readonly driverKind: ProviderDriverKind | null;
123 /**
124 * The live model list to display. Includes both built-in (probe-reported)
125 * and custom entries, distinguished by `isCustom`.
126 */
127 readonly models: ReadonlyArray<ServerProviderModel>;
128 /**
129 * The persisted custom-model list for this instance, resolved. Drives
130 * dedup, and is the list we hand back (with an entry appended / replaced /
131 * removed) via `onChange`.
132 */
133 readonly customModels: ReadonlyArray<CustomModelDefinition>;
134 readonly canManageCustomModels: boolean;
135 readonly canWritePreferences?: boolean;
136 /** Server-returned model slugs hidden from the model picker. */
137 readonly hiddenModels: ReadonlyArray<string>;
138 /** Model slugs favorited for this provider instance. */
139 readonly favoriteModels: ReadonlyArray<string>;
140 /** Explicit user-authored model ordering for this provider instance. */
141 readonly modelOrder: ReadonlyArray<string>;
142 /**
143 * Commit the new custom-model list. Caller is responsible for routing the
144 * write to the correct storage (legacy `settings.providers[kind]` vs.
145 * `providerInstances[id].config`).
146 */
147 readonly onChange: (next: ReadonlyArray<CustomModelDefinition>) => void;
148 readonly onHiddenModelsChange: (
149 next: ReadonlyArray<string>,
150 affectedModels?: ReadonlyArray<string>,
151 ) => void;
152 readonly onFavoriteModelsChange: (next: ReadonlyArray<string>) => void;
153 readonly onToggleFavorite: (model: string) => void;
154 readonly onModelOrderChange: (next: ReadonlyArray<string>) => void;
155}
156
157/**
158 * Shared "Models" section rendered on both the built-in default and custom
159 * provider-instance cards. Owns its own input + error local state so two
160 * cards on screen don't fight over the input value.
161 *
162 * Validation mirrors the pre-consolidation logic in `SettingsPanels`:
163 * - empty / whitespace → "Enter a model slug."
164 * - duplicate of a non-custom (probe-reported) slug → "already built in"
165 * - exceeds `MAX_CUSTOM_MODEL_LENGTH` → length error
166 * - duplicate of an already-saved custom slug → already-saved error
167 */
168export function ProviderModelsSection({
169 instanceId,
170 driverKind,
171 models,
172 customModels,
173 canManageCustomModels,
174 canWritePreferences = true,
175 hiddenModels,
176 favoriteModels,
177 modelOrder,
178 onChange,
179 onHiddenModelsChange,
180 onFavoriteModelsChange,
181 onToggleFavorite,
182 onModelOrderChange,
183}: ProviderModelsSectionProps) {
184 const [input, setInput] = useState("");
185 const [isAdding, setIsAdding] = useState(false);
186 const [filter, setFilter] = useState("");
187 const [error, setError] = useState<string | null>(null);
188 // Slug of the custom model whose inline editor is open, if any.
189 const [editingSlug, setEditingSlug] = useState<string | null>(null);
190 const listRef = useRef<HTMLDivElement>(null);
191 // Slug of a just-added custom model, scrolled into view once its row exists.
192 const scrollToSlugRef = useRef<string | null>(null);
193 const hiddenModelSet = useMemo(() => new Set(hiddenModels), [hiddenModels]);
194 const favoriteModelSet = useMemo(() => new Set(favoriteModels), [favoriteModels]);
195 const displayModels = useMemo(
196 () =>
197 groupModelsForDisplay(models, {
198 favoriteModels: favoriteModelSet,
199 hiddenModels: hiddenModelSet,
200 modelOrder,
201 }),
202 [favoriteModelSet, hiddenModelSet, modelOrder, models],
203 );
204 const favoriteCount = displayModels.filter((model) => favoriteModelSet.has(model.slug)).length;
205 const hiddenCount = displayModels.filter(
206 (model) => !model.isCustom && hiddenModelSet.has(model.slug),
207 ).length;
208 const builtInModels = useMemo(() => models.filter((model) => !model.isCustom), [models]);
209 const allBuiltInModelsHidden =
210 builtInModels.length > 0 && builtInModels.every((model) => hiddenModelSet.has(model.slug));
211 const showFilter = models.length > FILTER_THRESHOLD;
212 const normalizedFilter = filter.trim().toLowerCase();
213 const isFiltering = showFilter && normalizedFilter.length > 0;
214 const visibleModels = isFiltering
215 ? displayModels.filter(
216 (model) =>
217 model.name.toLowerCase().includes(normalizedFilter) ||
218 model.slug.toLowerCase().includes(normalizedFilter),
219 )
220 : displayModels;
221
222 // The parent commits the new custom model and hands back an updated
223 // `models` list, so the row can only be scrolled to after that render.
224 useEffect(() => {
225 const slug = scrollToSlugRef.current;
226 if (slug === null) return;
227 const row = listRef.current?.querySelector<HTMLElement>(
228 `[data-model-slug="${CSS.escape(slug)}"]`,
229 );
230 if (!row) return;
231 scrollToSlugRef.current = null;
232 row.scrollIntoView({ block: "nearest" });
233 }, [displayModels]);
234
235 const handleAdd = () => {
236 if (!canManageCustomModels || driverKind === "antigravity") return;
237 const normalized = normalizeCustomModelSlug(input);
238 if (!normalized) {
239 setError("Enter a model slug.");
240 return;
241 }
242 if (models.some((model) => !model.isCustom && model.slug === normalized)) {
243 setError("That model is already built in.");
244 return;
245 }
246 if (normalized.length > MAX_CUSTOM_MODEL_LENGTH) {
247 setError(`Model slugs must be ${MAX_CUSTOM_MODEL_LENGTH} characters or less.`);
248 return;
249 }
250 if (customModels.some((entry) => entry.slug === normalized)) {
251 setError("That custom model is already saved.");
252 return;
253 }
254
255 // Clear the filter so the new row renders even when it does not match,
256 // which is also what lets the pending scroll target resolve and clear.
257 scrollToSlugRef.current = normalized;
258 setFilter("");
259 onChange([...customModels, { slug: normalized, name: normalized, capabilities: null }]);
260 setInput("");
261 setError(null);
262 setIsAdding(false);
263 };
264
265 const cancelAdd = () => {
266 setInput("");
267 setError(null);
268 setIsAdding(false);
269 };
270
271 const handleRemove = (slug: string) => {
272 if (!canManageCustomModels) return;
273 if (editingSlug === slug) setEditingSlug(null);
274 onChange(customModels.filter((entry) => entry.slug !== slug));
275 onModelOrderChange(modelOrder.filter((model) => model !== slug));
276 onFavoriteModelsChange(favoriteModels.filter((model) => model !== slug));
277 setError(null);
278 };
279
280 const handleSaveEdit = (next: CustomModelDefinition) => {
281 if (!canManageCustomModels) return;
282 onChange(customModels.map((entry) => (entry.slug === next.slug ? next : entry)));
283 setEditingSlug(null);
284 };
285
286 const setHidden = (slug: string, hidden: boolean) => {
287 if (!canWritePreferences || hidden === hiddenModelSet.has(slug)) return;
288 onHiddenModelsChange(
289 hidden ? [...hiddenModels, slug] : hiddenModels.filter((model) => model !== slug),
290 [slug],
291 );
292 };
293
294 const handleToggleFavorite = (slug: string) => {
295 if (!canWritePreferences) return;
296 onToggleFavorite(slug);
297 };
298
299 // Rows only trade places with a neighbour in the same group (favorites,
300 // visible, hidden), and the display order is persisted as the new order.
301 const groupOf = (model: (typeof displayModels)[number]) =>
302 favoriteModelSet.has(model.slug)
303 ? "favorite"
304 : !model.isCustom && hiddenModelSet.has(model.slug)
305 ? "hidden"
306 : "visible";
307 const handleMove = (slug: string, direction: -1 | 1) => {
308 if (!canWritePreferences) return;
309 const index = displayModels.findIndex((model) => model.slug === slug);
310 const nextIndex = index + direction;
311 if (index < 0 || nextIndex < 0 || nextIndex >= displayModels.length) return;
312 if (groupOf(displayModels[index]!) !== groupOf(displayModels[nextIndex]!)) return;
313 const next = displayModels.map((model) => model.slug);
314 [next[index], next[nextIndex]] = [next[nextIndex]!, next[index]!];
315 onModelOrderChange(next);
316 };
317
318 type DisplayModel = (typeof displayModels)[number];
319
320 const starButton = (model: DisplayModel, isFavorite: boolean) => (
321 <Tooltip>
322 <TooltipTrigger
323 render={
324 <Button
325 size="icon-micro"
326 variant="ghost-muted"
327 disabled={!canWritePreferences}
328 onClick={() => handleToggleFavorite(model.slug)}
329 aria-label={`${isFavorite ? "Remove" : "Add"} ${model.name} ${
330 isFavorite ? "from" : "to"
331 } favorites`}
332 />
333 }
334 >
335 <StarIcon className={cn("size-3", isFavorite && "fill-current text-warning")} />
336 </TooltipTrigger>
337 <TooltipPopup side="top">
338 {isFavorite ? "Remove from favorites" : "Add to favorites"}
339 </TooltipPopup>
340 </Tooltip>
341 );
342
343 // Reorder and remove stay in the row at all times (dimmed when unavailable)
344 // so ordering is discoverable without hovering.
345 const rowActions = (
346 model: DisplayModel,
347 options: {
348 readonly isHidden: boolean;
349 readonly canMoveUp: boolean;
350 readonly canMoveDown: boolean;
351 },
352 ) => (
353 <span className="flex shrink-0 items-center justify-end gap-0.5">
354 {!options.isHidden && !isFiltering ? (
355 <>
356 <Tooltip>
357 <TooltipTrigger
358 render={
359 <Button
360 size="icon-micro"
361 variant="ghost-muted"
362 disabled={!canWritePreferences || !options.canMoveUp}
363 onClick={() => handleMove(model.slug, -1)}
364 aria-label={`Move ${model.name} up`}
365 />
366 }
367 >
368 <ArrowUpIcon className="size-3" />
369 </TooltipTrigger>
370 <TooltipPopup side="top">Move up</TooltipPopup>
371 </Tooltip>
372 <Tooltip>
373 <TooltipTrigger
374 render={
375 <Button
376 size="icon-micro"
377 variant="ghost-muted"
378 disabled={!canWritePreferences || !options.canMoveDown}
379 onClick={() => handleMove(model.slug, 1)}
380 aria-label={`Move ${model.name} down`}
381 />
382 }
383 >
384 <ArrowDownIcon className="size-3" />
385 </TooltipTrigger>
386 <TooltipPopup side="top">Move down</TooltipPopup>
387 </Tooltip>
388 </>
389 ) : null}
390 {model.isCustom ? (
391 <>
392 <Tooltip>
393 <TooltipTrigger
394 render={
395 <Button
396 size="icon-micro"
397 variant="ghost-muted"
398 disabled={!canManageCustomModels}
399 aria-label={`Edit ${model.slug}`}
400 onClick={() => {
401 if (!canManageCustomModels) return;
402 setEditingSlug((current) => (current === model.slug ? null : model.slug));
403 }}
404 />
405 }
406 >
407 <PencilIcon className="size-3" />
408 </TooltipTrigger>
409 <TooltipPopup side="top">Edit name and options</TooltipPopup>
410 </Tooltip>
411 <Tooltip>
412 <TooltipTrigger
413 render={
414 <Button
415 size="icon-micro"
416 variant="ghost-muted"
417 disabled={!canManageCustomModels}
418 aria-label={`Remove ${model.slug}`}
419 onClick={() => handleRemove(model.slug)}
420 />
421 }
422 >
423 <XIcon className="size-3" />
424 </TooltipTrigger>
425 <TooltipPopup side="top">Remove custom model</TooltipPopup>
426 </Tooltip>
427 </>
428 ) : null}
429 </span>
430 );
431
432 const pickerTooltip = (model: DisplayModel, isHidden: boolean) =>
433 model.isCustom
434 ? "Custom models are always shown in the picker"
435 : isHidden
436 ? "Hidden from picker"
437 : "Shown in picker";
438
439 // The trigger is a wrapper span: a disabled switch gets no pointer events,
440 // so it could not open the tooltip itself.
441 const pickerSwitch = (model: DisplayModel, isHidden: boolean) => (
442 <Tooltip>
443 <TooltipTrigger render={<span className="flex shrink-0 items-center" />}>
444 <Switch
445 size="sm"
446 checked={!isHidden}
447 disabled={!canWritePreferences || model.isCustom}
448 onCheckedChange={(checked) => setHidden(model.slug, !checked)}
449 aria-label={`Show ${model.name} in the model picker`}
450 />
451 </TooltipTrigger>
452 <TooltipPopup side="top">{pickerTooltip(model, isHidden)}</TooltipPopup>
453 </Tooltip>
454 );
455
456 const renderRow = (model: DisplayModel) => {
457 const capLabels = describeModelCapabilities(model);
458 const group = groupOf(model);
459 // Hidden is read from the preference itself: a favorited model can still be
460 // hidden, and its switch must say so even though it sits in the favorites group.
461 const isHidden = !model.isCustom && hiddenModelSet.has(model.slug);
462 const isFavorite = group === "favorite";
463 const index = displayModels.indexOf(model);
464 const previousModel = displayModels[index - 1];
465 const nextModel = displayModels[index + 1];
466 // Reordering a filtered view would be ambiguous, so arrows only show on
467 // the full list.
468 const canMoveUp =
469 !isFiltering && previousModel !== undefined && groupOf(previousModel) === group;
470 const canMoveDown = !isFiltering && nextModel !== undefined && groupOf(nextModel) === group;
471 const nameClassName = cn("text-xs", isHidden ? "text-muted-foreground" : "text-foreground/90");
472
473 return (
474 <div
475 key={`${instanceId}:${model.slug}`}
476 data-model-slug={model.slug}
477 className={cn(
478 // Actions column is at least wide enough for the four custom-row
479 // buttons so capability labels line up across built-in and custom rows.
480 "grid h-7 grid-cols-[1.5rem_minmax(0,1fr)_auto_minmax(5.5rem,auto)_auto] items-center gap-2 rounded-md px-2 transition-colors hover:bg-muted/30",
481 isHidden && "opacity-50",
482 )}
483 >
484 {starButton(model, isFavorite)}
485 <span className="flex min-w-0 items-baseline gap-2">
486 <span className={cn(nameClassName, "truncate")}>{model.name}</span>
487 {model.name !== model.slug ? (
488 <code className="truncate font-mono text-2xs text-muted-foreground/70">
489 {model.slug}
490 </code>
491 ) : null}
492 {model.isCustom ? (
493 <span className="text-2xs text-muted-foreground/70">custom</span>
494 ) : null}
495 </span>
496 {/*
497 Always a grid item so the columns line up across rows; the text
498 itself drops out on phone widths where it would starve the name.
499 */}
500 <span className="text-2xs text-muted-foreground/70">
501 {capLabels.length > 0 ? (
502 <span className="hidden sm:inline">{capLabels.join(" · ")}</span>
503 ) : null}
504 </span>
505 {rowActions(model, { isHidden, canMoveUp, canMoveDown })}
506 {pickerSwitch(model, isHidden)}
507 </div>
508 );
509 };
510
511 const groupLabel = (label: string, isFirst: boolean) => (
512 <div className={cn("px-2 pb-1.5 text-2xs text-muted-foreground", isFirst ? "pt-1" : "pt-5")}>
513 {label}
514 </div>
515 );
516
517 return (
518 <div className="lg:flex lg:h-full lg:min-h-0 lg:flex-col">
519 <div className="flex flex-wrap items-center justify-between gap-2">
520 {showFilter ? (
521 <Input
522 value={filter}
523 onChange={(event) => setFilter(event.target.value)}
524 placeholder="Filter models"
525 size="sm"
526 className="w-56 max-w-full"
527 spellCheck={false}
528 aria-label="Filter models"
529 />
530 ) : null}
531 <div className="flex items-center gap-2">
532 {builtInModels.length > 0 ? (
533 <Button
534 type="button"
535 size="xs"
536 variant="ghost-muted"
537 onClick={() =>
538 onHiddenModelsChange(
539 nextHiddenModelsForBulkToggle(models, hiddenModels),
540 builtInModels.map((model) => model.slug),
541 )
542 }
543 >
544 {allBuiltInModelsHidden ? "Enable all" : "Disable all"}
545 </Button>
546 ) : null}
547 <span className="text-xs text-muted-foreground">
548 {models.length} model{models.length === 1 ? "" : "s"}
549 {favoriteCount > 0
550 ? ` · ${favoriteCount} favorite${favoriteCount === 1 ? "" : "s"}`
551 : ""}
552 {hiddenCount > 0 ? ` · ${hiddenCount} hidden` : ""}
553 </span>
554 </div>
555 {driverKind !== "antigravity" && !isAdding ? (
556 <Button
557 type="button"
558 size="xs"
559 variant="ghost-muted"
560 className="ml-auto"
561 onClick={() => setIsAdding(true)}
562 >
563 <PlusIcon className="size-3" />
564 Add custom model
565 </Button>
566 ) : null}
567 </div>
568 <div
569 ref={listRef}
570 className="mt-2 -mx-2 max-h-64 overflow-y-auto lg:max-h-none lg:min-h-0 lg:flex-1"
571 >
572 {visibleModels.length === 0 ? (
573 <p className="px-2 py-2 text-xs text-muted-foreground">
574 {isFiltering ? "No models match." : "No models reported for this provider yet."}
575 </p>
576 ) : null}
577 {visibleModels.map((model, index) => {
578 const group = groupOf(model);
579 const previous = visibleModels[index - 1];
580 const startsGroup = previous === undefined || groupOf(previous) !== group;
581 const editingEntry =
582 canManageCustomModels && model.isCustom && editingSlug === model.slug
583 ? customModels.find((entry) => entry.slug === model.slug)
584 : undefined;
585 return (
586 <div key={`${instanceId}:${model.slug}:group`}>
587 {startsGroup && favoriteCount > 0 && group === "favorite"
588 ? groupLabel("Favorites", index === 0)
589 : null}
590 {startsGroup && favoriteCount > 0 && group === "visible"
591 ? groupLabel("All", index === 0)
592 : null}
593 {startsGroup && group === "hidden"
594 ? groupLabel("Hidden from picker", index === 0)
595 : null}
596 {renderRow(model)}
597 {editingEntry ? (
598 <CustomModelEditor
599 key={`${instanceId}:${model.slug}:editor`}
600 instanceId={instanceId}
601 driverKind={driverKind}
602 entry={editingEntry}
603 builtInModels={builtInModels}
604 onSave={handleSaveEdit}
605 onCancel={() => setEditingSlug(null)}
606 />
607 ) : null}
608 </div>
609 );
610 })}
611 </div>
612
613 {driverKind === "antigravity" || !canManageCustomModels ? null : isAdding ? (
614 <div className="mt-3 flex flex-col gap-2 sm:flex-row">
615 <Input
616 id={`provider-instance-${instanceId}-custom-model`}
617 size="sm"
618 autoFocus
619 value={input}
620 onChange={(event) => {
621 setInput(event.target.value);
622 if (error) setError(null);
623 }}
624 onKeyDown={(event) => {
625 if (event.key === "Escape") {
626 event.preventDefault();
627 cancelAdd();
628 return;
629 }
630 if (event.key !== "Enter") return;
631 event.preventDefault();
632 handleAdd();
633 }}
634 placeholder={driverKind ? CUSTOM_MODEL_PLACEHOLDER_BY_KIND[driverKind] : "model-slug"}
635 spellCheck={false}
636 />
637 <div className="flex shrink-0 gap-2">
638 <Button size="sm" variant="outline" onClick={handleAdd}>
639 Add
640 </Button>
641 <Button size="sm" variant="ghost" onClick={cancelAdd}>
642 Cancel
643 </Button>
644 </div>
645 </div>
646 ) : null}
647
648 {driverKind !== "antigravity" && error ? (
649 <p className="mt-2 text-xs text-destructive">{error}</p>
650 ) : null}
651 </div>
652 );
653}