1"use client";
2
3import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
4import * as Option from "effect/Option";
5import * as Schema from "effect/Schema";
6import {
7 isSecretSetting,
8 type ProviderSettingsFormAnnotation,
9 type ProviderSettingsFormControl,
10 type ProviderSettingsFormOption,
11 type ProviderSettingsFormSchemaAnnotation,
12} from "@t3tools/contracts";
13import { PlusIcon, XIcon } from "lucide-react";
14
15import { cn } from "../../lib/utils";
16import { Button } from "../ui/button";
17import { DraftInput } from "../ui/draft-input";
18import { Input } from "../ui/input";
19import { SecretInput } from "./SecretInput";
20import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
21import { Switch } from "../ui/switch";
22import { Textarea } from "../ui/textarea";
23import type { ProviderClientDefinition } from "@t3tools/provider-core/client";
24
25/** What the form reads from a definition: an agent provider's, or a source control host's. */
26export type SettingsFormDefinition = Pick<ProviderClientDefinition, "settingsSchema"> & {
27 readonly driverKind?: ProviderClientDefinition["driverKind"];
28};
29import { SettingsRow } from "./settingsLayout";
30
31export interface ProviderSettingsFieldModel {
32 readonly key: string;
33 readonly control: ProviderSettingsFormControl;
34 readonly label: string;
35 readonly description?: string | undefined;
36 readonly placeholder?: string | undefined;
37 readonly clearWhenEmpty: "omit" | "persist";
38 /** Kept in the server's secret store: a saved value arrives as `SECRET_REDACTED` and never shows. */
39 readonly secret?: boolean | undefined;
40 readonly defaultBooleanValue?: boolean | undefined;
41 /** Choices for a `select` control. The first entry is the default. */
42 readonly options?: ReadonlyArray<ProviderSettingsFormOption> | undefined;
43}
44
45function titleizeFieldKey(key: string): string {
46 return key
47 .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
48 .replace(/[-_]+/g, " ")
49 .replace(/^./, (char) => char.toUpperCase());
50}
51
52function readFieldAnnotations(
53 fieldSchema: ProviderClientDefinition["settingsSchema"]["fields"][string],
54) {
55 return Schema.resolveAnnotationsKey(fieldSchema) ?? Schema.resolveAnnotations(fieldSchema);
56}
57
58function readFieldAnnotationString(
59 fieldSchema: ProviderClientDefinition["settingsSchema"]["fields"][string],
60 key: "title" | "description",
61): string | undefined {
62 const annotations = readFieldAnnotations(fieldSchema);
63 const value = annotations?.[key];
64 return typeof value === "string" ? value : undefined;
65}
66
67function readProviderSettingsFormAnnotation(
68 fieldSchema: ProviderClientDefinition["settingsSchema"]["fields"][string],
69): ProviderSettingsFormAnnotation {
70 const annotation = readFieldAnnotations(fieldSchema)?.providerSettingsForm;
71 return annotation ?? {};
72}
73
74function readProviderSettingsFormSchemaAnnotation(
75 definition: SettingsFormDefinition,
76): ProviderSettingsFormSchemaAnnotation {
77 return Schema.resolveAnnotations(definition.settingsSchema)?.providerSettingsFormSchema ?? {};
78}
79
80function readFieldBooleanDefault(
81 fieldSchema: ProviderClientDefinition["settingsSchema"]["fields"][string],
82): boolean | undefined {
83 const decodeDefault = Schema.decodeUnknownOption(fieldSchema as Schema.Decoder<unknown>);
84 const decoded = decodeDefault(undefined);
85 return Option.isSome(decoded) && typeof decoded.value === "boolean" ? decoded.value : undefined;
86}
87
88export function deriveProviderSettingsFields(
89 definition: SettingsFormDefinition,
90 value?: unknown,
91): ReadonlyArray<ProviderSettingsFieldModel> {
92 const isLocalAcp =
93 definition.driverKind === "acpRegistry" &&
94 readProviderConfigString(value, "source") === "local";
95 const sharedAccount = readProviderConfigString(value, "sharedAccountId");
96 const schemaAnnotation = readProviderSettingsFormSchemaAnnotation(definition);
97 const orderedKeys = new Map(
98 (schemaAnnotation.order ?? []).map((key, index) => [key, index] as const),
99 );
100 const orderFallbackOffset = orderedKeys.size;
101
102 return Object.keys(definition.settingsSchema.fields)
103 .map((key, index) => ({ key, index }))
104 .toSorted((left, right) => {
105 return (
106 (orderedKeys.get(left.key) ?? orderFallbackOffset + left.index) -
107 (orderedKeys.get(right.key) ?? orderFallbackOffset + right.index)
108 );
109 })
110 .flatMap(({ key }) => {
111 const fieldSchema = definition.settingsSchema.fields[key]!;
112 const formAnnotation = readProviderSettingsFormAnnotation(fieldSchema);
113 if (formAnnotation.hidden) return [];
114 if (sharedAccount && (key === "homePath" || key === "shadowHomePath")) return [];
115 if (isLocalAcp && key !== "source" && key !== "commandPath") return [];
116
117 const annotatedTitle = readFieldAnnotationString(fieldSchema, "title");
118 const annotatedDescription = readFieldAnnotationString(fieldSchema, "description");
119 return [
120 {
121 key,
122 control: formAnnotation.control ?? "text",
123 label:
124 isLocalAcp && key === "commandPath"
125 ? "Executable"
126 : (annotatedTitle ?? titleizeFieldKey(key)),
127 ...(isLocalAcp && key === "commandPath"
128 ? { description: "Executable name or path on this environment." }
129 : annotatedDescription !== undefined
130 ? { description: annotatedDescription }
131 : {}),
132 ...(isLocalAcp && key === "commandPath"
133 ? { placeholder: "e.g. dsh" }
134 : formAnnotation.placeholder !== undefined
135 ? { placeholder: formAnnotation.placeholder }
136 : {}),
137 clearWhenEmpty: formAnnotation.clearWhenEmpty ?? "omit",
138 ...(isSecretSetting(fieldSchema) ? { secret: true } : {}),
139 ...(formAnnotation.control === "switch"
140 ? { defaultBooleanValue: readFieldBooleanDefault(fieldSchema) }
141 : {}),
142 ...(formAnnotation.control === "select" && formAnnotation.options
143 ? { options: formAnnotation.options }
144 : {}),
145 } satisfies ProviderSettingsFieldModel,
146 ];
147 });
148}
149
150let commandArgumentDraftId = 0;
151const makeCommandArgumentDraftRow = (value: string) => ({
152 id: `provider-argument-${commandArgumentDraftId++}`,
153 value,
154});
155
156function commandArgumentsEqual(left: ReadonlyArray<string>, right: ReadonlyArray<string>) {
157 return left.length === right.length && left.every((argument, index) => argument === right[index]);
158}
159
160function ProviderCommandArguments({
161 value,
162 onChange,
163}: Pick<ProviderSettingsFormProps, "value" | "onChange">) {
164 const args = useMemo(() => {
165 const configured =
166 value !== null && typeof value === "object"
167 ? (value as Record<string, unknown>).commandArgs
168 : undefined;
169 return Array.isArray(configured)
170 ? configured.filter((argument): argument is string => typeof argument === "string")
171 : [];
172 }, [value]);
173 const [rows, setRows] = useState(() => args.map(makeCommandArgumentDraftRow));
174 const rowsRef = useRef(rows);
175 const previousArgsRef = useRef(args);
176 const lastPublishedArgsRef = useRef<ReadonlyArray<string> | undefined>(undefined);
177
178 useEffect(() => {
179 const previousArgs = previousArgsRef.current;
180 const lastPublishedArgs = lastPublishedArgsRef.current;
181 previousArgsRef.current = args;
182 lastPublishedArgsRef.current = undefined;
183 if (
184 commandArgumentsEqual(previousArgs, args) ||
185 (lastPublishedArgs !== undefined && commandArgumentsEqual(lastPublishedArgs, args))
186 )
187 return;
188 const nextRows = args.map(makeCommandArgumentDraftRow);
189 rowsRef.current = nextRows;
190 setRows(nextRows);
191 }, [args]);
192
193 const updateArguments = (nextRows: typeof rows) => {
194 rowsRef.current = nextRows;
195 setRows(nextRows);
196 const next = nextRows.map((row) => row.value);
197 lastPublishedArgsRef.current = next;
198 const config =
199 value !== null && typeof value === "object" ? { ...(value as Record<string, unknown>) } : {};
200 onChange({ ...config, commandArgs: next });
201 };
202
203 return (
204 <SettingsRow
205 title="Arguments"
206 description="One literal argument per row, in launch order."
207 control={
208 <Button
209 type="button"
210 size="sm"
211 variant="outline"
212 onClick={() => updateArguments([...rowsRef.current, makeCommandArgumentDraftRow("")])}
213 >
214 <PlusIcon />
215 Add argument
216 </Button>
217 }
218 >
219 {rows.length > 0 ? (
220 <div className="mt-3 min-w-0 space-y-2 pb-2">
221 {rows.map((argument, index) => (
222 <div key={argument.id} className="flex min-w-0 items-center gap-1.5">
223 <DraftInput
224 size="sm"
225 font="mono"
226 value={argument.value}
227 onCommit={(next) =>
228 updateArguments(
229 rowsRef.current.map((current) =>
230 current.id === argument.id ? { ...current, value: next } : current,
231 ),
232 )
233 }
234 aria-label={`Argument ${index + 1}`}
235 spellCheck={false}
236 />
237 <Button
238 type="button"
239 size="icon-micro"
240 variant="ghost-destructive"
241 onClick={() =>
242 updateArguments(rowsRef.current.filter((current) => current.id !== argument.id))
243 }
244 aria-label={`Remove argument ${index + 1}`}
245 >
246 <XIcon />
247 </Button>
248 </div>
249 ))}
250 </div>
251 ) : null}
252 </SettingsRow>
253 );
254}
255
256function readProviderConfigString(config: unknown, key: string): string {
257 if (config === null || typeof config !== "object") return "";
258 const value = (config as Record<string, unknown>)[key];
259 return typeof value === "string" ? value : "";
260}
261
262function readProviderConfigBoolean(config: unknown, key: string, defaultValue = false): boolean {
263 if (config === null || typeof config !== "object") return defaultValue;
264 const value = (config as Record<string, unknown>)[key];
265 return typeof value === "boolean" ? value : defaultValue;
266}
267
268export function nextProviderConfigWithFieldValue(
269 config: unknown,
270 field: ProviderSettingsFieldModel,
271 value: string | boolean,
272): Record<string, unknown> | undefined {
273 const base: Record<string, unknown> =
274 config !== null && typeof config === "object" ? { ...(config as Record<string, unknown>) } : {};
275
276 if (typeof value === "boolean") {
277 const emptyBooleanValue = field.defaultBooleanValue ?? false;
278 if (field.clearWhenEmpty === "omit" && value === emptyBooleanValue) {
279 delete base[field.key];
280 } else {
281 base[field.key] = value;
282 }
283 return Object.keys(base).length > 0 ? base : undefined;
284 }
285
286 const trimmed = value.trim();
287 if (field.clearWhenEmpty === "omit" && trimmed.length === 0) {
288 delete base[field.key];
289 } else {
290 base[field.key] = value;
291 }
292 return Object.keys(base).length > 0 ? base : undefined;
293}
294
295interface ProviderSettingsFormProps {
296 readonly definition: SettingsFormDefinition;
297 readonly value: unknown;
298 readonly idPrefix: string;
299 /**
300 * `card` stacks label over control, `dialog` is the compact wizard layout,
301 * and `settings` renders the shared settings row treatment.
302 */
303 readonly variant: "card" | "dialog" | "settings";
304 readonly onChange: (nextConfig: Record<string, unknown> | undefined) => void;
305}
306
307/** Stores the default choice as an omitted key so unchanged configs stay small. */
308function ProviderSettingsSelect({
309 field,
310 value,
311 inputId,
312 size,
313 className,
314 onChange,
315}: {
316 readonly field: ProviderSettingsFieldModel;
317 readonly value: unknown;
318 readonly inputId: string;
319 readonly size: "sm" | "xs";
320 readonly className?: string | undefined;
321 readonly onChange: ProviderSettingsFormProps["onChange"];
322}) {
323 const options = field.options ?? [];
324 const fallback = options[0]?.value ?? "";
325 const current = readProviderConfigString(value, field.key) || fallback;
326 const label = options.find((option) => option.value === current)?.label ?? current;
327 return (
328 <Select
329 value={current}
330 onValueChange={(next) => {
331 if (typeof next !== "string") return;
332 onChange(nextProviderConfigWithFieldValue(value, field, next === fallback ? "" : next));
333 }}
334 >
335 <SelectTrigger id={inputId} size={size} className={className} aria-label={field.label}>
336 <SelectValue>{label}</SelectValue>
337 </SelectTrigger>
338 <SelectPopup align="start" alignItemWithTrigger={false}>
339 {options.map((option) => (
340 <SelectItem key={option.value} value={option.value}>
341 {option.label}
342 </SelectItem>
343 ))}
344 </SelectPopup>
345 </Select>
346 );
347}
348
349function FieldFrame(props: {
350 readonly variant: ProviderSettingsFormProps["variant"];
351 readonly children: ReactNode;
352}) {
353 if (props.variant === "card") {
354 return <div>{props.children}</div>;
355 }
356 return <div className="grid gap-1.5">{props.children}</div>;
357}
358
359interface ProviderSettingsFieldRowProps {
360 readonly field: ProviderSettingsFieldModel;
361 readonly value: unknown;
362 readonly idPrefix: string;
363 readonly variant: ProviderSettingsFormProps["variant"];
364 readonly onChange: ProviderSettingsFormProps["onChange"];
365}
366
367function ProviderSettingsFieldRow({
368 field,
369 value,
370 idPrefix,
371 variant,
372 onChange,
373}: ProviderSettingsFieldRowProps) {
374 const inputId = `${idPrefix}-${field.key}`;
375 const descriptionClassName =
376 variant === "dialog"
377 ? "text-2xs text-muted-foreground"
378 : "mt-1 block text-xs text-muted-foreground";
379 const label = <span className="text-xs font-medium text-foreground">{field.label}</span>;
380 const description = field.description ? (
381 <span className={descriptionClassName}>{field.description}</span>
382 ) : null;
383
384 if (variant === "settings") {
385 const descriptionId = field.description ? `${inputId}-description` : undefined;
386 const control =
387 field.control === "switch" ? (
388 <Switch
389 checked={readProviderConfigBoolean(value, field.key, field.defaultBooleanValue)}
390 onCheckedChange={(checked) =>
391 onChange(nextProviderConfigWithFieldValue(value, field, Boolean(checked)))
392 }
393 aria-label={field.label}
394 aria-describedby={descriptionId}
395 />
396 ) : field.control === "select" ? (
397 <ProviderSettingsSelect
398 field={field}
399 value={value}
400 inputId={inputId}
401 size="sm"
402 className="w-full max-w-full @min-[32rem]/settings-row:w-56"
403 onChange={onChange}
404 />
405 ) : field.control === "textarea" ? (
406 <Textarea
407 id={inputId}
408 aria-describedby={descriptionId}
409 className="w-full max-w-full @min-[32rem]/settings-row:w-[min(24rem,50cqw)]"
410 value={readProviderConfigString(value, field.key)}
411 onChange={(event) =>
412 onChange(nextProviderConfigWithFieldValue(value, field, event.target.value))
413 }
414 placeholder={field.placeholder}
415 spellCheck={false}
416 />
417 ) : field.secret ? (
418 <SecretInput
419 id={inputId}
420 aria-describedby={descriptionId}
421 size="sm"
422 className="w-full max-w-full @min-[32rem]/settings-row:w-56"
423 configured={readProviderConfigString(value, field.key).length > 0}
424 value=""
425 onCommit={(next) => onChange(nextProviderConfigWithFieldValue(value, field, next))}
426 />
427 ) : (
428 <DraftInput
429 id={inputId}
430 aria-describedby={descriptionId}
431 size="sm"
432 className="w-full max-w-full @min-[32rem]/settings-row:w-56"
433 type={field.control === "password" ? "password" : undefined}
434 autoComplete={field.control === "password" ? "off" : undefined}
435 value={readProviderConfigString(value, field.key)}
436 placeholder={field.placeholder}
437 onCommit={(next) => {
438 onChange(nextProviderConfigWithFieldValue(value, field, next));
439 }}
440 spellCheck={false}
441 />
442 );
443
444 return (
445 <SettingsRow
446 title={
447 field.control === "switch" ? field.label : <label htmlFor={inputId}>{field.label}</label>
448 }
449 description={
450 field.description ? <span id={descriptionId}>{field.description}</span> : undefined
451 }
452 control={control}
453 />
454 );
455 }
456
457 if (field.control === "switch") {
458 return (
459 <FieldFrame variant={variant}>
460 <div className="flex items-center justify-between gap-3">
461 <div className="min-w-0">
462 {label}
463 {description}
464 </div>
465 <Switch
466 checked={readProviderConfigBoolean(value, field.key, field.defaultBooleanValue)}
467 onCheckedChange={(checked) =>
468 onChange(nextProviderConfigWithFieldValue(value, field, Boolean(checked)))
469 }
470 aria-label={field.label}
471 />
472 </div>
473 </FieldFrame>
474 );
475 }
476
477 if (field.control === "select") {
478 return (
479 <FieldFrame variant={variant}>
480 <label htmlFor={inputId} className={cn(variant === "card" && "block")}>
481 {label}
482 <ProviderSettingsSelect
483 field={field}
484 value={value}
485 inputId={inputId}
486 size="sm"
487 className={cn("w-full", variant === "card" && "mt-1.5")}
488 onChange={onChange}
489 />
490 {description}
491 </label>
492 </FieldFrame>
493 );
494 }
495
496 if (field.control === "textarea") {
497 return (
498 <FieldFrame variant={variant}>
499 <label htmlFor={inputId} className={cn(variant === "card" && "block")}>
500 {label}
501 <Textarea
502 id={inputId}
503 className={cn(variant === "card" && "mt-1.5")}
504 value={readProviderConfigString(value, field.key)}
505 onChange={(event) =>
506 onChange(nextProviderConfigWithFieldValue(value, field, event.target.value))
507 }
508 placeholder={field.placeholder}
509 spellCheck={false}
510 />
511 {description}
512 </label>
513 </FieldFrame>
514 );
515 }
516
517 const type = field.control === "password" ? "password" : undefined;
518 const BufferedInput = field.secret ? SecretInput : DraftInput;
519 const ControlledInput = field.secret ? SecretInput : Input;
520 return (
521 <FieldFrame variant={variant}>
522 <label htmlFor={inputId} className={cn(variant === "card" && "block")}>
523 {label}
524 {variant === "card" ? (
525 <BufferedInput
526 id={inputId}
527 size="sm"
528 className="mt-1.5"
529 type={field.secret ? undefined : type}
530 autoComplete={!field.secret && field.control === "password" ? "off" : undefined}
531 value={readProviderConfigString(value, field.key)}
532 onCommit={(next) => onChange(nextProviderConfigWithFieldValue(value, field, next))}
533 placeholder={field.placeholder}
534 spellCheck={false}
535 />
536 ) : (
537 <ControlledInput
538 id={inputId}
539 type={field.secret ? undefined : type}
540 autoComplete={!field.secret && field.control === "password" ? "off" : undefined}
541 value={readProviderConfigString(value, field.key)}
542 onChange={(event) =>
543 onChange(nextProviderConfigWithFieldValue(value, field, event.target.value))
544 }
545 placeholder={field.placeholder}
546 spellCheck={false}
547 />
548 )}
549 {description}
550 </label>
551 </FieldFrame>
552 );
553}
554
555export function ProviderSettingsForm({
556 definition,
557 value,
558 idPrefix,
559 variant,
560 onChange,
561}: ProviderSettingsFormProps) {
562 const fields = useMemo(
563 () => deriveProviderSettingsFields(definition, value),
564 [definition, value],
565 );
566 const isLocalAcp =
567 definition.driverKind === "acpRegistry" &&
568 readProviderConfigString(value, "source") === "local";
569
570 if (fields.length === 0) {
571 return null;
572 }
573
574 return (
575 <>
576 {fields.map((field) => (
577 <ProviderSettingsFieldRow
578 key={field.key}
579 field={field}
580 value={value}
581 idPrefix={idPrefix}
582 variant={variant}
583 onChange={onChange}
584 />
585 ))}
586 {isLocalAcp ? <ProviderCommandArguments value={value} onChange={onChange} /> : null}
587 </>
588 );
589}