1"use client";
2
3import { Spinner } from "~/components/ui/spinner";
4
5import {
6 AlertTriangleIcon,
7 CheckIcon,
8 ArrowUpCircleIcon,
9 CopyIcon,
10 DownloadIcon,
11 ExternalLinkIcon,
12 LayersIcon,
13 PlusIcon,
14 Trash2Icon,
15 XIcon,
16} from "lucide-react";
17import { Lock as LockGlyph, LockOpen } from "lucide";
18import * as Arr from "effect/Array";
19import * as Result from "effect/Result";
20import { useEffect, useRef, useState, type ReactElement, type ReactNode } from "react";
21import {
22 isProviderDriverKind,
23 resolveProviderInstanceEnabled,
24 type ProviderInstanceConfig,
25 type ProviderInstanceEnvironmentVariable,
26 type ProviderInstanceId,
27 type AcpRegistryUrlAuthAction,
28 type EnvironmentId,
29 type ProjectId,
30 type ProviderDriverKind,
31 type ServerProvider,
32 type ServerProviderModel,
33} from "@t3tools/contracts";
34
35import {
36 type CustomModelDefinition,
37 readCustomModelEntries,
38 toCustomModelSetting,
39} from "@t3tools/shared/model";
40import { cn } from "../../lib/utils";
41import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";
42import { normalizeProviderAccentColor } from "../../providerInstances";
43import { Badge } from "../ui/badge";
44import { Button } from "../ui/button";
45import { MorphIcon } from "~/components/MorphIcon";
46import { DraftInput } from "../ui/draft-input";
47import { SecretInput } from "./SecretInput";
48import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
49import { Switch } from "../ui/switch";
50import { stackedThreadToast, toastManager } from "../ui/toast";
51import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
52import type {
53 ProviderClientDefinition,
54 ProviderEnvironmentField,
55} from "@t3tools/provider-core/client";
56import { deriveProviderSettingsFields, ProviderSettingsForm } from "./ProviderSettingsForm";
57import { ProviderModelsSection } from "./ProviderModelsSection";
58import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon";
59import { ProviderAccentColorPicker } from "./ProviderAccentColorPicker";
60import { RedactedSensitiveText } from "./RedactedSensitiveText";
61import { SettingResetButton, SettingsRow, SettingsSection } from "./settingsLayout";
62import { AcpSessionManagementSection } from "./AcpSessionManagementSection";
63import { FoldedSettingsSection } from "./FoldedSettingsSection";
64import { readCodexSetupMode } from "./CodexSetupSection.logic";
65import {
66 getProviderVersionAdvisoryPresentation,
67 PROVIDER_STATUS_STYLES,
68 getProviderSummary,
69 getProviderVersionLabel,
70 type ProviderStatusKey,
71} from "./providerStatus";
72
73const ENVIRONMENT_VARIABLE_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
74
75function ProviderStatusDiagnostic({
76 detail,
77 children,
78}: {
79 detail: string | null;
80 children: ReactElement;
81}) {
82 if (!detail) return children;
83 return (
84 <Tooltip>
85 <TooltipTrigger render={children} />
86 <TooltipPopup side="top">{detail}</TooltipPopup>
87 </Tooltip>
88 );
89}
90
91let environmentVariableDraftId = 0;
92const nextEnvironmentVariableDraftId = () => `provider-env-${environmentVariableDraftId++}`;
93
94type EnvironmentDraftRow = {
95 readonly id: string;
96 readonly name: string;
97 readonly value: string;
98 readonly sensitive: boolean;
99 readonly valueRedacted?: boolean;
100};
101
102function makeEnvironmentDraftRow(
103 variable: ProviderInstanceEnvironmentVariable,
104 index: number,
105): EnvironmentDraftRow {
106 return {
107 id: `${index}:${variable.name}`,
108 name: variable.name,
109 value: variable.value,
110 sensitive: variable.sensitive,
111 ...(variable.valueRedacted !== undefined ? { valueRedacted: variable.valueRedacted } : {}),
112 };
113}
114
115function providerEnvironmentsEqual(
116 left: ReadonlyArray<ProviderInstanceEnvironmentVariable>,
117 right: ReadonlyArray<ProviderInstanceEnvironmentVariable>,
118): boolean {
119 return (
120 left.length === right.length &&
121 left.every((variable, index) => {
122 const other = right[index];
123 return (
124 other !== undefined &&
125 variable.name === other.name &&
126 variable.value === other.value &&
127 variable.sensitive === other.sensitive &&
128 variable.valueRedacted === other.valueRedacted
129 );
130 })
131 );
132}
133
134/**
135 * Read `customModels` from the opaque config blob. The concrete driver
136 * schemas type it as `CustomModelSetting[]`, but it arrives here as
137 * `Schema.Unknown`, so the shared reader does the shape checking.
138 */
139function readConfigCustomModels(config: unknown): ReadonlyArray<CustomModelDefinition> {
140 if (config === null || typeof config !== "object") return [];
141 return readCustomModelEntries((config as Record<string, unknown>).customModels);
142}
143
144function readConfigString(config: unknown, key: string): string | null {
145 if (config === null || typeof config !== "object") return null;
146 const value = (config as Record<string, unknown>)[key];
147 return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
148}
149
150/**
151 * Set `key` to an arbitrary value on the opaque config blob. Unlike
152 * provider settings field updates, does not drop empty-looking values — the
153 * caller is responsible for deciding whether an empty array / empty
154 * object should be stored explicitly (e.g. `customModels: []` is a
155 * meaningful "user cleared their custom list" state distinct from
156 * "driver default").
157 */
158function nextConfigBlobWithValue(
159 config: unknown,
160 key: string,
161 value: unknown,
162): Record<string, unknown> {
163 const base: Record<string, unknown> =
164 config !== null && typeof config === "object" ? { ...(config as Record<string, unknown>) } : {};
165 base[key] = value;
166 return base;
167}
168
169/**
170 * Custom rows come from current settings so name/descriptor edits show
171 * instantly; a bare entry falls back to the live row's driver-default
172 * capabilities (the server fills those in on its next probe).
173 */
174export function deriveProviderModelsForDisplay(input: {
175 readonly liveModels: ReadonlyArray<ServerProviderModel> | undefined;
176 readonly customModels: ReadonlyArray<CustomModelDefinition>;
177}): ReadonlyArray<ServerProviderModel> {
178 const liveCustomModelsBySlug = new Map(
179 Arr.filterMap(input.liveModels ?? [], (model) =>
180 model.isCustom ? Result.succeed([model.slug, model] as const) : Result.failVoid,
181 ),
182 );
183 const serverModels = input.liveModels?.filter((model) => !model.isCustom) ?? [];
184 const serverSlugs = new Set(serverModels.map((model) => model.slug));
185 // A custom entry with a built-in slug only renames that built-in on the
186 // server, so the built-in row already shows it.
187 const customModels = input.customModels.flatMap((entry) =>
188 serverSlugs.has(entry.slug)
189 ? []
190 : [
191 {
192 slug: entry.slug,
193 name: entry.name,
194 isCustom: true,
195 capabilities:
196 entry.capabilities ?? liveCustomModelsBySlug.get(entry.slug)?.capabilities ?? null,
197 },
198 ],
199 );
200 return [...serverModels, ...customModels];
201}
202
203function ProviderAuthEmail(props: { readonly email: string | undefined }) {
204 const email = props.email?.trim();
205 if (!email) return null;
206
207 return (
208 <RedactedSensitiveText
209 value={email}
210 ariaLabel="Toggle account email visibility"
211 revealTooltip="Click to reveal email"
212 hideTooltip="Click to hide email"
213 className="max-w-full truncate"
214 />
215 );
216}
217
218export function readProviderEnvironmentVariable(
219 environment: ReadonlyArray<ProviderInstanceEnvironmentVariable> | undefined,
220 name: string,
221): ProviderInstanceEnvironmentVariable | undefined {
222 return environment?.find((variable) => variable.name === name);
223}
224
225export function providerEnvironmentWithoutNames(
226 environment: ReadonlyArray<ProviderInstanceEnvironmentVariable> | undefined,
227 names: ReadonlySet<string>,
228): ReadonlyArray<ProviderInstanceEnvironmentVariable> {
229 return (environment ?? []).filter((variable) => !names.has(variable.name));
230}
231
232export function nextProviderEnvironmentWithFieldValue(
233 environment: ReadonlyArray<ProviderInstanceEnvironmentVariable> | undefined,
234 field: ProviderEnvironmentField,
235 value: string,
236): ReadonlyArray<ProviderInstanceEnvironmentVariable> {
237 const trimmed = value.trim();
238 const next: ProviderInstanceEnvironmentVariable[] = [];
239 let found = false;
240
241 for (const variable of environment ?? []) {
242 if (variable.name !== field.name) {
243 next.push(variable);
244 continue;
245 }
246 found = true;
247 if (trimmed.length > 0) {
248 next.push({
249 name: variable.name,
250 value: trimmed,
251 sensitive: field.sensitive ?? true,
252 });
253 }
254 }
255
256 if (!found && trimmed.length > 0) {
257 next.push({
258 name: field.name,
259 value: trimmed,
260 sensitive: field.sensitive ?? true,
261 });
262 }
263
264 return next;
265}
266
267function ProviderEnvironmentFieldRow(props: {
268 readonly field: ProviderEnvironmentField;
269 readonly variable: ProviderInstanceEnvironmentVariable | undefined;
270 readonly idPrefix: string;
271 readonly onCommit: (field: ProviderEnvironmentField, value: string) => void;
272 readonly onRemove: (field: ProviderEnvironmentField) => void;
273}) {
274 const inputId = `${props.idPrefix}-environment-${props.field.name}`;
275 const value = props.variable?.valueRedacted ? "" : (props.variable?.value ?? "");
276
277 return (
278 <SettingsRow
279 title={<label htmlFor={inputId}>{props.field.label}</label>}
280 description={props.field.description}
281 control={
282 <div className="flex w-full min-w-0 items-center gap-2 @min-[32rem]/settings-row:w-56">
283 {props.field.sensitive === false ? (
284 <DraftInput
285 id={inputId}
286 size="sm"
287 className="min-w-0 flex-1"
288 autoComplete="off"
289 value={value}
290 onCommit={(next) => props.onCommit(props.field, next)}
291 placeholder={props.field.placeholder}
292 spellCheck={false}
293 />
294 ) : (
295 <SecretInput
296 id={inputId}
297 size="sm"
298 className="min-w-0 flex-1"
299 value={value}
300 onCommit={(next) => props.onCommit(props.field, next)}
301 placeholder={props.field.placeholder}
302 configured={props.variable?.valueRedacted === true}
303 spellCheck={false}
304 />
305 )}
306 {props.variable ? (
307 <Button
308 type="button"
309 size="icon-sm"
310 variant="ghost-destructive"
311 onClick={() => props.onRemove(props.field)}
312 aria-label={`Clear ${props.field.label}`}
313 >
314 <XIcon className="size-3.5" />
315 </Button>
316 ) : null}
317 </div>
318 }
319 />
320 );
321}
322
323export function ProviderEnvironmentSection(props: {
324 readonly environment: ReadonlyArray<ProviderInstanceEnvironmentVariable>;
325 readonly onChange: (environment: ReadonlyArray<ProviderInstanceEnvironmentVariable>) => void;
326}) {
327 const [rows, setRows] = useState<ReadonlyArray<EnvironmentDraftRow>>(() =>
328 props.environment.map(makeEnvironmentDraftRow),
329 );
330 const previousEnvironmentRef = useRef(props.environment);
331 const lastPublishedEnvironmentRef = useRef<
332 ReadonlyArray<ProviderInstanceEnvironmentVariable> | undefined
333 >(undefined);
334
335 useEffect(() => {
336 const previousEnvironment = previousEnvironmentRef.current;
337 const lastPublishedEnvironment = lastPublishedEnvironmentRef.current;
338 previousEnvironmentRef.current = props.environment;
339 lastPublishedEnvironmentRef.current = undefined;
340 if (
341 previousEnvironment === props.environment ||
342 providerEnvironmentsEqual(previousEnvironment, props.environment) ||
343 (lastPublishedEnvironment !== undefined &&
344 providerEnvironmentsEqual(lastPublishedEnvironment, props.environment))
345 ) {
346 return;
347 }
348 setRows(props.environment.map(makeEnvironmentDraftRow));
349 }, [props.environment]);
350
351 const publishRows = (nextRows: ReadonlyArray<EnvironmentDraftRow>) => {
352 const published: ProviderInstanceEnvironmentVariable[] = [];
353 for (const row of nextRows) {
354 const name = row.name.trim();
355 if (!ENVIRONMENT_VARIABLE_NAME_PATTERN.test(name)) {
356 if (
357 name.length > 0 ||
358 row.value.length > 0 ||
359 row.sensitive !== true ||
360 row.valueRedacted !== undefined
361 ) {
362 return;
363 }
364 continue;
365 }
366 const { id: _id, ...rest } = row;
367 published.push({ ...rest, name });
368 }
369 lastPublishedEnvironmentRef.current = published;
370 props.onChange(published);
371 };
372
373 const updateVariable = (id: string, patch: Partial<Omit<EnvironmentDraftRow, "id">>) => {
374 const nextRows = rows.map((row) =>
375 row.id === id
376 ? {
377 ...row,
378 ...patch,
379 ...(patch.value !== undefined ? { valueRedacted: false } : {}),
380 }
381 : row,
382 );
383 setRows(nextRows);
384 publishRows(nextRows);
385 };
386
387 const removeVariable = (id: string) => {
388 const nextRows = rows.filter((row) => row.id !== id);
389 setRows(nextRows);
390 publishRows(nextRows);
391 };
392
393 const addVariable = () =>
394 setRows([
395 ...rows,
396 {
397 id: nextEnvironmentVariableDraftId(),
398 name: "",
399 value: "",
400 sensitive: true,
401 },
402 ]);
403
404 return (
405 <SettingsRow
406 title="Variables"
407 description="API keys, base URLs, and other per-instance CLI settings."
408 control={
409 <Button type="button" size="sm" variant="outline" onClick={addVariable}>
410 <PlusIcon className="size-3" />
411 Add variable
412 </Button>
413 }
414 >
415 {rows.length > 0 ? (
416 <div className="mt-3 min-w-0 space-y-2 pb-2">
417 {rows.map((variable, index) => (
418 <div key={variable.id} className="flex min-w-0 flex-wrap items-center gap-1.5">
419 <DraftInput
420 size="sm"
421 font="mono"
422 className="w-full min-w-0 sm:w-44 sm:shrink-0"
423 value={variable.name}
424 onCommit={(name) => updateVariable(variable.id, { name: name.trim() })}
425 placeholder="VARIABLE_NAME"
426 spellCheck={false}
427 aria-label={`Environment variable name ${index + 1}`}
428 />
429 <span className="hidden text-xs text-muted-foreground sm:inline" aria-hidden>
430 =
431 </span>
432 {variable.sensitive ? (
433 <SecretInput
434 size="sm"
435 font="mono"
436 className="min-w-0 flex-1"
437 value={variable.valueRedacted ? "" : variable.value}
438 onCommit={(value) => updateVariable(variable.id, { value })}
439 placeholder="value"
440 configured={variable.valueRedacted === true}
441 spellCheck={false}
442 aria-label={`Environment variable value ${index + 1}`}
443 />
444 ) : (
445 <DraftInput
446 size="sm"
447 font="mono"
448 className="min-w-0 flex-1"
449 value={variable.valueRedacted ? "" : variable.value}
450 onCommit={(value) => updateVariable(variable.id, { value })}
451 autoComplete="off"
452 placeholder="value"
453 spellCheck={false}
454 aria-label={`Environment variable value ${index + 1}`}
455 />
456 )}
457 <Tooltip>
458 <TooltipTrigger
459 render={
460 <Button
461 type="button"
462 size="icon-micro"
463 variant="ghost-muted"
464 onClick={() => {
465 const sensitive = !variable.sensitive;
466 updateVariable(variable.id, {
467 sensitive,
468 ...(sensitive && variable.valueRedacted === undefined
469 ? {}
470 : { valueRedacted: sensitive ? variable.valueRedacted : false }),
471 });
472 }}
473 aria-pressed={variable.sensitive}
474 aria-label={`Mark environment variable ${variable.name || index + 1} as sensitive`}
475 >
476 <MorphIcon
477 className="size-3"
478 icon={variable.sensitive ? LockGlyph : LockOpen}
479 />
480 </Button>
481 }
482 />
483 <TooltipPopup side="top">
484 {variable.sensitive ? "Sensitive, stored separately" : "Plain text"}
485 </TooltipPopup>
486 </Tooltip>
487 <Button
488 type="button"
489 size="icon-micro"
490 variant="ghost-destructive"
491 onClick={() => removeVariable(variable.id)}
492 aria-label={`Remove environment variable ${variable.name || index + 1}`}
493 >
494 <XIcon className="size-3" />
495 </Button>
496 </div>
497 ))}
498 <p className="text-xs text-muted-foreground">
499 Sensitive values are stored separately and never returned to the app.
500 </p>
501 </div>
502 ) : null}
503 </SettingsRow>
504 );
505}
506
507/**
508 * When a project is in scope, the enable control reads and writes that
509 * project's override instead of the environment's `instance.enabled`.
510 * `value` is the project's explicit override (`undefined` means it
511 * inherits the environment); `effectiveEnabled` is what the Switch shows.
512 */
513/**
514 * The project-scope counterpart of a settings row's inheritance indicator:
515 * hover for the summary, open for the project and device values side by side.
516 */
517function ProjectOverrideInheritance({
518 projectEnabled,
519 deviceEnabled,
520}: {
521 readonly projectEnabled: boolean;
522 readonly deviceEnabled: boolean;
523}) {
524 const layers = [
525 { key: "project", label: "Project", enabled: projectEnabled, effective: true },
526 { key: "device", label: "Device", enabled: deviceEnabled, effective: false },
527 ];
528 return (
529 <Popover>
530 <Tooltip>
531 <TooltipTrigger
532 render={
533 <PopoverTrigger
534 render={
535 <Button
536 size="icon-micro"
537 variant="ghost-muted"
538 aria-label="Overridden for this project. Show where this value comes from"
539 />
540 }
541 />
542 }
543 >
544 <LayersIcon className="size-3 text-primary" />
545 </TooltipTrigger>
546 <TooltipPopup side="top">Overridden for this project</TooltipPopup>
547 </Tooltip>
548 <PopoverPopup align="end" width="md" padding="none">
549 <ol role="list" className="px-3 py-2.5 text-sm">
550 {layers.map((layer) => (
551 <li
552 key={layer.key}
553 className={cn(
554 "grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 rounded-md px-2 py-1",
555 layer.effective && "bg-foreground/[0.06]",
556 )}
557 >
558 <span
559 className={cn(
560 "min-w-0 truncate",
561 layer.effective ? "font-medium text-foreground" : "text-muted-foreground",
562 )}
563 >
564 {layer.label}
565 </span>
566 <span
567 className={cn(
568 "flex items-center gap-1.5",
569 layer.effective ? "text-foreground" : "text-muted-foreground",
570 )}
571 >
572 {layer.enabled ? "On" : "Off"}
573 {layer.effective ? (
574 <CheckIcon aria-hidden className="size-3.5 shrink-0 text-primary" />
575 ) : (
576 <span aria-hidden className="size-3.5 shrink-0" />
577 )}
578 </span>
579 </li>
580 ))}
581 </ol>
582 </PopoverPopup>
583 </Popover>
584 );
585}
586
587interface ProjectOverrideControl {
588 readonly value: boolean | undefined;
589 readonly effectiveEnabled: boolean;
590 readonly onChange: (next: boolean) => void;
591 readonly onReset: () => void;
592}
593
594interface ProviderInstanceCardProps {
595 readonly instanceId: ProviderInstanceId;
596 readonly instance: ProviderInstanceConfig;
597 readonly driverOption: ProviderClientDefinition | undefined;
598 readonly liveProvider: ServerProvider | undefined;
599 readonly mode: "list" | "editor";
600 readonly selected?: boolean | undefined;
601 readonly onSelect?: (() => void) | undefined;
602 readonly readOnly?: boolean | undefined;
603 readonly canWriteSettings?: boolean;
604 readonly onUpdate: (nextInstance: ProviderInstanceConfig) => void;
605 /**
606 * Pass `undefined` to hide the delete footer entirely. Built-in default
607 * instance slots use `undefined` — they can't be deleted without losing
608 * the slot, and their "reset to defaults" affordance lives on an outer
609 * reset button instead. Explicit `| undefined` in the type accommodates
610 * `exactOptionalPropertyTypes: true`, where an absent key and
611 * `{ onDelete: undefined }` are treated as distinct shapes.
612 */
613 readonly onDelete?: (() => void) | undefined;
614 /**
615 * Optional outer reset button rendered next to the driver icon. Built-in
616 * default slots supply a reset-to-factory control here; custom instances
617 * omit it.
618 */
619 readonly headerAction?: ReactNode | undefined;
620 readonly setup?: ReactNode;
621 readonly runtime?: ReactNode;
622 readonly hiddenModels: ReadonlyArray<string>;
623 readonly favoriteModels: ReadonlyArray<string>;
624 readonly modelOrder: ReadonlyArray<string>;
625 readonly onHiddenModelsChange: (
626 next: ReadonlyArray<string>,
627 affectedModels?: ReadonlyArray<string>,
628 ) => void;
629 readonly onFavoriteModelsChange: (next: ReadonlyArray<string>) => void;
630 readonly onToggleFavorite: (model: string) => void;
631 readonly onModelOrderChange: (next: ReadonlyArray<string>) => void;
632 readonly onRunUpdate?: (() => void) | undefined;
633 readonly onInstallRecommended?: (() => void) | undefined;
634 readonly isUpdating?: boolean | undefined;
635 readonly onAcceptUrlAuth?: ((action: AcpRegistryUrlAuthAction) => void) | undefined;
636 readonly environmentId?: EnvironmentId | undefined;
637 /** Omit this prop entirely for environment-wide editing. */
638 readonly projectOverride?: ProjectOverrideControl | undefined;
639 readonly acpProjects?:
640 | ReadonlyArray<{
641 readonly id: ProjectId;
642 readonly title: string;
643 readonly workspaceRoot: string;
644 }>
645 | undefined;
646}
647
648const EMPTY_ACP_PROJECTS: NonNullable<ProviderInstanceCardProps["acpProjects"]> = [];
649
650/**
651 * Renders one provider instance as either a compact selectable list row or
652 * the full editor shown beside that list. Both modes use the same enabled
653 * state and provider metadata.
654 *
655 * Behavior notes:
656 * - `liveProvider` is matched by the caller via `instanceId`; when no
657 * match is available (e.g. the server hasn't checked it yet, or the
658 * driver is not shipped by the current build) the card still renders
659 * with a neutral "checking" summary.
660 * - Unknown drivers (`driverOption === undefined`) get a read-only
661 * notice instead of editable fields, so fork instances round-trip
662 * without accidentally destroying their config.
663 * - The enabled Switch writes to the envelope's `instance.enabled`
664 * field, which is the single enabled flag: the server folds any legacy
665 * driver-specific `config.enabled` into the envelope on load and both
666 * sides resolve through `resolveProviderInstanceEnabled` (an explicit
667 * false wins, then envelope, then config, then the driver default).
668 * With `projectOverride` set, the Switch instead reads and writes
669 * that project's override, leaving the environment's `instance.enabled`
670 * untouched.
671 */
672export function ProviderInstanceCard({
673 instanceId,
674 instance,
675 driverOption,
676 liveProvider,
677 mode,
678 selected = false,
679 onSelect,
680 readOnly = false,
681 canWriteSettings = true,
682 onUpdate,
683 onDelete,
684 headerAction,
685 setup,
686 runtime,
687 hiddenModels,
688 favoriteModels,
689 modelOrder,
690 onHiddenModelsChange,
691 onFavoriteModelsChange,
692 onToggleFavorite,
693 onModelOrderChange,
694 onRunUpdate,
695 onInstallRecommended,
696 isUpdating = false,
697 onAcceptUrlAuth,
698 environmentId,
699 acpProjects = EMPTY_ACP_PROJECTS,
700 projectOverride,
701}: ProviderInstanceCardProps) {
702 const enabled = projectOverride?.effectiveEnabled ?? resolveProviderInstanceEnabled(instance);
703 const compatibility = enabled ? liveProvider?.compatibilityAdvisory : undefined;
704 // A locally disabled provider reads "Disabled" with a muted dot even if its
705 // last server status is stale. Enabled providers use the server status.
706 const statusKey: ProviderStatusKey = enabled
707 ? ((liveProvider?.status as ProviderStatusKey | undefined) ?? "warning")
708 : "disabled";
709 const statusStyle = PROVIDER_STATUS_STYLES[statusKey];
710 // `liveProvider.enabled` is machine-wide; a project override can turn it on.
711 const summary = enabled
712 ? getProviderSummary(liveProvider && { ...liveProvider, enabled: true })
713 : { headline: "Disabled", detail: null };
714 const authEmail = liveProvider?.auth.email?.trim();
715 const isAuthenticated = enabled && liveProvider?.auth.status === "authenticated";
716 const authLabel =
717 enabled && liveProvider?.auth.status === "authenticated"
718 ? (liveProvider.auth.label ?? liveProvider.auth.type ?? null)
719 : null;
720 const versionLabel = getProviderVersionLabel(liveProvider?.version);
721 const versionAdvisory = getProviderVersionAdvisoryPresentation(
722 liveProvider?.versionAdvisory,
723 liveProvider?.compatibilityAdvisory,
724 enabled,
725 );
726 const updateCommand = versionAdvisory?.updateCommand ?? null;
727 const updateState = liveProvider?.updateState;
728 // The server reports each update step. `isUpdating` also covers the moment
729 // between the click and the server's first report.
730 const updateProgress = isUpdating
731 ? ((updateState?.status === "queued" || updateState?.status === "running"
732 ? updateState.message
733 : null) ?? "Starting update")
734 : null;
735 const updateProblem =
736 !isUpdating && (updateState?.status === "failed" || updateState?.status === "unchanged")
737 ? updateState.message
738 : null;
739 const hasCompatibilityWarning =
740 compatibility !== undefined &&
741 compatibility.status !== "supported" &&
742 compatibility.status !== "unknown";
743 const VersionAdvisoryIcon = hasCompatibilityWarning ? AlertTriangleIcon : ArrowUpCircleIcon;
744 const onRunVersionAction = readOnly
745 ? undefined
746 : versionAdvisory?.targetVersion
747 ? onInstallRecommended
748 : onRunUpdate;
749 const urlAuthAction = liveProvider?.auth.action;
750 const displayName =
751 instance.displayName?.trim() || driverOption?.label || String(instance.driver);
752 const accentColor = normalizeProviderAccentColor(instance.accentColor);
753 const { copyToClipboard } = useCopyToClipboard<{ providerName: string }>({
754 onCopy: ({ providerName }) => {
755 toastManager.add({
756 type: "success",
757 title: `${providerName} update command copied`,
758 description: "Run it in a terminal when you are ready to update.",
759 });
760 },
761 onError: (error, { providerName }) => {
762 toastManager.add(
763 stackedThreadToast({
764 type: "error",
765 title: `Could not copy ${providerName} update command`,
766 description: error.message,
767 }),
768 );
769 },
770 });
771
772 // Narrow `instance.driver` for callers that key on the closed
773 // `ProviderDriverKind` union (e.g. `normalizeModelSlug`'s alias table). Custom
774 // fork drivers pass through as `null` and those callers fall back to
775 // verbatim behaviour.
776 const driverKind: ProviderDriverKind | null = isProviderDriverKind(instance.driver)
777 ? instance.driver
778 : null;
779 const customModels =
780 instance.driver === "antigravity" ? [] : readConfigCustomModels(instance.config);
781 // Server-returned models may lag behind settings writes. Treat server
782 // models as the source for built-ins only; custom rows come directly
783 // from the current instance config so add/remove reflects immediately.
784 const modelsForDisplay = deriveProviderModelsForDisplay({
785 liveModels: liveProvider?.models,
786 customModels,
787 });
788 const updateDisplayName = (value: string) => {
789 const trimmed = value.trim();
790 const { displayName: _omit, ...rest } = instance;
791 onUpdate(
792 trimmed.length > 0
793 ? ({ ...rest, displayName: trimmed } as ProviderInstanceConfig)
794 : (rest as ProviderInstanceConfig),
795 );
796 };
797
798 const updateEnabled = (value: boolean) => {
799 if (projectOverride) {
800 projectOverride.onChange(value);
801 return;
802 }
803 onUpdate({ ...instance, enabled: value });
804 };
805
806 const updateAccentColor = (value: string) => {
807 const normalized = normalizeProviderAccentColor(value);
808 const { accentColor: _omit, ...rest } = instance;
809 onUpdate(
810 normalized
811 ? ({ ...rest, accentColor: normalized } as ProviderInstanceConfig)
812 : (rest as ProviderInstanceConfig),
813 );
814 };
815
816 const updateConfig = (nextConfig: Record<string, unknown> | undefined) => {
817 const { config: _omit, ...rest } = instance;
818 onUpdate(
819 nextConfig !== undefined
820 ? ({ ...rest, config: nextConfig } as ProviderInstanceConfig)
821 : (rest as ProviderInstanceConfig),
822 );
823 };
824
825 const updateCustomModels = (next: ReadonlyArray<CustomModelDefinition>) => {
826 const nextConfig = nextConfigBlobWithValue(
827 instance.config,
828 "customModels",
829 next.map(toCustomModelSetting),
830 );
831 const { config: _omit, ...rest } = instance;
832 onUpdate({ ...rest, config: nextConfig } as ProviderInstanceConfig);
833 };
834
835 const updateEnvironment = (environment: ReadonlyArray<ProviderInstanceEnvironmentVariable>) => {
836 const cleaned = environment.filter((variable) => variable.name.trim().length > 0);
837 const { environment: _omit, ...rest } = instance;
838 onUpdate(
839 cleaned.length > 0
840 ? ({ ...rest, environment: cleaned } as ProviderInstanceConfig)
841 : (rest as ProviderInstanceConfig),
842 );
843 };
844 // Drivers that need a named secret (Cursor's API key) get a dedicated field;
845 // the generic editor only shows the remaining variables.
846 const environmentFields = driverOption?.environmentFields ?? [];
847 const environmentFieldNames = new Set(environmentFields.map((field) => field.name));
848 const genericEnvironment = providerEnvironmentWithoutNames(
849 instance.environment,
850 environmentFieldNames,
851 );
852 const updateGenericEnvironment = (
853 environment: ReadonlyArray<ProviderInstanceEnvironmentVariable>,
854 ) => {
855 const dedicatedEnvironment = (instance.environment ?? []).filter((variable) =>
856 environmentFieldNames.has(variable.name),
857 );
858 updateEnvironment([...dedicatedEnvironment, ...environment]);
859 };
860 const updateEnvironmentField = (field: ProviderEnvironmentField, value: string) => {
861 updateEnvironment(nextProviderEnvironmentWithFieldValue(instance.environment, field, value));
862 };
863 const removeEnvironmentField = (field: ProviderEnvironmentField) => {
864 updateEnvironment(providerEnvironmentWithoutNames(instance.environment, new Set([field.name])));
865 };
866
867 const titleIconNode = (
868 <ProviderInstanceIcon
869 driverKind={driverKind ?? instance.driver}
870 displayName={displayName}
871 accentColor={accentColor}
872 acpRegistryAgentId={
873 readConfigString(instance.config, "source") === "local"
874 ? undefined
875 : (readConfigString(instance.config, "agentId") ?? undefined)
876 }
877 acpRegistryIconUrl={
878 readConfigString(instance.config, "source") === "local"
879 ? undefined
880 : (readConfigString(instance.config, "registryIconUrl") ?? undefined)
881 }
882 showBadge={Boolean(accentColor)}
883 className="size-5"
884 iconClassName="size-4 text-foreground/80"
885 badgeClassName="right-[-0.125rem] bottom-[-0.125rem] h-3 min-w-3 px-0.5 text-5xs"
886 />
887 );
888
889 const titleTailNode = headerAction ? (
890 <span className="inline-flex h-5 w-5 shrink-0 items-center justify-center">{headerAction}</span>
891 ) : null;
892
893 const versionCodeNode = versionLabel ? (
894 <code className="text-xs text-muted-foreground">{versionLabel}</code>
895 ) : null;
896
897 // Healthy and disabled rows read fine from their text; only trouble gets a dot.
898 const statusDotNode =
899 statusKey === "warning" || statusKey === "error" ? (
900 <span className={cn("size-1.5 shrink-0 rounded-full", statusStyle.dot)} aria-hidden />
901 ) : null;
902 const needsAttention = statusKey === "warning" || statusKey === "error";
903 const statusDiagnostic = hasCompatibilityWarning && needsAttention ? summary.detail : null;
904 // Keep compatibility copy compact; the version popover carries the explanation.
905 const inlineStatusDetail = hasCompatibilityWarning
906 ? compatibility?.status === "broken"
907 ? "Incompatible"
908 : compatibility?.status === "unsupported"
909 ? "Unsupported"
910 : "Limited support"
911 : summary.detail;
912 const editorStatusNode =
913 isAuthenticated && authEmail ? (
914 <>
915 {needsAttention ? statusDotNode : null}
916 <span>Authenticated as</span>
917 <ProviderAuthEmail email={authEmail} />
918 {authLabel ? <span>· {authLabel}</span> : null}
919 {inlineStatusDetail ? (
920 <span className="min-w-0 [overflow-wrap:anywhere]">· {inlineStatusDetail}</span>
921 ) : null}
922 </>
923 ) : (
924 <>
925 {statusDotNode}
926 <span>{summary.headline}</span>
927 {inlineStatusDetail ? (
928 <span className="min-w-0 [overflow-wrap:anywhere]">· {inlineStatusDetail}</span>
929 ) : null}
930 </>
931 );
932 const versionAdvisoryNode = versionAdvisory ? (
933 <Popover>
934 <Tooltip>
935 <TooltipTrigger
936 render={
937 <PopoverTrigger
938 render={
939 <Button
940 type="button"
941 size={mode === "list" ? "icon-micro" : "icon-xs"}
942 variant="ghost-muted"
943 className={mode === "list" ? "pointer-events-auto relative shrink-0" : undefined}
944 aria-label={`${updateProgress ? "Updating" : versionAdvisory.title} — view details`}
945 >
946 {updateProgress ? (
947 <Spinner tone="muted" {...(mode === "list" ? { size: "sm" as const } : {})} />
948 ) : (
949 <VersionAdvisoryIcon
950 className={cn(
951 mode === "list" && "size-3.5",
952 hasCompatibilityWarning && "text-warning",
953 )}
954 />
955 )}
956 </Button>
957 }
958 />
959 }
960 />
961 <TooltipPopup side="top">
962 {updateProgress ? "Updating" : versionAdvisory.title}
963 </TooltipPopup>
964 </Tooltip>
965 <PopoverPopup side="bottom" align="end" width="md" aria-label={versionAdvisory.title}>
966 <div className="grid min-w-0 gap-3">
967 <div className="grid gap-0.5">
968 <p className="text-sm font-semibold leading-tight text-foreground">
969 {versionAdvisory.title}
970 </p>
971 <p
972 className={cn(
973 "text-xs leading-snug",
974 versionAdvisory.emphasis === "strong" ? "text-warning" : "text-muted-foreground",
975 )}
976 >
977 {versionAdvisory.detail}
978 </p>
979 </div>
980 {onRunVersionAction ? (
981 <Button
982 type="button"
983 size="xs"
984 variant="outline"
985 className="w-full"
986 disabled={isUpdating}
987 onClick={onRunVersionAction}
988 >
989 {isUpdating ? <Spinner /> : <DownloadIcon />}
990 {isUpdating
991 ? "Updating"
992 : versionAdvisory.targetVersion
993 ? `Install ${getProviderVersionLabel(versionAdvisory.targetVersion)}`
994 : "Update now"}
995 </Button>
996 ) : null}
997 {updateProgress || updateProblem ? (
998 <p
999 aria-live="polite"
1000 className={cn(
1001 "text-xs leading-snug [overflow-wrap:anywhere]",
1002 updateProblem ? "text-warning" : "text-muted-foreground",
1003 )}
1004 >
1005 {updateProgress ?? updateProblem}
1006 </p>
1007 ) : null}
1008 {onRunVersionAction && updateCommand ? (
1009 <div className="flex items-center gap-2 text-3xs font-medium uppercase tracking-wider text-muted-foreground">
1010 <span aria-hidden className="h-px flex-1 bg-border" />
1011 or, update manually using
1012 <span aria-hidden className="h-px flex-1 bg-border" />
1013 </div>
1014 ) : null}
1015 {updateCommand ? (
1016 <div className="flex min-w-0 items-center gap-1 rounded-md border border-border/70 bg-muted/40 py-0.5 pr-0.5 pl-2">
1017 <code className="min-w-0 flex-1 truncate font-mono text-2xs text-foreground">
1018 {updateCommand}
1019 </code>
1020 <Tooltip>
1021 <TooltipTrigger
1022 render={
1023 <Button
1024 type="button"
1025 size="icon-xs"
1026 variant="ghost-muted"
1027 className="shrink-0"
1028 onClick={() => copyToClipboard(updateCommand, { providerName: displayName })}
1029 aria-label="Copy update command"
1030 >
1031 <CopyIcon className="size-3" />
1032 </Button>
1033 }
1034 />
1035 <TooltipPopup side="top">Copy command</TooltipPopup>
1036 </Tooltip>
1037 </div>
1038 ) : null}
1039 </div>
1040 </PopoverPopup>
1041 </Popover>
1042 ) : null;
1043
1044 if (mode === "list") {
1045 return (
1046 <div
1047 data-slot="settings-row"
1048 className={cn(
1049 "group flex min-h-18 items-center gap-3 px-3 py-3 transition-colors sm:px-4",
1050 selected ? "bg-muted/45" : "hover:bg-muted/25",
1051 )}
1052 >
1053 <div
1054 className={cn(
1055 "pointer-events-none relative flex min-w-0 flex-1 items-start gap-3 rounded-md text-left transition-opacity",
1056 !enabled && !selected && "opacity-60 group-hover:opacity-100",
1057 )}
1058 >
1059 <button
1060 type="button"
1061 className="pointer-events-auto absolute inset-0 cursor-pointer rounded-md outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
1062 onClick={onSelect}
1063 aria-label={`Select ${displayName}`}
1064 aria-pressed={selected}
1065 />
1066 {titleIconNode}
1067 <span className="min-w-0 flex-1">
1068 <span className="flex min-w-0 items-center gap-2">
1069 <span className="truncate text-sm font-medium text-foreground">{displayName}</span>
1070 {versionLabel ? (
1071 <code className="max-w-24 shrink-0 truncate text-xs text-muted-foreground">
1072 {versionLabel}
1073 </code>
1074 ) : null}
1075 {versionAdvisoryNode}
1076 </span>
1077 <span className="mt-0.5 flex items-start gap-1.5 text-xs leading-normal text-muted-foreground/80">
1078 {/* The dot describes provider health, not the update in progress. */}
1079 {statusDotNode && !updateProgress ? (
1080 <span className="flex h-[1.45em] shrink-0 items-center">{statusDotNode}</span>
1081 ) : null}
1082 <ProviderStatusDiagnostic detail={statusDiagnostic}>
1083 <span
1084 tabIndex={statusDiagnostic ? 0 : undefined}
1085 aria-live="polite"
1086 className="pointer-events-auto line-clamp-2 [overflow-wrap:anywhere]"
1087 >
1088 {updateProgress ? (
1089 `Updating · ${updateProgress}`
1090 ) : (
1091 <>
1092 {summary.headline}
1093 {needsAttention && inlineStatusDetail ? ` · ${inlineStatusDetail}` : null}
1094 </>
1095 )}
1096 </span>
1097 </ProviderStatusDiagnostic>
1098 </span>
1099 </span>
1100 </div>
1101 <span className="flex h-5 shrink-0 items-center gap-1">
1102 {projectOverride?.value !== undefined ? (
1103 <span className="flex items-center">
1104 <ProjectOverrideInheritance
1105 projectEnabled={projectOverride.value}
1106 deviceEnabled={resolveProviderInstanceEnabled(instance)}
1107 />
1108 <SettingResetButton
1109 label={`${displayName} enablement`}
1110 tooltip="Reset to the device setting"
1111 disabled={readOnly || !canWriteSettings}
1112 onClick={projectOverride.onReset}
1113 />
1114 </span>
1115 ) : null}
1116 <Switch
1117 checked={enabled}
1118 disabled={readOnly || (projectOverride !== undefined && !canWriteSettings)}
1119 onCheckedChange={(checked) => updateEnabled(Boolean(checked))}
1120 aria-label={`Enable ${displayName}`}
1121 />
1122 </span>
1123 </div>
1124 );
1125 }
1126
1127 const editorHeaderAction = (
1128 <div className="flex shrink-0 items-center gap-1.5">
1129 {driverOption?.badgeLabel ? (
1130 <Badge variant="warning" size="sm" className="shrink-0">
1131 {driverOption.badgeLabel}
1132 </Badge>
1133 ) : null}
1134 {versionCodeNode}
1135 {versionAdvisoryNode}
1136 <span
1137 inert={readOnly}
1138 aria-disabled={readOnly || undefined}
1139 className={cn("inline-flex items-center gap-1", readOnly && "opacity-50")}
1140 >
1141 {titleTailNode}
1142 {onDelete ? (
1143 <Button
1144 type="button"
1145 size="icon-xs"
1146 variant="ghost-destructive"
1147 disabled={readOnly}
1148 onClick={onDelete}
1149 aria-label={`Delete instance ${instanceId}`}
1150 >
1151 <Trash2Icon />
1152 </Button>
1153 ) : null}
1154 </span>
1155 </div>
1156 );
1157
1158 const runtimeFields = driverOption ? (
1159 <ProviderSettingsForm
1160 definition={driverOption}
1161 value={instance.config}
1162 idPrefix={`provider-instance-${instanceId}`}
1163 variant="settings"
1164 onChange={updateConfig}
1165 />
1166 ) : (
1167 <SettingsRow
1168 title="Driver"
1169 description={
1170 <span>
1171 This instance uses <code className="text-foreground">{String(instance.driver)}</code>,
1172 which is not available in this build. Its configuration is preserved.
1173 </span>
1174 }
1175 />
1176 );
1177
1178 return (
1179 <>
1180 <SettingsSection title={displayName} icon={titleIconNode} headerAction={editorHeaderAction}>
1181 <SettingsRow
1182 title="Display name"
1183 status={
1184 <>
1185 <ProviderStatusDiagnostic detail={statusDiagnostic}>
1186 <div
1187 tabIndex={statusDiagnostic ? 0 : undefined}
1188 className="flex min-w-0 flex-wrap items-baseline gap-x-1.5"
1189 >
1190 {editorStatusNode}
1191 </div>
1192 </ProviderStatusDiagnostic>
1193 {urlAuthAction && onAcceptUrlAuth ? (
1194 <div className="grid max-w-xl gap-1.5 pt-1 text-xs">
1195 <p>{urlAuthAction.message}</p>
1196 <code className="break-all text-2xs">{urlAuthAction.url}</code>
1197 <Button
1198 render={
1199 <a
1200 href={urlAuthAction.url}
1201 target="_blank"
1202 rel="noopener noreferrer"
1203 onClick={() => onAcceptUrlAuth(urlAuthAction)}
1204 />
1205 }
1206 size="xs"
1207 variant="outline"
1208 className="w-fit"
1209 disabled={readOnly}
1210 >
1211 <ExternalLinkIcon />
1212 Continue authentication
1213 </Button>
1214 </div>
1215 ) : null}
1216 </>
1217 }
1218 control={
1219 <div
1220 inert={readOnly}
1221 aria-disabled={readOnly || undefined}
1222 className={cn(
1223 "flex w-full min-w-0 items-center justify-end gap-2 @min-[32rem]/settings-row:w-auto",
1224 readOnly && "opacity-50 select-none",
1225 )}
1226 >
1227 <ProviderAccentColorPicker
1228 layout="inline"
1229 displayName={displayName}
1230 value={accentColor}
1231 onCommit={updateAccentColor}
1232 commitDelayMs={120}
1233 />
1234 <DraftInput
1235 id={`provider-instance-${instanceId}-display-name`}
1236 size="sm"
1237 className="min-w-0 flex-1 @min-[32rem]/settings-row:w-56"
1238 value={instance.displayName ?? ""}
1239 onCommit={updateDisplayName}
1240 placeholder={driverOption?.label ?? "Instance label"}
1241 spellCheck={false}
1242 />
1243 </div>
1244 }
1245 />
1246 </SettingsSection>
1247
1248 {setup || environmentFields.length > 0 ? (
1249 <SettingsSection title="Setup">
1250 {setup}
1251 <div
1252 inert={readOnly}
1253 aria-disabled={readOnly || undefined}
1254 className={readOnly ? "opacity-50 select-none" : undefined}
1255 >
1256 {environmentFields.length > 0 ? (
1257 <>
1258 {environmentFields.map((field) => (
1259 <ProviderEnvironmentFieldRow
1260 key={field.name}
1261 field={field}
1262 variable={readProviderEnvironmentVariable(instance.environment, field.name)}
1263 idPrefix={`provider-instance-${instanceId}`}
1264 onCommit={updateEnvironmentField}
1265 onRemove={removeEnvironmentField}
1266 />
1267 ))}
1268 </>
1269 ) : null}
1270 </div>
1271 </SettingsSection>
1272 ) : null}
1273
1274 {instance.driver === "codex" && readCodexSetupMode(instance.config) === "managed" ? (
1275 <div
1276 inert={readOnly}
1277 aria-disabled={readOnly || undefined}
1278 className={readOnly ? "opacity-50 select-none" : undefined}
1279 >
1280 <FoldedSettingsSection
1281 key={instanceId}
1282 id={`provider-instance-${instanceId}-runtime`}
1283 title="Runtime"
1284 headerPlacement="outside"
1285 >
1286 {runtime ?? runtimeFields}
1287 </FoldedSettingsSection>
1288 </div>
1289 ) : !driverOption || deriveProviderSettingsFields(driverOption).length > 0 ? (
1290 <SettingsSection
1291 title="Runtime"
1292 inert={readOnly}
1293 aria-disabled={readOnly || undefined}
1294 className={readOnly ? "opacity-50 select-none" : undefined}
1295 >
1296 {runtimeFields}
1297 </SettingsSection>
1298 ) : null}
1299
1300 <SettingsSection
1301 title="Environment"
1302 inert={readOnly}
1303 aria-disabled={readOnly || undefined}
1304 className={readOnly ? "opacity-50 select-none" : undefined}
1305 >
1306 <ProviderEnvironmentSection
1307 environment={genericEnvironment}
1308 onChange={updateGenericEnvironment}
1309 />
1310 </SettingsSection>
1311 {environmentId !== undefined && liveProvider?.driver === "acpRegistry" ? (
1312 <AcpSessionManagementSection
1313 environmentId={environmentId}
1314 instanceId={instanceId}
1315 provider={liveProvider}
1316 projects={acpProjects}
1317 readOnly={readOnly}
1318 />
1319 ) : null}
1320
1321 {driverOption !== undefined ? (
1322 <SettingsSection title="Models">
1323 <div className="px-3 py-3 sm:px-4">
1324 <p className="mb-3 text-xs text-muted-foreground">
1325 Model preferences and custom models are saved on the selected environment and shared
1326 across connected devices.
1327 </p>
1328 <ProviderModelsSection
1329 canManageCustomModels={!readOnly}
1330 canWritePreferences={canWriteSettings}
1331 instanceId={instanceId}
1332 driverKind={driverKind}
1333 models={modelsForDisplay}
1334 customModels={customModels}
1335 hiddenModels={hiddenModels}
1336 favoriteModels={favoriteModels}
1337 modelOrder={modelOrder}
1338 onChange={updateCustomModels}
1339 onHiddenModelsChange={onHiddenModelsChange}
1340 onFavoriteModelsChange={onFavoriteModelsChange}
1341 onToggleFavorite={onToggleFavorite}
1342 onModelOrderChange={onModelOrderChange}
1343 />
1344 </div>
1345 </SettingsSection>
1346 ) : null}
1347 </>
1348 );
1349}