authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-05-02 14:14:34-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-05-02 14:14:34-07:00
log460d9c3eb977bc0341876527af7fde591133925f
tree501de003cb72c522684678407119c37bf138a7a9
parentcb8015a34d4fc687ca1e03f7bbec80dc13da6390
signature Signed by PGP key B5690EEEBB952194

Refactor provider settings to declarative metadata (#2452)

Co-authored-by: Julius Marminge <julius@macmini.local>

7 files changed, 738 insertions(+), 296 deletions(-)

apps/web/src/components/settings/AddProviderInstanceDialog.tsx+41-49
...@@ -13,7 +13,7 @@ import { useSettings, useUpdateSettings } from "../../hooks/useSettings";...@@ -13,7 +13,7 @@ import { useSettings, useUpdateSettings } from "../../hooks/useSettings";
13import { cn } from "../../lib/utils";13import { cn } from "../../lib/utils";
14import { normalizeProviderAccentColor } from "../../providerInstances";14import { normalizeProviderAccentColor } from "../../providerInstances";
15import { Button } from "../ui/button";15import { Button } from "../ui/button";
16import { ACPRegistryIcon, Gemini, GithubCopilotIcon, PiAgentIcon } from "../Icons";16import { ACPRegistryIcon, Gemini, GithubCopilotIcon, PiAgentIcon, type Icon } from "../Icons";
17import {17import {
18 Dialog,18 Dialog,
19 DialogDescription,19 DialogDescription,
...@@ -26,7 +26,8 @@ import { Badge } from "../ui/badge";...@@ -26,7 +26,8 @@ import { Badge } from "../ui/badge";
26import { Input } from "../ui/input";26import { Input } from "../ui/input";
27import { RadioGroup } from "../ui/radio-group";27import { RadioGroup } from "../ui/radio-group";
28import { toastManager } from "../ui/toast";28import { toastManager } from "../ui/toast";
29import { DRIVER_OPTION_BY_VALUE, DRIVER_OPTIONS, type DriverOption } from "./providerDriverMeta";29import { DRIVER_OPTION_BY_VALUE, DRIVER_OPTIONS } from "./providerDriverMeta";
30import { ProviderSettingsForm, deriveProviderSettingsFields } from "./ProviderSettingsForm";
3031
31const PROVIDER_ACCENT_SWATCHES = [32const PROVIDER_ACCENT_SWATCHES = [
32 "#2563eb",33 "#2563eb",
...@@ -61,30 +62,33 @@ function deriveInstanceId(driver: ProviderDriverKind, label: string): string {...@@ -61,30 +62,33 @@ function deriveInstanceId(driver: ProviderDriverKind, label: string): string {
61const INSTANCE_ID_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/;62const INSTANCE_ID_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/;
62const DEFAULT_DRIVER_KIND = ProviderDriverKind.make("codex");63const DEFAULT_DRIVER_KIND = ProviderDriverKind.make("codex");
63const DEFAULT_DRIVER_OPTION = DRIVER_OPTIONS[0]!;64const DEFAULT_DRIVER_OPTION = DRIVER_OPTIONS[0]!;
64const COMING_SOON_DRIVER_OPTIONS: readonly DriverOption[] = [65const EMPTY_CONFIG_DRAFT: Record<string, unknown> = {};
66interface ComingSoonDriverOption {
67 readonly value: ProviderDriverKind;
68 readonly label: string;
69 readonly icon: Icon;
70}
71
72const COMING_SOON_DRIVER_OPTIONS: readonly ComingSoonDriverOption[] = [
65 {73 {
66 value: ProviderDriverKind.make("githubCopilot"),74 value: ProviderDriverKind.make("githubCopilot"),
67 label: "Github Copilot",75 label: "Github Copilot",
68 icon: GithubCopilotIcon,76 icon: GithubCopilotIcon,
69 fields: [],
70 },77 },
71 {78 {
72 value: ProviderDriverKind.make("gemini"),79 value: ProviderDriverKind.make("gemini"),
73 label: "Gemini",80 label: "Gemini",
74 icon: Gemini,81 icon: Gemini,
75 fields: [],
76 },82 },
77 {83 {
78 value: ProviderDriverKind.make("acpRegistry"),84 value: ProviderDriverKind.make("acpRegistry"),
79 label: "ACP Registry",85 label: "ACP Registry",
80 icon: ACPRegistryIcon,86 icon: ACPRegistryIcon,
81 fields: [],
82 },87 },
83 {88 {
84 value: ProviderDriverKind.make("piAgent"),89 value: ProviderDriverKind.make("piAgent"),
85 label: "Pi Agent",90 label: "Pi Agent",
86 icon: PiAgentIcon,91 icon: PiAgentIcon,
87 fields: [],
88 },92 },
89];93];
9094
...@@ -118,10 +122,9 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns...@@ -118,10 +122,9 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns
118 const [accentColor, setAccentColor] = useState<string>("");122 const [accentColor, setAccentColor] = useState<string>("");
119 const [instanceId, setInstanceId] = useState("");123 const [instanceId, setInstanceId] = useState("");
120 const [instanceIdDirty, setInstanceIdDirty] = useState(false);124 const [instanceIdDirty, setInstanceIdDirty] = useState(false);
121 // Driver-specific field values keyed by `${driver}:${fieldKey}` so toggling125 // Driver-specific config drafts keyed by driver so toggling between drivers
122 // between drivers during the same dialog session doesn't lose in-progress126 // during the same dialog session does not lose in-progress input.
123 // input. Only the active driver's values are persisted on save.127 const [configByDriver, setConfigByDriver] = useState<Record<string, Record<string, unknown>>>({});
124 const [fieldValues, setFieldValues] = useState<Record<string, string>>({});
125 // Errors are suppressed until the user has tried to submit once. After that128 // Errors are suppressed until the user has tried to submit once. After that
126 // they update live so fixing the problem clears the message in place.129 // they update live so fixing the problem clears the message in place.
127 const [hasAttemptedSubmit, setHasAttemptedSubmit] = useState(false);130 const [hasAttemptedSubmit, setHasAttemptedSubmit] = useState(false);
...@@ -141,7 +144,7 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns...@@ -141,7 +144,7 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns
141 setInstanceId("");144 setInstanceId("");
142 setWizardStep(0);145 setWizardStep(0);
143 setInstanceIdDirty(false);146 setInstanceIdDirty(false);
144 setFieldValues({});147 setConfigByDriver({});
145 setHasAttemptedSubmit(false);148 setHasAttemptedSubmit(false);
146 }, [open]);149 }, [open]);
147150
...@@ -153,23 +156,28 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns...@@ -153,23 +156,28 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns
153 }, [driver, label, instanceIdDirty]);156 }, [driver, label, instanceIdDirty]);
154157
155 const driverOption = DRIVER_OPTION_BY_VALUE[driver] ?? DEFAULT_DRIVER_OPTION;158 const driverOption = DRIVER_OPTION_BY_VALUE[driver] ?? DEFAULT_DRIVER_OPTION;
159 const driverSettingsFields = useMemo(
160 () => deriveProviderSettingsFields(driverOption),
161 [driverOption],
162 );
156 const instanceIdError = validateInstanceId(instanceId, existingIds);163 const instanceIdError = validateInstanceId(instanceId, existingIds);
157 const showInstanceIdError = hasAttemptedSubmit && instanceIdError !== null;164 const showInstanceIdError = hasAttemptedSubmit && instanceIdError !== null;
158 const previewLabel = label.trim() || `${driverOption.label} Workspace`;165 const previewLabel = label.trim() || `${driverOption.label} Workspace`;
159 const wizardSteps = ["Driver", "Identity", "Config"] as const;166 const wizardSteps = ["Driver", "Identity", "Config"] as const;
160 const wizardStepSummaries = [driverOption.label, previewLabel, null] as const;167 const wizardStepSummaries = [driverOption.label, previewLabel, null] as const;
161168
162 const getFieldValue = useCallback(169 const configDraft = configByDriver[driver] ?? EMPTY_CONFIG_DRAFT;
163 (fieldKey: string) => fieldValues[`${driver}:${fieldKey}`] ?? "",170 const setConfigDraft = useCallback(
164 [driver, fieldValues],171 (config: Record<string, unknown> | undefined) => {
165 );172 setConfigByDriver((existing) => {
166173 const next = { ...existing };
167 const setFieldValue = useCallback(174 if (config === undefined || Object.keys(config).length === 0) {
168 (fieldKey: string, value: string) => {175 delete next[driver];
169 setFieldValues((existing) => ({176 } else {
170 ...existing,177 next[driver] = config;
171 [`${driver}:${fieldKey}`]: value,178 }
172 }));179 return next;
180 });
173 },181 },
174 [driver],182 [driver],
175 );183 );
...@@ -178,13 +186,7 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns...@@ -178,13 +186,7 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns
178 setHasAttemptedSubmit(true);186 setHasAttemptedSubmit(true);
179 if (instanceIdError !== null) return;187 if (instanceIdError !== null) return;
180188
181 // Build the config blob from non-empty driver-specific field values.189 const config = configByDriver[driver] ?? {};
182 // Empty strings are dropped so defaults remain in effect on the server.
183 const config: Record<string, string> = {};
184 for (const field of driverOption.fields) {
185 const value = (fieldValues[`${driver}:${field.key}`] ?? "").trim();
186 if (value.length > 0) config[field.key] = value;
187 }
188 const hasConfig = Object.keys(config).length > 0;190 const hasConfig = Object.keys(config).length > 0;
189 const normalizedAccentColor = normalizeProviderAccentColor(accentColor);191 const normalizedAccentColor = normalizeProviderAccentColor(accentColor);
190192
...@@ -222,7 +224,7 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns...@@ -222,7 +224,7 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns
222 }, [224 }, [
223 driver,225 driver,
224 driverOption,226 driverOption,
225 fieldValues,227 configByDriver,
226 instanceId,228 instanceId,
227 instanceIdError,229 instanceIdError,
228 label,230 label,
...@@ -433,25 +435,15 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns...@@ -433,25 +435,15 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns
433 </span>435 </span>
434 </div>436 </div>
435437
436 {driverOption.fields.length > 0 ? (438 {driverSettingsFields.length > 0 ? (
437 <div className={cn("grid gap-4", wizardStep !== 2 && "hidden")}>439 <div className={cn("grid gap-4", wizardStep !== 2 && "hidden")}>
438 {driverOption.fields.map((field) => (440 <ProviderSettingsForm
439 <label key={field.key} className="grid gap-1.5">441 definition={driverOption}
440 <span className="text-xs font-medium text-foreground">{field.label}</span>442 value={configDraft}
441 <Input443 idPrefix={`add-provider-${driver}`}
442 className="bg-background"444 variant="dialog"
443 type={field.type === "password" ? "password" : undefined}445 onChange={setConfigDraft}
444 autoComplete={field.type === "password" ? "off" : undefined}446 />
445 placeholder={field.placeholder}
446 value={getFieldValue(field.key)}
447 onChange={(event) => setFieldValue(field.key, event.target.value)}
448 spellCheck={false}
449 />
450 {field.description ? (
451 <span className="text-[11px] text-muted-foreground">{field.description}</span>
452 ) : null}
453 </label>
454 ))}
455 </div>447 </div>
456 ) : wizardStep === 2 ? (448 ) : wizardStep === 2 ? (
457 <div className="grid gap-2">449 <div className="grid gap-2">
apps/web/src/components/settings/ProviderInstanceCard.tsx+14-67
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1"use client";1"use client";
22
3import { ChevronDownIcon, PlusIcon, Trash2Icon, XIcon } from "lucide-react";3import { ChevronDownIcon, PlusIcon, Trash2Icon, XIcon } from "lucide-react";
4import { useEffect, useMemo, useState } from "react";4import { useEffect, useMemo, useState, type ReactNode } from "react";
5import {5import {
6 isProviderDriverKind,6 isProviderDriverKind,
7 type ProviderInstanceConfig,7 type ProviderInstanceConfig,
...@@ -21,6 +21,7 @@ import { DraftInput } from "../ui/draft-input";...@@ -21,6 +21,7 @@ import { DraftInput } from "../ui/draft-input";
21import { Switch } from "../ui/switch";21import { Switch } from "../ui/switch";
22import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";22import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
23import type { DriverOption } from "./providerDriverMeta";23import type { DriverOption } from "./providerDriverMeta";
24import { ProviderSettingsForm } from "./ProviderSettingsForm";
24import { ProviderModelsSection } from "./ProviderModelsSection";25import { ProviderModelsSection } from "./ProviderModelsSection";
25import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon";26import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon";
26import {27import {
...@@ -86,20 +87,6 @@ function redactedEmailPlaceholder(email: string): string {...@@ -86,20 +87,6 @@ function redactedEmailPlaceholder(email: string): string {
86 }).join("");87 }).join("");
87}88}
8889
89/**
90 * Read a string value at `key` from the opaque per-driver config blob.
91 * Returns an empty string when the key is missing or the stored value is
92 * not a string. The permissive shape reflects that `config` is
93 * `Schema.Unknown` at the contract boundary — forks may populate it with
94 * non-string values that the built-in UI should round-trip without
95 * throwing.
96 */
97function readConfigString(config: unknown, key: string): string {
98 if (config === null || typeof config !== "object") return "";
99 const value = (config as Record<string, unknown>)[key];
100 return typeof value === "string" ? value : "";
101}
102
103/**90/**
104 * Read a string[] at `key` from the opaque config blob, filtering out91 * Read a string[] at `key` from the opaque config blob, filtering out
105 * non-string entries. Used for `customModels`, which is always typed as92 * non-string entries. Used for `customModels`, which is always typed as
...@@ -113,35 +100,9 @@ function readConfigStringArray(config: unknown, key: string): ReadonlyArray<stri...@@ -113,35 +100,9 @@ function readConfigStringArray(config: unknown, key: string): ReadonlyArray<stri
113 return value.filter((entry): entry is string => typeof entry === "string");100 return value.filter((entry): entry is string => typeof entry === "string");
114}101}
115102
116/**
117 * Produce the next config blob after setting `key` to `value`. Empty
118 * strings drop the key so server defaults stay in effect, mirroring the
119 * save-time normalization in `AddProviderInstanceDialog`. Returns
120 * `undefined` when the resulting blob has no keys, which matches
121 * `ProviderInstanceConfig.config` being optional.
122 *
123 * Non-string values already stored in the blob are carried through
124 * verbatim so fork-owned fields survive edits made through this UI.
125 */
126function nextConfigBlobWithString(
127 config: unknown,
128 key: string,
129 value: string,
130): Record<string, unknown> | undefined {
131 const base: Record<string, unknown> =
132 config !== null && typeof config === "object" ? { ...(config as Record<string, unknown>) } : {};
133 const trimmed = value.trim();
134 if (trimmed.length > 0) {
135 base[key] = value;
136 } else {
137 delete base[key];
138 }
139 return Object.keys(base).length > 0 ? base : undefined;
140}
141
142/**103/**
143 * Set `key` to an arbitrary value on the opaque config blob. Unlike104 * Set `key` to an arbitrary value on the opaque config blob. Unlike
144 * `nextConfigBlobWithString`, does not drop empty-looking values — the105 * provider settings field updates, does not drop empty-looking values — the
145 * caller is responsible for deciding whether an empty array / empty106 * caller is responsible for deciding whether an empty array / empty
146 * object should be stored explicitly (e.g. `customModels: []` is a107 * object should be stored explicitly (e.g. `customModels: []` is a
147 * meaningful "user cleared their custom list" state distinct from108 * meaningful "user cleared their custom list" state distinct from
...@@ -473,7 +434,7 @@ interface ProviderInstanceCardProps {...@@ -473,7 +434,7 @@ interface ProviderInstanceCardProps {
473 * default slots supply a reset-to-factory control here; custom instances434 * default slots supply a reset-to-factory control here; custom instances
474 * omit it.435 * omit it.
475 */436 */
476 readonly headerAction?: React.ReactNode | undefined;437 readonly headerAction?: ReactNode | undefined;
477 readonly hiddenModels: ReadonlyArray<string>;438 readonly hiddenModels: ReadonlyArray<string>;
478 readonly favoriteModels: ReadonlyArray<string>;439 readonly favoriteModels: ReadonlyArray<string>;
479 readonly modelOrder: ReadonlyArray<string>;440 readonly modelOrder: ReadonlyArray<string>;
...@@ -585,8 +546,7 @@ export function ProviderInstanceCard({...@@ -585,8 +546,7 @@ export function ProviderInstanceCard({
585 );546 );
586 };547 };
587548
588 const updateConfigField = (key: string, value: string) => {549 const updateConfig = (nextConfig: Record<string, unknown> | undefined) => {
589 const nextConfig = nextConfigBlobWithString(instance.config, key, value);
590 const { config: _omit, ...rest } = instance;550 const { config: _omit, ...rest } = instance;
591 onUpdate(551 onUpdate(
592 nextConfig !== undefined552 nextConfig !== undefined
...@@ -759,28 +719,15 @@ export function ProviderInstanceCard({...@@ -759,28 +719,15 @@ export function ProviderInstanceCard({
759 />719 />
760 </div>720 </div>
761721
762 {driverOption?.fields.map((field) => (722 {driverOption ? (
763 <div key={field.key} className="border-t border-border/60 px-4 py-3 sm:px-5">723 <ProviderSettingsForm
764 <label htmlFor={`provider-instance-${instanceId}-${field.key}`} className="block">724 definition={driverOption}
765 <span className="text-xs font-medium text-foreground">{field.label}</span>725 value={instance.config}
766 <DraftInput726 idPrefix={`provider-instance-${instanceId}`}
767 id={`provider-instance-${instanceId}-${field.key}`}727 variant="card"
768 className="mt-1.5"728 onChange={updateConfig}
769 type={field.type === "password" ? "password" : undefined}729 />
770 autoComplete={field.type === "password" ? "off" : undefined}730 ) : null}
771 value={readConfigString(instance.config, field.key)}
772 onCommit={(next) => updateConfigField(field.key, next)}
773 placeholder={field.placeholder}
774 spellCheck={false}
775 />
776 {field.description ? (
777 <span className="mt-1 block text-xs text-muted-foreground">
778 {field.description}
779 </span>
780 ) : null}
781 </label>
782 </div>
783 ))}
784731
785 {driverOption !== undefined ? (732 {driverOption !== undefined ? (
786 <ProviderModelsSection733 <ProviderModelsSection
apps/web/src/components/settings/ProviderSettingsForm.test.ts created+131
...@@ -0,0 +1,131 @@
1import { describe, expect, it } from "vitest";
2import { ProviderDriverKind } from "@t3tools/contracts";
3
4import { DRIVER_OPTION_BY_VALUE } from "./providerDriverMeta";
5import {
6 deriveProviderSettingsFields,
7 nextProviderConfigWithFieldValue,
8 readProviderConfigBoolean,
9 readProviderConfigString,
10} from "./ProviderSettingsForm";
11
12describe("ProviderSettingsForm helpers", () => {
13 it("derives visible provider config fields from the client definition schema", () => {
14 const codex = DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("codex")];
15
16 expect(codex).toBeDefined();
17 expect(deriveProviderSettingsFields(codex!).map((field) => field.key)).toEqual([
18 "binaryPath",
19 "homePath",
20 "shadowHomePath",
21 ]);
22 });
23
24 it("sources labels and descriptions from schema annotations", () => {
25 const opencode = DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("opencode")];
26 expect(opencode).toBeDefined();
27
28 const serverPassword = deriveProviderSettingsFields(opencode!).find(
29 (field) => field.key === "serverPassword",
30 );
31
32 expect(serverPassword).toMatchObject({
33 label: "Server password",
34 description: "Stored in plain text on disk.",
35 control: "password",
36 });
37 });
38
39 it("preserves unknown config keys while omitting empty configurable fields", () => {
40 const opencode = DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("opencode")];
41 expect(opencode).toBeDefined();
42
43 const serverUrl = deriveProviderSettingsFields(opencode!).find(
44 (field) => field.key === "serverUrl",
45 );
46 expect(serverUrl).toBeDefined();
47
48 const next = nextProviderConfigWithFieldValue(
49 { forkOwned: 1, serverUrl: "http://127.0.0.1:4096" },
50 serverUrl!,
51 "",
52 );
53
54 expect(next).toEqual({ forkOwned: 1 });
55 });
56
57 it("reads non-string config values as blank strings", () => {
58 expect(readProviderConfigString({ binaryPath: 123 }, "binaryPath")).toBe("");
59 });
60
61 it("omits false boolean fields when clearWhenEmpty is omit", () => {
62 const next = nextProviderConfigWithFieldValue(
63 { forkOwned: 1, experimental: true },
64 {
65 key: "experimental",
66 control: "switch",
67 label: "Experimental",
68 clearWhenEmpty: "omit",
69 defaultBooleanValue: false,
70 },
71 false,
72 );
73
74 expect(next).toEqual({ forkOwned: 1 });
75 });
76
77 it("omits true boolean fields when true is the default", () => {
78 const next = nextProviderConfigWithFieldValue(
79 { forkOwned: 1, experimental: false },
80 {
81 key: "experimental",
82 control: "switch",
83 label: "Experimental",
84 clearWhenEmpty: "omit",
85 defaultBooleanValue: true,
86 },
87 true,
88 );
89
90 expect(next).toEqual({ forkOwned: 1 });
91 });
92
93 it("stores false boolean fields when true is the default", () => {
94 const next = nextProviderConfigWithFieldValue(
95 undefined,
96 {
97 key: "experimental",
98 control: "switch",
99 label: "Experimental",
100 clearWhenEmpty: "omit",
101 defaultBooleanValue: true,
102 },
103 false,
104 );
105
106 expect(next).toEqual({ experimental: false });
107 });
108
109 it("preserves false boolean fields when clearWhenEmpty is persist", () => {
110 const next = nextProviderConfigWithFieldValue(
111 undefined,
112 {
113 key: "experimental",
114 control: "switch",
115 label: "Experimental",
116 clearWhenEmpty: "persist",
117 },
118 false,
119 );
120
121 expect(next).toEqual({ experimental: false });
122 });
123
124 it("reads non-boolean config values as false booleans", () => {
125 expect(readProviderConfigBoolean({ experimental: "true" }, "experimental")).toBe(false);
126 });
127
128 it("reads missing boolean config values from the supplied default", () => {
129 expect(readProviderConfigBoolean({}, "experimental", true)).toBe(true);
130 });
131});
apps/web/src/components/settings/ProviderSettingsForm.tsx created+306
...@@ -0,0 +1,306 @@
1"use client";
2
3import { useMemo, type ReactNode } from "react";
4import { Schema } from "effect";
5import type {
6 ProviderSettingsFormAnnotation,
7 ProviderSettingsFormControl,
8 ProviderSettingsFormSchemaAnnotation,
9} from "@t3tools/contracts";
10
11import { cn } from "../../lib/utils";
12import { DraftInput } from "../ui/draft-input";
13import { Input } from "../ui/input";
14import { Switch } from "../ui/switch";
15import { Textarea } from "../ui/textarea";
16import type { ProviderClientDefinition } from "./providerDriverMeta";
17
18export interface ProviderSettingsFieldModel {
19 readonly key: string;
20 readonly control: ProviderSettingsFormControl;
21 readonly label: string;
22 readonly description?: string | undefined;
23 readonly placeholder?: string | undefined;
24 readonly clearWhenEmpty: "omit" | "persist";
25 readonly defaultBooleanValue?: boolean | undefined;
26}
27
28function titleizeFieldKey(key: string): string {
29 return key
30 .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
31 .replace(/[-_]+/g, " ")
32 .replace(/^./, (char) => char.toUpperCase());
33}
34
35function readFieldAnnotations(
36 fieldSchema: ProviderClientDefinition["settingsSchema"]["fields"][string],
37) {
38 return Schema.resolveAnnotationsKey(fieldSchema) ?? Schema.resolveAnnotations(fieldSchema);
39}
40
41function readFieldAnnotationString(
42 fieldSchema: ProviderClientDefinition["settingsSchema"]["fields"][string],
43 key: "title" | "description",
44): string | undefined {
45 const annotations = readFieldAnnotations(fieldSchema);
46 const value = annotations?.[key];
47 return typeof value === "string" ? value : undefined;
48}
49
50function readProviderSettingsFormAnnotation(
51 fieldSchema: ProviderClientDefinition["settingsSchema"]["fields"][string],
52): ProviderSettingsFormAnnotation {
53 const annotation = readFieldAnnotations(fieldSchema)?.providerSettingsForm;
54 return annotation ?? {};
55}
56
57function readProviderSettingsFormSchemaAnnotation(
58 definition: ProviderClientDefinition,
59): ProviderSettingsFormSchemaAnnotation {
60 return Schema.resolveAnnotations(definition.settingsSchema)?.providerSettingsFormSchema ?? {};
61}
62
63function readFieldBooleanDefault(
64 fieldSchema: ProviderClientDefinition["settingsSchema"]["fields"][string],
65): boolean | undefined {
66 try {
67 const decoded = Schema.decodeUnknownSync(fieldSchema as Schema.Decoder<unknown>)(undefined);
68 return typeof decoded === "boolean" ? decoded : undefined;
69 } catch {
70 return undefined;
71 }
72}
73
74export function deriveProviderSettingsFields(
75 definition: ProviderClientDefinition,
76): ReadonlyArray<ProviderSettingsFieldModel> {
77 const schemaAnnotation = readProviderSettingsFormSchemaAnnotation(definition);
78 const orderedKeys = new Map(
79 (schemaAnnotation.order ?? []).map((key, index) => [key, index] as const),
80 );
81 const orderFallbackOffset = orderedKeys.size;
82
83 return Object.keys(definition.settingsSchema.fields)
84 .map((key, index) => ({ key, index }))
85 .toSorted((left, right) => {
86 return (
87 (orderedKeys.get(left.key) ?? orderFallbackOffset + left.index) -
88 (orderedKeys.get(right.key) ?? orderFallbackOffset + right.index)
89 );
90 })
91 .flatMap(({ key }) => {
92 const fieldSchema = definition.settingsSchema.fields[key]!;
93 const formAnnotation = readProviderSettingsFormAnnotation(fieldSchema);
94 if (formAnnotation.hidden) return [];
95
96 const annotatedTitle = readFieldAnnotationString(fieldSchema, "title");
97 const annotatedDescription = readFieldAnnotationString(fieldSchema, "description");
98 return [
99 {
100 key,
101 control: formAnnotation.control ?? "text",
102 label: annotatedTitle ?? titleizeFieldKey(key),
103 ...(annotatedDescription !== undefined ? { description: annotatedDescription } : {}),
104 ...(formAnnotation.placeholder !== undefined
105 ? { placeholder: formAnnotation.placeholder }
106 : {}),
107 clearWhenEmpty: formAnnotation.clearWhenEmpty ?? "omit",
108 ...(formAnnotation.control === "switch"
109 ? { defaultBooleanValue: readFieldBooleanDefault(fieldSchema) }
110 : {}),
111 } satisfies ProviderSettingsFieldModel,
112 ];
113 });
114}
115
116export function readProviderConfigString(config: unknown, key: string): string {
117 if (config === null || typeof config !== "object") return "";
118 const value = (config as Record<string, unknown>)[key];
119 return typeof value === "string" ? value : "";
120}
121
122export function readProviderConfigBoolean(
123 config: unknown,
124 key: string,
125 defaultValue = false,
126): boolean {
127 if (config === null || typeof config !== "object") return defaultValue;
128 const value = (config as Record<string, unknown>)[key];
129 return typeof value === "boolean" ? value : defaultValue;
130}
131
132export function nextProviderConfigWithFieldValue(
133 config: unknown,
134 field: ProviderSettingsFieldModel,
135 value: string | boolean,
136): Record<string, unknown> | undefined {
137 const base: Record<string, unknown> =
138 config !== null && typeof config === "object" ? { ...(config as Record<string, unknown>) } : {};
139
140 if (typeof value === "boolean") {
141 const emptyBooleanValue = field.defaultBooleanValue ?? false;
142 if (field.clearWhenEmpty === "omit" && value === emptyBooleanValue) {
143 delete base[field.key];
144 } else {
145 base[field.key] = value;
146 }
147 return Object.keys(base).length > 0 ? base : undefined;
148 }
149
150 const trimmed = value.trim();
151 if (field.clearWhenEmpty === "omit" && trimmed.length === 0) {
152 delete base[field.key];
153 } else {
154 base[field.key] = value;
155 }
156 return Object.keys(base).length > 0 ? base : undefined;
157}
158
159interface ProviderSettingsFormProps {
160 readonly definition: ProviderClientDefinition;
161 readonly value: unknown;
162 readonly idPrefix: string;
163 readonly variant: "card" | "dialog";
164 readonly onChange: (nextConfig: Record<string, unknown> | undefined) => void;
165}
166
167function FieldFrame(props: {
168 readonly variant: ProviderSettingsFormProps["variant"];
169 readonly children: ReactNode;
170}) {
171 if (props.variant === "card") {
172 return <div className="border-t border-border/60 px-4 py-3 sm:px-5">{props.children}</div>;
173 }
174 return <div className="grid gap-1.5">{props.children}</div>;
175}
176
177interface ProviderSettingsFieldRowProps {
178 readonly field: ProviderSettingsFieldModel;
179 readonly value: unknown;
180 readonly idPrefix: string;
181 readonly variant: ProviderSettingsFormProps["variant"];
182 readonly onChange: ProviderSettingsFormProps["onChange"];
183}
184
185function ProviderSettingsFieldRow({
186 field,
187 value,
188 idPrefix,
189 variant,
190 onChange,
191}: ProviderSettingsFieldRowProps) {
192 const inputId = `${idPrefix}-${field.key}`;
193 const descriptionClassName =
194 variant === "card"
195 ? "mt-1 block text-xs text-muted-foreground"
196 : "text-[11px] text-muted-foreground";
197 const label = <span className="text-xs font-medium text-foreground">{field.label}</span>;
198 const description = field.description ? (
199 <span className={descriptionClassName}>{field.description}</span>
200 ) : null;
201
202 if (field.control === "switch") {
203 return (
204 <FieldFrame variant={variant}>
205 <div className="flex items-center justify-between gap-3">
206 <div className="min-w-0">
207 {label}
208 {description}
209 </div>
210 <Switch
211 checked={readProviderConfigBoolean(value, field.key, field.defaultBooleanValue)}
212 onCheckedChange={(checked) =>
213 onChange(nextProviderConfigWithFieldValue(value, field, Boolean(checked)))
214 }
215 aria-label={field.label}
216 />
217 </div>
218 </FieldFrame>
219 );
220 }
221
222 if (field.control === "textarea") {
223 return (
224 <FieldFrame variant={variant}>
225 <label htmlFor={inputId} className={cn(variant === "card" && "block")}>
226 {label}
227 <Textarea
228 id={inputId}
229 className={cn(variant === "card" && "mt-1.5")}
230 value={readProviderConfigString(value, field.key)}
231 onChange={(event) =>
232 onChange(nextProviderConfigWithFieldValue(value, field, event.target.value))
233 }
234 placeholder={field.placeholder}
235 spellCheck={false}
236 />
237 {description}
238 </label>
239 </FieldFrame>
240 );
241 }
242
243 const type = field.control === "password" ? "password" : undefined;
244 return (
245 <FieldFrame variant={variant}>
246 <label htmlFor={inputId} className={cn(variant === "card" && "block")}>
247 {label}
248 {variant === "card" ? (
249 <DraftInput
250 id={inputId}
251 className="mt-1.5"
252 type={type}
253 autoComplete={field.control === "password" ? "off" : undefined}
254 value={readProviderConfigString(value, field.key)}
255 onCommit={(next) => onChange(nextProviderConfigWithFieldValue(value, field, next))}
256 placeholder={field.placeholder}
257 spellCheck={false}
258 />
259 ) : (
260 <Input
261 id={inputId}
262 className="bg-background"
263 type={type}
264 autoComplete={field.control === "password" ? "off" : undefined}
265 value={readProviderConfigString(value, field.key)}
266 onChange={(event) =>
267 onChange(nextProviderConfigWithFieldValue(value, field, event.target.value))
268 }
269 placeholder={field.placeholder}
270 spellCheck={false}
271 />
272 )}
273 {description}
274 </label>
275 </FieldFrame>
276 );
277}
278
279export function ProviderSettingsForm({
280 definition,
281 value,
282 idPrefix,
283 variant,
284 onChange,
285}: ProviderSettingsFormProps) {
286 const fields = useMemo(() => deriveProviderSettingsFields(definition), [definition]);
287
288 if (fields.length === 0) {
289 return null;
290 }
291
292 return (
293 <>
294 {fields.map((field) => (
295 <ProviderSettingsFieldRow
296 key={field.key}
297 field={field}
298 value={value}
299 idPrefix={idPrefix}
300 variant={variant}
301 onChange={onChange}
302 />
303 ))}
304 </>
305 );
306}
apps/web/src/components/settings/SettingsPanels.tsx+5-52
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1import { ArchiveIcon, ArchiveX, LoaderIcon, PlusIcon, RefreshCwIcon } from "lucide-react";1import { ArchiveIcon, ArchiveX, LoaderIcon, PlusIcon, RefreshCwIcon } from "lucide-react";
2import { useQueryClient } from "@tanstack/react-query";2import { useQueryClient } from "@tanstack/react-query";
3import { type ReactNode, useCallback, useMemo, useRef, useState } from "react";3import { useCallback, useMemo, useRef, useState } from "react";
4import {4import {
5 defaultInstanceIdForDriver,5 defaultInstanceIdForDriver,
6 type DesktopUpdateChannel,6 type DesktopUpdateChannel,
...@@ -57,7 +57,7 @@ import { stackedThreadToast, toastManager } from "../ui/toast";...@@ -57,7 +57,7 @@ import { stackedThreadToast, toastManager } from "../ui/toast";
57import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";57import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
58import { AddProviderInstanceDialog } from "./AddProviderInstanceDialog";58import { AddProviderInstanceDialog } from "./AddProviderInstanceDialog";
59import { ProviderInstanceCard } from "./ProviderInstanceCard";59import { ProviderInstanceCard } from "./ProviderInstanceCard";
60import { getDriverOption } from "./providerDriverMeta";60import { DRIVER_OPTIONS, getDriverOption } from "./providerDriverMeta";
61import { buildProviderInstanceUpdatePatch } from "./SettingsPanels.logic";61import { buildProviderInstanceUpdatePatch } from "./SettingsPanels.logic";
62import {62import {
63 SettingResetButton,63 SettingResetButton,
...@@ -113,56 +113,9 @@ function withoutProviderInstanceFavorites(...@@ -113,56 +113,9 @@ function withoutProviderInstanceFavorites(
113 return favorites.filter((favorite) => favorite.provider !== instanceId);113 return favorites.filter((favorite) => favorite.provider !== instanceId);
114}114}
115115
116type InstallProviderSettings = {116const PROVIDER_SETTINGS = DRIVER_OPTIONS.map((definition) => ({
117 provider: ProviderDriverKind;117 provider: definition.value,
118 title: string;118}));
119 badgeLabel?: string;
120 binaryPlaceholder: string;
121 binaryDescription: ReactNode;
122 serverUrlPlaceholder?: string;
123 serverUrlDescription?: ReactNode;
124 serverPasswordPlaceholder?: string;
125 serverPasswordDescription?: ReactNode;
126 homePathKey?: "codexHomePath";
127 homePlaceholder?: string;
128 homeDescription?: ReactNode;
129};
130
131const PROVIDER_SETTINGS: readonly InstallProviderSettings[] = [
132 {
133 provider: ProviderDriverKind.make("codex"),
134 title: "Codex",
135 binaryPlaceholder: "Codex binary path",
136 binaryDescription: "Path to the Codex binary",
137 homePathKey: "codexHomePath",
138 homePlaceholder: "CODEX_HOME",
139 homeDescription: "Optional custom Codex home and config directory.",
140 },
141 {
142 provider: ProviderDriverKind.make("claudeAgent"),
143 title: "Claude",
144 binaryPlaceholder: "Claude binary path",
145 binaryDescription: "Path to the Claude binary",
146 },
147 {
148 provider: ProviderDriverKind.make("cursor"),
149 title: "Cursor",
150 badgeLabel: "Early Access",
151 binaryPlaceholder: "Cursor agent binary path",
152 binaryDescription: "Path to the Cursor agent binary",
153 },
154 {
155 provider: ProviderDriverKind.make("opencode"),
156 title: "OpenCode",
157 binaryPlaceholder: "OpenCode binary path",
158 binaryDescription: "Path to the OpenCode binary",
159 serverUrlPlaceholder: "http://127.0.0.1:4096",
160 serverUrlDescription: "Leave blank to let T3 Code spawn the server when needed",
161 serverPasswordPlaceholder: "Server password (optional)",
162 serverPasswordDescription:
163 "If your OpenCode server requires authentication, enter the password here. NOTE: Stored in plain text on disk",
164 },
165] as const;
166119
167function ProviderLastChecked({ lastCheckedAt }: { lastCheckedAt: string | null }) {120function ProviderLastChecked({ lastCheckedAt }: { lastCheckedAt: string | null }) {
168 useRelativeTimeTick();121 useRelativeTimeTick();
apps/web/src/components/settings/providerDriverMeta.ts+32-101
...@@ -1,31 +1,28 @@...@@ -1,31 +1,28 @@
1import { ProviderDriverKind } from "@t3tools/contracts";1import {
2 ClaudeSettings,
3 CodexSettings,
4 CursorSettings,
5 OpenCodeSettings,
6 ProviderDriverKind,
7} from "@t3tools/contracts";
8import type { Schema } from "effect";
2import { ClaudeAI, CursorIcon, type Icon, OpenAI, OpenCodeIcon } from "../Icons";9import { ClaudeAI, CursorIcon, type Icon, OpenAI, OpenCodeIcon } from "../Icons";
310
4/**11type ProviderSettingsSchema = {
5 * A single editable field exposed on a provider instance. `key` must match12 readonly fields: Readonly<Record<string, Schema.Top>>;
6 * the corresponding driver config schema in13} & Schema.Top;
7 * `packages/contracts/src/settings.ts` — values are merged into the saved
8 * `ProviderInstanceConfig.config` blob (or the legacy
9 * `ServerSettings.providers[kind]` struct) under this key verbatim.
10 */
11export interface DriverFieldDef {
12 readonly key: string;
13 readonly label: string;
14 readonly placeholder?: string;
15 readonly description?: string;
16 readonly type?: "text" | "password";
17}
1814
19/**15/**
20 * Presentation + editable-field metadata for a registered driver. Shared16 * Browser-safe provider definition. This is deliberately shaped like the
21 * between the Add-Instance dialog and the per-instance settings card so17 * future provider package client export: the core web app gets a schema with
22 * both surfaces offer the same keys for the same driver.18 * field annotations plus provider-level presentation metadata, then renders
19 * settings generically.
23 */20 */
24export interface DriverOption {21export interface ProviderClientDefinition {
25 readonly value: ProviderDriverKind;22 readonly value: ProviderDriverKind;
26 readonly label: string;23 readonly label: string;
27 readonly icon: Icon;24 readonly icon: Icon;
28 readonly fields: readonly DriverFieldDef[];25 readonly settingsSchema: ProviderSettingsSchema;
29 /**26 /**
30 * Optional short label rendered as a `variant="warning"` badge next to27 * Optional short label rendered as a `variant="warning"` badge next to
31 * the instance title. Used to flag drivers that still ship under an28 * the instance title. Used to flag drivers that still ship under an
...@@ -36,109 +33,43 @@ export interface DriverOption {...@@ -36,109 +33,43 @@ export interface DriverOption {
36 readonly badgeLabel?: string;33 readonly badgeLabel?: string;
37}34}
3835
39export const DRIVER_OPTIONS: readonly DriverOption[] = [36export const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] = [
40 {37 {
41 value: ProviderDriverKind.make("codex"),38 value: ProviderDriverKind.make("codex"),
42 label: "Codex",39 label: "Codex",
43 icon: OpenAI,40 icon: OpenAI,
44 fields: [41 settingsSchema: CodexSettings,
45 {
46 key: "binaryPath",
47 label: "Binary path",
48 placeholder: "codex",
49 description: "Path to the Codex binary used by this instance.",
50 },
51 {
52 key: "homePath",
53 label: "CODEX_HOME path",
54 placeholder: "~/.codex",
55 description: "Custom Codex home and config directory.",
56 },
57 {
58 key: "shadowHomePath",
59 label: "Shadow home path",
60 placeholder: "~/.codex-t3/personal",
61 description:
62 "Account-specific Codex home. Keeps auth.json separate while sharing state from CODEX_HOME.",
63 },
64 ],
65 },42 },
66 {43 {
67 value: ProviderDriverKind.make("claudeAgent"),44 value: ProviderDriverKind.make("claudeAgent"),
68 label: "Claude",45 label: "Claude",
69 icon: ClaudeAI,46 icon: ClaudeAI,
70 fields: [47 settingsSchema: ClaudeSettings,
71 {
72 key: "binaryPath",
73 label: "Binary path",
74 placeholder: "claude",
75 description: "Path to the Claude binary used by this instance.",
76 },
77 {
78 key: "homePath",
79 label: "Claude HOME path",
80 placeholder: "~",
81 description:
82 "Custom HOME used when running this Claude instance. Keeps .claude.json and .claude separate.",
83 },
84 {
85 key: "launchArgs",
86 label: "Launch arguments",
87 placeholder: "e.g. --chrome",
88 description: "Additional CLI arguments passed on session start.",
89 },
90 ],
91 },48 },
92 {49 {
93 value: ProviderDriverKind.make("cursor"),50 value: ProviderDriverKind.make("cursor"),
94 label: "Cursor",51 label: "Cursor",
95 icon: CursorIcon,52 icon: CursorIcon,
96 badgeLabel: "Early Access",53 badgeLabel: "Early Access",
97 fields: [54 settingsSchema: CursorSettings,
98 {
99 key: "binaryPath",
100 label: "Binary path",
101 placeholder: "agent",
102 description: "Path to the Cursor agent binary.",
103 },
104 {
105 key: "apiEndpoint",
106 label: "API endpoint",
107 placeholder: "https://…",
108 description: "Override the Cursor API endpoint for this instance.",
109 },
110 ],
111 },55 },
112 {56 {
113 value: ProviderDriverKind.make("opencode"),57 value: ProviderDriverKind.make("opencode"),
114 label: "OpenCode",58 label: "OpenCode",
115 icon: OpenCodeIcon,59 icon: OpenCodeIcon,
116 fields: [60 settingsSchema: OpenCodeSettings,
117 {
118 key: "binaryPath",
119 label: "Binary path",
120 placeholder: "opencode",
121 description: "Path to the OpenCode binary.",
122 },
123 {
124 key: "serverUrl",
125 label: "Server URL",
126 placeholder: "http://127.0.0.1:4096",
127 description: "Leave blank to let T3 Code spawn the server when needed.",
128 },
129 {
130 key: "serverPassword",
131 label: "Server password",
132 placeholder: "Optional",
133 type: "password",
134 description: "Stored in plain text on disk.",
135 },
136 ],
137 },61 },
138];62];
13963
140export const DRIVER_OPTION_BY_VALUE: Partial<Record<ProviderDriverKind, DriverOption>> =64export const PROVIDER_CLIENT_DEFINITION_BY_VALUE: Partial<
141 Object.fromEntries(DRIVER_OPTIONS.map((option) => [option.value, option]));65 Record<ProviderDriverKind, ProviderClientDefinition>
66> = Object.fromEntries(
67 PROVIDER_CLIENT_DEFINITIONS.map((definition) => [definition.value, definition]),
68);
69
70export const DRIVER_OPTIONS = PROVIDER_CLIENT_DEFINITIONS;
71export const DRIVER_OPTION_BY_VALUE = PROVIDER_CLIENT_DEFINITION_BY_VALUE;
72export type DriverOption = ProviderClientDefinition;
14273
143/**74/**
144 * Look up the driver metadata for an instance's `driver` field. Accepts75 * Look up the driver metadata for an instance's `driver` field. Accepts
...@@ -147,5 +78,5 @@ export const DRIVER_OPTION_BY_VALUE: Partial<Record<ProviderDriverKind, DriverOp...@@ -147,5 +78,5 @@ export const DRIVER_OPTION_BY_VALUE: Partial<Record<ProviderDriverKind, DriverOp
147 */78 */
148export function getDriverOption(driver: ProviderDriverKind | undefined): DriverOption | undefined {79export function getDriverOption(driver: ProviderDriverKind | undefined): DriverOption | undefined {
149 if (driver === undefined) return undefined;80 if (driver === undefined) return undefined;
150 return DRIVER_OPTION_BY_VALUE[driver];81 return PROVIDER_CLIENT_DEFINITION_BY_VALUE[driver];
151}82}
packages/contracts/src/settings.ts+209-27
...@@ -96,38 +96,220 @@ const makeBinaryPathSetting = (fallback: string) =>...@@ -96,38 +96,220 @@ const makeBinaryPathSetting = (fallback: string) =>
96 Schema.withDecodingDefault(Effect.succeed(fallback)),96 Schema.withDecodingDefault(Effect.succeed(fallback)),
97 );97 );
9898
99export const CodexSettings = Schema.Struct({99export type ProviderSettingsFormControl = "text" | "password" | "textarea" | "switch";
100 enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),100
101 binaryPath: makeBinaryPathSetting("codex"),101export interface ProviderSettingsFormAnnotation {
102 homePath: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),102 readonly control?: ProviderSettingsFormControl | undefined;
103 shadowHomePath: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),103 readonly placeholder?: string | undefined;
104 customModels: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(Effect.succeed([]))),104 readonly hidden?: boolean | undefined;
105});105 readonly clearWhenEmpty?: "omit" | "persist" | undefined;
106}
107
108export interface ProviderSettingsFormSchemaAnnotation {
109 readonly order?: readonly string[] | undefined;
110}
111
112declare module "effect/Schema" {
113 namespace Annotations {
114 interface Annotations {
115 readonly providerSettingsForm?: ProviderSettingsFormAnnotation | undefined;
116 readonly providerSettingsFormSchema?: ProviderSettingsFormSchemaAnnotation | undefined;
117 }
118 }
119}
120
121export type ProviderSettingsOrder<Fields extends Schema.Struct.Fields> = readonly Extract<
122 keyof Fields,
123 string
124>[];
125
126export function makeProviderSettingsSchema<const Fields extends Schema.Struct.Fields>(
127 fields: Fields,
128 options?: {
129 readonly order?: ProviderSettingsOrder<Fields> | undefined;
130 },
131): Schema.Struct<Fields> {
132 return Schema.Struct(fields).pipe(
133 Schema.annotate({
134 providerSettingsFormSchema:
135 options?.order === undefined ? undefined : { order: options.order },
136 }),
137 );
138}
139
140export const CodexSettings = makeProviderSettingsSchema(
141 {
142 enabled: Schema.Boolean.pipe(
143 Schema.withDecodingDefault(Effect.succeed(true)),
144 Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
145 ),
146 binaryPath: makeBinaryPathSetting("codex").pipe(
147 Schema.annotateKey({
148 title: "Binary path",
149 description: "Path to the Codex binary used by this instance.",
150 providerSettingsForm: { placeholder: "codex", clearWhenEmpty: "omit" },
151 }),
152 ),
153 homePath: TrimmedString.pipe(
154 Schema.withDecodingDefault(Effect.succeed("")),
155 Schema.annotateKey({
156 title: "CODEX_HOME path",
157 description: "Custom Codex home and config directory.",
158 providerSettingsForm: {
159 placeholder: "~/.codex",
160 clearWhenEmpty: "omit",
161 },
162 }),
163 ),
164 shadowHomePath: TrimmedString.pipe(
165 Schema.withDecodingDefault(Effect.succeed("")),
166 Schema.annotateKey({
167 title: "Shadow home path",
168 description:
169 "Account-specific Codex home. Keeps auth.json separate while sharing state from CODEX_HOME.",
170 providerSettingsForm: {
171 placeholder: "~/.codex-t3/personal",
172 clearWhenEmpty: "omit",
173 },
174 }),
175 ),
176 customModels: Schema.Array(Schema.String).pipe(
177 Schema.withDecodingDefault(Effect.succeed([])),
178 Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
179 ),
180 },
181 {
182 order: ["binaryPath", "homePath", "shadowHomePath"],
183 },
184);
106export type CodexSettings = typeof CodexSettings.Type;185export type CodexSettings = typeof CodexSettings.Type;
107186
108export const ClaudeSettings = Schema.Struct({187export const ClaudeSettings = makeProviderSettingsSchema(
109 enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),188 {
110 binaryPath: makeBinaryPathSetting("claude"),189 enabled: Schema.Boolean.pipe(
111 homePath: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),190 Schema.withDecodingDefault(Effect.succeed(true)),
112 customModels: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(Effect.succeed([]))),191 Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
113 launchArgs: Schema.String.pipe(Schema.withDecodingDefault(Effect.succeed(""))),192 ),
114});193 binaryPath: makeBinaryPathSetting("claude").pipe(
194 Schema.annotateKey({
195 title: "Binary path",
196 description: "Path to the Claude binary used by this instance.",
197 providerSettingsForm: { placeholder: "claude", clearWhenEmpty: "omit" },
198 }),
199 ),
200 homePath: TrimmedString.pipe(
201 Schema.withDecodingDefault(Effect.succeed("")),
202 Schema.annotateKey({
203 title: "Claude HOME path",
204 description:
205 "Custom HOME used when running this Claude instance. Keeps .claude.json and .claude separate.",
206 providerSettingsForm: { placeholder: "~", clearWhenEmpty: "omit" },
207 }),
208 ),
209 customModels: Schema.Array(Schema.String).pipe(
210 Schema.withDecodingDefault(Effect.succeed([])),
211 Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
212 ),
213 launchArgs: Schema.String.pipe(
214 Schema.withDecodingDefault(Effect.succeed("")),
215 Schema.annotateKey({
216 title: "Launch arguments",
217 description: "Additional CLI arguments passed on session start.",
218 providerSettingsForm: {
219 placeholder: "e.g. --chrome",
220 clearWhenEmpty: "omit",
221 },
222 }),
223 ),
224 },
225 {
226 order: ["binaryPath", "homePath", "launchArgs"],
227 },
228);
115export type ClaudeSettings = typeof ClaudeSettings.Type;229export type ClaudeSettings = typeof ClaudeSettings.Type;
116230
117export const CursorSettings = Schema.Struct({231export const CursorSettings = makeProviderSettingsSchema(
118 enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),232 {
119 binaryPath: makeBinaryPathSetting("agent"),233 enabled: Schema.Boolean.pipe(
120 apiEndpoint: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),234 Schema.withDecodingDefault(Effect.succeed(false)),
121 customModels: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(Effect.succeed([]))),235 Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
122});236 ),
237 binaryPath: makeBinaryPathSetting("agent").pipe(
238 Schema.annotateKey({
239 title: "Binary path",
240 description: "Path to the Cursor agent binary.",
241 providerSettingsForm: { placeholder: "agent", clearWhenEmpty: "omit" },
242 }),
243 ),
244 apiEndpoint: TrimmedString.pipe(
245 Schema.withDecodingDefault(Effect.succeed("")),
246 Schema.annotateKey({
247 title: "API endpoint",
248 description: "Override the Cursor API endpoint for this instance.",
249 providerSettingsForm: {
250 placeholder: "https://...",
251 clearWhenEmpty: "omit",
252 },
253 }),
254 ),
255 customModels: Schema.Array(Schema.String).pipe(
256 Schema.withDecodingDefault(Effect.succeed([])),
257 Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
258 ),
259 },
260 {
261 order: ["binaryPath", "apiEndpoint"],
262 },
263);
123export type CursorSettings = typeof CursorSettings.Type;264export type CursorSettings = typeof CursorSettings.Type;
124export const OpenCodeSettings = Schema.Struct({265export const OpenCodeSettings = makeProviderSettingsSchema(
125 enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),266 {
126 binaryPath: makeBinaryPathSetting("opencode"),267 enabled: Schema.Boolean.pipe(
127 serverUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),268 Schema.withDecodingDefault(Effect.succeed(true)),
128 serverPassword: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),269 Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
129 customModels: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(Effect.succeed([]))),270 ),
130});271 binaryPath: makeBinaryPathSetting("opencode").pipe(
272 Schema.annotateKey({
273 title: "Binary path",
274 description: "Path to the OpenCode binary.",
275 providerSettingsForm: {
276 placeholder: "opencode",
277 clearWhenEmpty: "omit",
278 },
279 }),
280 ),
281 serverUrl: TrimmedString.pipe(
282 Schema.withDecodingDefault(Effect.succeed("")),
283 Schema.annotateKey({
284 title: "Server URL",
285 description: "Leave blank to let T3 Code spawn the server when needed.",
286 providerSettingsForm: {
287 placeholder: "http://127.0.0.1:4096",
288 clearWhenEmpty: "omit",
289 },
290 }),
291 ),
292 serverPassword: TrimmedString.pipe(
293 Schema.withDecodingDefault(Effect.succeed("")),
294 Schema.annotateKey({
295 title: "Server password",
296 description: "Stored in plain text on disk.",
297 providerSettingsForm: {
298 control: "password",
299 placeholder: "Optional",
300 clearWhenEmpty: "omit",
301 },
302 }),
303 ),
304 customModels: Schema.Array(Schema.String).pipe(
305 Schema.withDecodingDefault(Effect.succeed([])),
306 Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
307 ),
308 },
309 {
310 order: ["binaryPath", "serverUrl", "serverPassword"],
311 },
312);
131export type OpenCodeSettings = typeof OpenCodeSettings.Type;313export type OpenCodeSettings = typeof OpenCodeSettings.Type;
132314
133export const ObservabilitySettings = Schema.Struct({315export const ObservabilitySettings = Schema.Struct({