| author | |
| committer | |
| log | 460d9c3eb977bc0341876527af7fde591133925f |
| tree | 501de003cb72c522684678407119c37bf138a7a9 |
| parent | cb8015a34d4fc687ca1e03f7bbec80dc13da6390 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 13 | import { cn } from "../../lib/utils"; |
| 14 | 14 | import { normalizeProviderAccentColor } from "../../providerInstances"; |
| 15 | 15 | import { Button } from "../ui/button"; |
| 16 | import { ACPRegistryIcon, Gemini, GithubCopilotIcon, PiAgentIcon } from "../Icons"; | |
| 16 | import { ACPRegistryIcon, Gemini, GithubCopilotIcon, PiAgentIcon, type Icon } from "../Icons"; | |
| 17 | 17 | import { |
| 18 | 18 | Dialog, |
| 19 | 19 | DialogDescription, |
| ... | ... | @@ -26,7 +26,8 @@ import { Badge } from "../ui/badge"; |
| 26 | 26 | import { Input } from "../ui/input"; |
| 27 | 27 | import { RadioGroup } from "../ui/radio-group"; |
| 28 | 28 | import { toastManager } from "../ui/toast"; |
| 29 | import { DRIVER_OPTION_BY_VALUE, DRIVER_OPTIONS, type DriverOption } from "./providerDriverMeta"; | |
| 29 | import { DRIVER_OPTION_BY_VALUE, DRIVER_OPTIONS } from "./providerDriverMeta"; | |
| 30 | import { ProviderSettingsForm, deriveProviderSettingsFields } from "./ProviderSettingsForm"; | |
| 30 | 31 | |
| 31 | 32 | const PROVIDER_ACCENT_SWATCHES = [ |
| 32 | 33 | "#2563eb", |
| ... | ... | @@ -61,30 +62,33 @@ function deriveInstanceId(driver: ProviderDriverKind, label: string): string { |
| 61 | 62 | const INSTANCE_ID_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/; |
| 62 | 63 | const DEFAULT_DRIVER_KIND = ProviderDriverKind.make("codex"); |
| 63 | 64 | const DEFAULT_DRIVER_OPTION = DRIVER_OPTIONS[0]!; |
| 64 | const COMING_SOON_DRIVER_OPTIONS: readonly DriverOption[] = [ | |
| 65 | const EMPTY_CONFIG_DRAFT: Record<string, unknown> = {}; | |
| 66 | interface ComingSoonDriverOption { | |
| 67 | readonly value: ProviderDriverKind; | |
| 68 | readonly label: string; | |
| 69 | readonly icon: Icon; | |
| 70 | } | |
| 71 | ||
| 72 | const COMING_SOON_DRIVER_OPTIONS: readonly ComingSoonDriverOption[] = [ | |
| 65 | 73 | { |
| 66 | 74 | value: ProviderDriverKind.make("githubCopilot"), |
| 67 | 75 | label: "Github Copilot", |
| 68 | 76 | icon: GithubCopilotIcon, |
| 69 | fields: [], | |
| 70 | 77 | }, |
| 71 | 78 | { |
| 72 | 79 | value: ProviderDriverKind.make("gemini"), |
| 73 | 80 | label: "Gemini", |
| 74 | 81 | icon: Gemini, |
| 75 | fields: [], | |
| 76 | 82 | }, |
| 77 | 83 | { |
| 78 | 84 | value: ProviderDriverKind.make("acpRegistry"), |
| 79 | 85 | label: "ACP Registry", |
| 80 | 86 | icon: ACPRegistryIcon, |
| 81 | fields: [], | |
| 82 | 87 | }, |
| 83 | 88 | { |
| 84 | 89 | value: ProviderDriverKind.make("piAgent"), |
| 85 | 90 | label: "Pi Agent", |
| 86 | 91 | icon: PiAgentIcon, |
| 87 | fields: [], | |
| 88 | 92 | }, |
| 89 | 93 | ]; |
| 90 | 94 | |
| ... | ... | @@ -118,10 +122,9 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns |
| 118 | 122 | const [accentColor, setAccentColor] = useState<string>(""); |
| 119 | 123 | const [instanceId, setInstanceId] = useState(""); |
| 120 | 124 | const [instanceIdDirty, setInstanceIdDirty] = useState(false); |
| 121 | // Driver-specific field values keyed by `${driver}:${fieldKey}` so toggling | |
| 122 | // between drivers during the same dialog session doesn't lose in-progress | |
| 123 | // input. Only the active driver's values are persisted on save. | |
| 124 | const [fieldValues, setFieldValues] = useState<Record<string, string>>({}); | |
| 125 | // Driver-specific config drafts keyed by driver so toggling between drivers | |
| 126 | // during the same dialog session does not lose in-progress input. | |
| 127 | const [configByDriver, setConfigByDriver] = useState<Record<string, Record<string, unknown>>>({}); | |
| 125 | 128 | // Errors are suppressed until the user has tried to submit once. After that |
| 126 | 129 | // they update live so fixing the problem clears the message in place. |
| 127 | 130 | const [hasAttemptedSubmit, setHasAttemptedSubmit] = useState(false); |
| ... | ... | @@ -141,7 +144,7 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns |
| 141 | 144 | setInstanceId(""); |
| 142 | 145 | setWizardStep(0); |
| 143 | 146 | setInstanceIdDirty(false); |
| 144 | setFieldValues({}); | |
| 147 | setConfigByDriver({}); | |
| 145 | 148 | setHasAttemptedSubmit(false); |
| 146 | 149 | }, [open]); |
| 147 | 150 | |
| ... | ... | @@ -153,23 +156,28 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns |
| 153 | 156 | }, [driver, label, instanceIdDirty]); |
| 154 | 157 | |
| 155 | 158 | const driverOption = DRIVER_OPTION_BY_VALUE[driver] ?? DEFAULT_DRIVER_OPTION; |
| 159 | const driverSettingsFields = useMemo( | |
| 160 | () => deriveProviderSettingsFields(driverOption), | |
| 161 | [driverOption], | |
| 162 | ); | |
| 156 | 163 | const instanceIdError = validateInstanceId(instanceId, existingIds); |
| 157 | 164 | const showInstanceIdError = hasAttemptedSubmit && instanceIdError !== null; |
| 158 | 165 | const previewLabel = label.trim() || `${driverOption.label} Workspace`; |
| 159 | 166 | const wizardSteps = ["Driver", "Identity", "Config"] as const; |
| 160 | 167 | const wizardStepSummaries = [driverOption.label, previewLabel, null] as const; |
| 161 | 168 | |
| 162 | const getFieldValue = useCallback( | |
| 163 | (fieldKey: string) => fieldValues[`${driver}:${fieldKey}`] ?? "", | |
| 164 | [driver, fieldValues], | |
| 165 | ); | |
| 166 | ||
| 167 | const setFieldValue = useCallback( | |
| 168 | (fieldKey: string, value: string) => { | |
| 169 | setFieldValues((existing) => ({ | |
| 170 | ...existing, | |
| 171 | [`${driver}:${fieldKey}`]: value, | |
| 172 | })); | |
| 169 | const configDraft = configByDriver[driver] ?? EMPTY_CONFIG_DRAFT; | |
| 170 | const setConfigDraft = useCallback( | |
| 171 | (config: Record<string, unknown> | undefined) => { | |
| 172 | setConfigByDriver((existing) => { | |
| 173 | const next = { ...existing }; | |
| 174 | if (config === undefined || Object.keys(config).length === 0) { | |
| 175 | delete next[driver]; | |
| 176 | } else { | |
| 177 | next[driver] = config; | |
| 178 | } | |
| 179 | return next; | |
| 180 | }); | |
| 173 | 181 | }, |
| 174 | 182 | [driver], |
| 175 | 183 | ); |
| ... | ... | @@ -178,13 +186,7 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns |
| 178 | 186 | setHasAttemptedSubmit(true); |
| 179 | 187 | if (instanceIdError !== null) return; |
| 180 | 188 | |
| 181 | // Build the config blob from non-empty driver-specific field values. | |
| 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 | } | |
| 189 | const config = configByDriver[driver] ?? {}; | |
| 188 | 190 | const hasConfig = Object.keys(config).length > 0; |
| 189 | 191 | const normalizedAccentColor = normalizeProviderAccentColor(accentColor); |
| 190 | 192 | |
| ... | ... | @@ -222,7 +224,7 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns |
| 222 | 224 | }, [ |
| 223 | 225 | driver, |
| 224 | 226 | driverOption, |
| 225 | fieldValues, | |
| 227 | configByDriver, | |
| 226 | 228 | instanceId, |
| 227 | 229 | instanceIdError, |
| 228 | 230 | label, |
| ... | ... | @@ -433,25 +435,15 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns |
| 433 | 435 | </span> |
| 434 | 436 | </div> |
| 435 | 437 | |
| 436 | {driverOption.fields.length > 0 ? ( | |
| 438 | {driverSettingsFields.length > 0 ? ( | |
| 437 | 439 | <div className={cn("grid gap-4", wizardStep !== 2 && "hidden")}> |
| 438 | {driverOption.fields.map((field) => ( | |
| 439 | <label key={field.key} className="grid gap-1.5"> | |
| 440 | <span className="text-xs font-medium text-foreground">{field.label}</span> | |
| 441 | <Input | |
| 442 | className="bg-background" | |
| 443 | type={field.type === "password" ? "password" : undefined} | |
| 444 | autoComplete={field.type === "password" ? "off" : undefined} | |
| 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 | ))} | |
| 440 | <ProviderSettingsForm | |
| 441 | definition={driverOption} | |
| 442 | value={configDraft} | |
| 443 | idPrefix={`add-provider-${driver}`} | |
| 444 | variant="dialog" | |
| 445 | onChange={setConfigDraft} | |
| 446 | /> | |
| 455 | 447 | </div> |
| 456 | 448 | ) : wizardStep === 2 ? ( |
| 457 | 449 | <div className="grid gap-2"> |
apps/web/src/components/settings/ProviderInstanceCard.tsx+14-67| ... | ... | @@ -1,7 +1,7 @@ |
| 1 | 1 | "use client"; |
| 2 | 2 | |
| 3 | 3 | import { ChevronDownIcon, PlusIcon, Trash2Icon, XIcon } from "lucide-react"; |
| 4 | import { useEffect, useMemo, useState } from "react"; | |
| 4 | import { useEffect, useMemo, useState, type ReactNode } from "react"; | |
| 5 | 5 | import { |
| 6 | 6 | isProviderDriverKind, |
| 7 | 7 | type ProviderInstanceConfig, |
| ... | ... | @@ -21,6 +21,7 @@ import { DraftInput } from "../ui/draft-input"; |
| 21 | 21 | import { Switch } from "../ui/switch"; |
| 22 | 22 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 23 | 23 | import type { DriverOption } from "./providerDriverMeta"; |
| 24 | import { ProviderSettingsForm } from "./ProviderSettingsForm"; | |
| 24 | 25 | import { ProviderModelsSection } from "./ProviderModelsSection"; |
| 25 | 26 | import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; |
| 26 | 27 | import { |
| ... | ... | @@ -86,20 +87,6 @@ function redactedEmailPlaceholder(email: string): string { |
| 86 | 87 | }).join(""); |
| 87 | 88 | } |
| 88 | 89 | |
| 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 | */ | |
| 97 | function 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 | 91 | * Read a string[] at `key` from the opaque config blob, filtering out |
| 105 | 92 | * non-string entries. Used for `customModels`, which is always typed as |
| ... | ... | @@ -113,35 +100,9 @@ function readConfigStringArray(config: unknown, key: string): ReadonlyArray<stri |
| 113 | 100 | return value.filter((entry): entry is string => typeof entry === "string"); |
| 114 | 101 | } |
| 115 | 102 | |
| 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 | */ | |
| 126 | function 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 | 104 | * Set `key` to an arbitrary value on the opaque config blob. Unlike |
| 144 | * `nextConfigBlobWithString`, does not drop empty-looking values — the | |
| 105 | * provider settings field updates, does not drop empty-looking values — the | |
| 145 | 106 | * caller is responsible for deciding whether an empty array / empty |
| 146 | 107 | * object should be stored explicitly (e.g. `customModels: []` is a |
| 147 | 108 | * meaningful "user cleared their custom list" state distinct from |
| ... | ... | @@ -473,7 +434,7 @@ interface ProviderInstanceCardProps { |
| 473 | 434 | * default slots supply a reset-to-factory control here; custom instances |
| 474 | 435 | * omit it. |
| 475 | 436 | */ |
| 476 | readonly headerAction?: React.ReactNode | undefined; | |
| 437 | readonly headerAction?: ReactNode | undefined; | |
| 477 | 438 | readonly hiddenModels: ReadonlyArray<string>; |
| 478 | 439 | readonly favoriteModels: ReadonlyArray<string>; |
| 479 | 440 | readonly modelOrder: ReadonlyArray<string>; |
| ... | ... | @@ -585,8 +546,7 @@ export function ProviderInstanceCard({ |
| 585 | 546 | ); |
| 586 | 547 | }; |
| 587 | 548 | |
| 588 | const updateConfigField = (key: string, value: string) => { | |
| 589 | const nextConfig = nextConfigBlobWithString(instance.config, key, value); | |
| 549 | const updateConfig = (nextConfig: Record<string, unknown> | undefined) => { | |
| 590 | 550 | const { config: _omit, ...rest } = instance; |
| 591 | 551 | onUpdate( |
| 592 | 552 | nextConfig !== undefined |
| ... | ... | @@ -759,28 +719,15 @@ export function ProviderInstanceCard({ |
| 759 | 719 | /> |
| 760 | 720 | </div> |
| 761 | 721 | |
| 762 | {driverOption?.fields.map((field) => ( | |
| 763 | <div key={field.key} className="border-t border-border/60 px-4 py-3 sm:px-5"> | |
| 764 | <label htmlFor={`provider-instance-${instanceId}-${field.key}`} className="block"> | |
| 765 | <span className="text-xs font-medium text-foreground">{field.label}</span> | |
| 766 | <DraftInput | |
| 767 | id={`provider-instance-${instanceId}-${field.key}`} | |
| 768 | className="mt-1.5" | |
| 769 | type={field.type === "password" ? "password" : undefined} | |
| 770 | autoComplete={field.type === "password" ? "off" : undefined} | |
| 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 | ))} | |
| 722 | {driverOption ? ( | |
| 723 | <ProviderSettingsForm | |
| 724 | definition={driverOption} | |
| 725 | value={instance.config} | |
| 726 | idPrefix={`provider-instance-${instanceId}`} | |
| 727 | variant="card" | |
| 728 | onChange={updateConfig} | |
| 729 | /> | |
| 730 | ) : null} | |
| 784 | 731 | |
| 785 | 732 | {driverOption !== undefined ? ( |
| 786 | 733 | <ProviderModelsSection |
apps/web/src/components/settings/ProviderSettingsForm.test.ts created+131| ... | ... | @@ -0,0 +1,131 @@ |
| 1 | import { describe, expect, it } from "vitest"; | |
| 2 | import { ProviderDriverKind } from "@t3tools/contracts"; | |
| 3 | ||
| 4 | import { DRIVER_OPTION_BY_VALUE } from "./providerDriverMeta"; | |
| 5 | import { | |
| 6 | deriveProviderSettingsFields, | |
| 7 | nextProviderConfigWithFieldValue, | |
| 8 | readProviderConfigBoolean, | |
| 9 | readProviderConfigString, | |
| 10 | } from "./ProviderSettingsForm"; | |
| 11 | ||
| 12 | describe("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 | ||
| 3 | import { useMemo, type ReactNode } from "react"; | |
| 4 | import { Schema } from "effect"; | |
| 5 | import type { | |
| 6 | ProviderSettingsFormAnnotation, | |
| 7 | ProviderSettingsFormControl, | |
| 8 | ProviderSettingsFormSchemaAnnotation, | |
| 9 | } from "@t3tools/contracts"; | |
| 10 | ||
| 11 | import { cn } from "../../lib/utils"; | |
| 12 | import { DraftInput } from "../ui/draft-input"; | |
| 13 | import { Input } from "../ui/input"; | |
| 14 | import { Switch } from "../ui/switch"; | |
| 15 | import { Textarea } from "../ui/textarea"; | |
| 16 | import type { ProviderClientDefinition } from "./providerDriverMeta"; | |
| 17 | ||
| 18 | export 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 | ||
| 28 | function 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 | ||
| 35 | function readFieldAnnotations( | |
| 36 | fieldSchema: ProviderClientDefinition["settingsSchema"]["fields"][string], | |
| 37 | ) { | |
| 38 | return Schema.resolveAnnotationsKey(fieldSchema) ?? Schema.resolveAnnotations(fieldSchema); | |
| 39 | } | |
| 40 | ||
| 41 | function 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 | ||
| 50 | function readProviderSettingsFormAnnotation( | |
| 51 | fieldSchema: ProviderClientDefinition["settingsSchema"]["fields"][string], | |
| 52 | ): ProviderSettingsFormAnnotation { | |
| 53 | const annotation = readFieldAnnotations(fieldSchema)?.providerSettingsForm; | |
| 54 | return annotation ?? {}; | |
| 55 | } | |
| 56 | ||
| 57 | function readProviderSettingsFormSchemaAnnotation( | |
| 58 | definition: ProviderClientDefinition, | |
| 59 | ): ProviderSettingsFormSchemaAnnotation { | |
| 60 | return Schema.resolveAnnotations(definition.settingsSchema)?.providerSettingsFormSchema ?? {}; | |
| 61 | } | |
| 62 | ||
| 63 | function 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 | ||
| 74 | export 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 | ||
| 116 | export 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 | ||
| 122 | export 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 | ||
| 132 | export 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 | ||
| 159 | interface 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 | ||
| 167 | function 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 | ||
| 177 | interface 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 | ||
| 185 | function 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 | ||
| 279 | export 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 | 1 | import { ArchiveIcon, ArchiveX, LoaderIcon, PlusIcon, RefreshCwIcon } from "lucide-react"; |
| 2 | 2 | import { useQueryClient } from "@tanstack/react-query"; |
| 3 | import { type ReactNode, useCallback, useMemo, useRef, useState } from "react"; | |
| 3 | import { useCallback, useMemo, useRef, useState } from "react"; | |
| 4 | 4 | import { |
| 5 | 5 | defaultInstanceIdForDriver, |
| 6 | 6 | type DesktopUpdateChannel, |
| ... | ... | @@ -57,7 +57,7 @@ import { stackedThreadToast, toastManager } from "../ui/toast"; |
| 57 | 57 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 58 | 58 | import { AddProviderInstanceDialog } from "./AddProviderInstanceDialog"; |
| 59 | 59 | import { ProviderInstanceCard } from "./ProviderInstanceCard"; |
| 60 | import { getDriverOption } from "./providerDriverMeta"; | |
| 60 | import { DRIVER_OPTIONS, getDriverOption } from "./providerDriverMeta"; | |
| 61 | 61 | import { buildProviderInstanceUpdatePatch } from "./SettingsPanels.logic"; |
| 62 | 62 | import { |
| 63 | 63 | SettingResetButton, |
| ... | ... | @@ -113,56 +113,9 @@ function withoutProviderInstanceFavorites( |
| 113 | 113 | return favorites.filter((favorite) => favorite.provider !== instanceId); |
| 114 | 114 | } |
| 115 | 115 | |
| 116 | type InstallProviderSettings = { | |
| 117 | provider: ProviderDriverKind; | |
| 118 | title: string; | |
| 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 | ||
| 131 | const 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; | |
| 116 | const PROVIDER_SETTINGS = DRIVER_OPTIONS.map((definition) => ({ | |
| 117 | provider: definition.value, | |
| 118 | })); | |
| 166 | 119 | |
| 167 | 120 | function ProviderLastChecked({ lastCheckedAt }: { lastCheckedAt: string | null }) { |
| 168 | 121 | useRelativeTimeTick(); |
apps/web/src/components/settings/providerDriverMeta.ts+32-101| ... | ... | @@ -1,31 +1,28 @@ |
| 1 | import { ProviderDriverKind } from "@t3tools/contracts"; | |
| 1 | import { | |
| 2 | ClaudeSettings, | |
| 3 | CodexSettings, | |
| 4 | CursorSettings, | |
| 5 | OpenCodeSettings, | |
| 6 | ProviderDriverKind, | |
| 7 | } from "@t3tools/contracts"; | |
| 8 | import type { Schema } from "effect"; | |
| 2 | 9 | import { ClaudeAI, CursorIcon, type Icon, OpenAI, OpenCodeIcon } from "../Icons"; |
| 3 | 10 | |
| 4 | /** | |
| 5 | * A single editable field exposed on a provider instance. `key` must match | |
| 6 | * the corresponding driver config schema in | |
| 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 | */ | |
| 11 | export interface DriverFieldDef { | |
| 12 | readonly key: string; | |
| 13 | readonly label: string; | |
| 14 | readonly placeholder?: string; | |
| 15 | readonly description?: string; | |
| 16 | readonly type?: "text" | "password"; | |
| 17 | } | |
| 11 | type ProviderSettingsSchema = { | |
| 12 | readonly fields: Readonly<Record<string, Schema.Top>>; | |
| 13 | } & Schema.Top; | |
| 18 | 14 | |
| 19 | 15 | /** |
| 20 | * Presentation + editable-field metadata for a registered driver. Shared | |
| 21 | * between the Add-Instance dialog and the per-instance settings card so | |
| 22 | * both surfaces offer the same keys for the same driver. | |
| 16 | * Browser-safe provider definition. This is deliberately shaped like the | |
| 17 | * future provider package client export: the core web app gets a schema with | |
| 18 | * field annotations plus provider-level presentation metadata, then renders | |
| 19 | * settings generically. | |
| 23 | 20 | */ |
| 24 | export interface DriverOption { | |
| 21 | export interface ProviderClientDefinition { | |
| 25 | 22 | readonly value: ProviderDriverKind; |
| 26 | 23 | readonly label: string; |
| 27 | 24 | readonly icon: Icon; |
| 28 | readonly fields: readonly DriverFieldDef[]; | |
| 25 | readonly settingsSchema: ProviderSettingsSchema; | |
| 29 | 26 | /** |
| 30 | 27 | * Optional short label rendered as a `variant="warning"` badge next to |
| 31 | 28 | * the instance title. Used to flag drivers that still ship under an |
| ... | ... | @@ -36,109 +33,43 @@ export interface DriverOption { |
| 36 | 33 | readonly badgeLabel?: string; |
| 37 | 34 | } |
| 38 | 35 | |
| 39 | export const DRIVER_OPTIONS: readonly DriverOption[] = [ | |
| 36 | export const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] = [ | |
| 40 | 37 | { |
| 41 | 38 | value: ProviderDriverKind.make("codex"), |
| 42 | 39 | label: "Codex", |
| 43 | 40 | icon: OpenAI, |
| 44 | fields: [ | |
| 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 | ], | |
| 41 | settingsSchema: CodexSettings, | |
| 65 | 42 | }, |
| 66 | 43 | { |
| 67 | 44 | value: ProviderDriverKind.make("claudeAgent"), |
| 68 | 45 | label: "Claude", |
| 69 | 46 | icon: ClaudeAI, |
| 70 | fields: [ | |
| 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 | ], | |
| 47 | settingsSchema: ClaudeSettings, | |
| 91 | 48 | }, |
| 92 | 49 | { |
| 93 | 50 | value: ProviderDriverKind.make("cursor"), |
| 94 | 51 | label: "Cursor", |
| 95 | 52 | icon: CursorIcon, |
| 96 | 53 | badgeLabel: "Early Access", |
| 97 | fields: [ | |
| 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 | ], | |
| 54 | settingsSchema: CursorSettings, | |
| 111 | 55 | }, |
| 112 | 56 | { |
| 113 | 57 | value: ProviderDriverKind.make("opencode"), |
| 114 | 58 | label: "OpenCode", |
| 115 | 59 | icon: OpenCodeIcon, |
| 116 | fields: [ | |
| 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 | ], | |
| 60 | settingsSchema: OpenCodeSettings, | |
| 137 | 61 | }, |
| 138 | 62 | ]; |
| 139 | 63 | |
| 140 | export const DRIVER_OPTION_BY_VALUE: Partial<Record<ProviderDriverKind, DriverOption>> = | |
| 141 | Object.fromEntries(DRIVER_OPTIONS.map((option) => [option.value, option])); | |
| 64 | export const PROVIDER_CLIENT_DEFINITION_BY_VALUE: Partial< | |
| 65 | Record<ProviderDriverKind, ProviderClientDefinition> | |
| 66 | > = Object.fromEntries( | |
| 67 | PROVIDER_CLIENT_DEFINITIONS.map((definition) => [definition.value, definition]), | |
| 68 | ); | |
| 69 | ||
| 70 | export const DRIVER_OPTIONS = PROVIDER_CLIENT_DEFINITIONS; | |
| 71 | export const DRIVER_OPTION_BY_VALUE = PROVIDER_CLIENT_DEFINITION_BY_VALUE; | |
| 72 | export type DriverOption = ProviderClientDefinition; | |
| 142 | 73 | |
| 143 | 74 | /** |
| 144 | 75 | * 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 | 78 | */ |
| 148 | 79 | export function getDriverOption(driver: ProviderDriverKind | undefined): DriverOption | undefined { |
| 149 | 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 | 96 | Schema.withDecodingDefault(Effect.succeed(fallback)), |
| 97 | 97 | ); |
| 98 | 98 | |
| 99 | export const CodexSettings = Schema.Struct({ | |
| 100 | enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), | |
| 101 | binaryPath: makeBinaryPathSetting("codex"), | |
| 102 | homePath: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), | |
| 103 | shadowHomePath: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), | |
| 104 | customModels: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(Effect.succeed([]))), | |
| 105 | }); | |
| 99 | export type ProviderSettingsFormControl = "text" | "password" | "textarea" | "switch"; | |
| 100 | ||
| 101 | export interface ProviderSettingsFormAnnotation { | |
| 102 | readonly control?: ProviderSettingsFormControl | undefined; | |
| 103 | readonly placeholder?: string | undefined; | |
| 104 | readonly hidden?: boolean | undefined; | |
| 105 | readonly clearWhenEmpty?: "omit" | "persist" | undefined; | |
| 106 | } | |
| 107 | ||
| 108 | export interface ProviderSettingsFormSchemaAnnotation { | |
| 109 | readonly order?: readonly string[] | undefined; | |
| 110 | } | |
| 111 | ||
| 112 | declare module "effect/Schema" { | |
| 113 | namespace Annotations { | |
| 114 | interface Annotations { | |
| 115 | readonly providerSettingsForm?: ProviderSettingsFormAnnotation | undefined; | |
| 116 | readonly providerSettingsFormSchema?: ProviderSettingsFormSchemaAnnotation | undefined; | |
| 117 | } | |
| 118 | } | |
| 119 | } | |
| 120 | ||
| 121 | export type ProviderSettingsOrder<Fields extends Schema.Struct.Fields> = readonly Extract< | |
| 122 | keyof Fields, | |
| 123 | string | |
| 124 | >[]; | |
| 125 | ||
| 126 | export 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 | ||
| 140 | export 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 | ); | |
| 106 | 185 | export type CodexSettings = typeof CodexSettings.Type; |
| 107 | 186 | |
| 108 | export const ClaudeSettings = Schema.Struct({ | |
| 109 | enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), | |
| 110 | binaryPath: makeBinaryPathSetting("claude"), | |
| 111 | homePath: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), | |
| 112 | customModels: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(Effect.succeed([]))), | |
| 113 | launchArgs: Schema.String.pipe(Schema.withDecodingDefault(Effect.succeed(""))), | |
| 114 | }); | |
| 187 | export const ClaudeSettings = makeProviderSettingsSchema( | |
| 188 | { | |
| 189 | enabled: Schema.Boolean.pipe( | |
| 190 | Schema.withDecodingDefault(Effect.succeed(true)), | |
| 191 | Schema.annotateKey({ providerSettingsForm: { hidden: true } }), | |
| 192 | ), | |
| 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 | ); | |
| 115 | 229 | export type ClaudeSettings = typeof ClaudeSettings.Type; |
| 116 | 230 | |
| 117 | export const CursorSettings = Schema.Struct({ | |
| 118 | enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), | |
| 119 | binaryPath: makeBinaryPathSetting("agent"), | |
| 120 | apiEndpoint: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), | |
| 121 | customModels: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(Effect.succeed([]))), | |
| 122 | }); | |
| 231 | export const CursorSettings = makeProviderSettingsSchema( | |
| 232 | { | |
| 233 | enabled: Schema.Boolean.pipe( | |
| 234 | Schema.withDecodingDefault(Effect.succeed(false)), | |
| 235 | Schema.annotateKey({ providerSettingsForm: { hidden: true } }), | |
| 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 | ); | |
| 123 | 264 | export type CursorSettings = typeof CursorSettings.Type; |
| 124 | export const OpenCodeSettings = Schema.Struct({ | |
| 125 | enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), | |
| 126 | binaryPath: makeBinaryPathSetting("opencode"), | |
| 127 | serverUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), | |
| 128 | serverPassword: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), | |
| 129 | customModels: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(Effect.succeed([]))), | |
| 130 | }); | |
| 265 | export const OpenCodeSettings = makeProviderSettingsSchema( | |
| 266 | { | |
| 267 | enabled: Schema.Boolean.pipe( | |
| 268 | Schema.withDecodingDefault(Effect.succeed(true)), | |
| 269 | Schema.annotateKey({ providerSettingsForm: { hidden: true } }), | |
| 270 | ), | |
| 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 | ); | |
| 131 | 313 | export type OpenCodeSettings = typeof OpenCodeSettings.Type; |
| 132 | 314 | |
| 133 | 315 | export const ObservabilitySettings = Schema.Struct({ |