| 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,7 +13,7 @@ import { useSettings, useUpdateSettings } from "../../hooks/useSettings"; |
| 13 | import { cn } from "../../lib/utils"; | 13 | import { cn } from "../../lib/utils"; |
| 14 | import { normalizeProviderAccentColor } from "../../providerInstances"; | 14 | import { normalizeProviderAccentColor } from "../../providerInstances"; |
| 15 | import { Button } from "../ui/button"; | 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 | import { | 17 | import { |
| 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"; |
| 26 | import { Input } from "../ui/input"; | 26 | import { Input } from "../ui/input"; |
| 27 | import { RadioGroup } from "../ui/radio-group"; | 27 | import { RadioGroup } from "../ui/radio-group"; |
| 28 | import { toastManager } from "../ui/toast"; | 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 | const PROVIDER_ACCENT_SWATCHES = [ | 32 | const 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 { |
| 61 | const INSTANCE_ID_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/; | 62 | const INSTANCE_ID_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/; |
| 62 | const DEFAULT_DRIVER_KIND = ProviderDriverKind.make("codex"); | 63 | const DEFAULT_DRIVER_KIND = ProviderDriverKind.make("codex"); |
| 63 | const DEFAULT_DRIVER_OPTION = DRIVER_OPTIONS[0]!; | 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 | 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 | ]; |
| 90 | 94 | ||
| ... | @@ -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 toggling | 125 | // Driver-specific config drafts keyed by driver so toggling between drivers |
| 122 | // between drivers during the same dialog session doesn't lose in-progress | 126 | // 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 that | 128 | // 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]); |
| 147 | 150 | ||
| ... | @@ -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]); |
| 154 | 157 | ||
| 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; |
| 161 | 168 | ||
| 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) => { |
| 166 | 173 | 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; |
| 180 | 188 | ||
| 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); |
| 190 | 192 | ||
| ... | @@ -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> |
| 435 | 437 | ||
| 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 | <Input | 443 | 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"; |
| 2 | 2 | ||
| 3 | import { ChevronDownIcon, PlusIcon, Trash2Icon, XIcon } from "lucide-react"; | 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 | import { | 5 | import { |
| 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"; |
| 21 | import { Switch } from "../ui/switch"; | 21 | import { Switch } from "../ui/switch"; |
| 22 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | 22 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 23 | import type { DriverOption } from "./providerDriverMeta"; | 23 | import type { DriverOption } from "./providerDriverMeta"; |
| 24 | import { ProviderSettingsForm } from "./ProviderSettingsForm"; | ||
| 24 | import { ProviderModelsSection } from "./ProviderModelsSection"; | 25 | import { ProviderModelsSection } from "./ProviderModelsSection"; |
| 25 | import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; | 26 | import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; |
| 26 | import { | 27 | import { |
| ... | @@ -86,20 +87,6 @@ function redactedEmailPlaceholder(email: string): string { | ... | @@ -86,20 +87,6 @@ function redactedEmailPlaceholder(email: string): string { |
| 86 | }).join(""); | 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 | * Read a string[] at `key` from the opaque config blob, filtering out | 91 | * Read a string[] at `key` from the opaque config blob, filtering out |
| 105 | * non-string entries. Used for `customModels`, which is always typed as | 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,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 | } |
| 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 | * Set `key` to an arbitrary value on the opaque config blob. Unlike | 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 | * caller is responsible for deciding whether an empty array / empty | 106 | * caller is responsible for deciding whether an empty array / empty |
| 146 | * object should be stored explicitly (e.g. `customModels: []` is a | 107 | * object should be stored explicitly (e.g. `customModels: []` is a |
| 147 | * meaningful "user cleared their custom list" state distinct from | 108 | * 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 instances | 434 | * 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 | }; |
| 587 | 548 | ||
| 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 !== undefined | 552 | nextConfig !== undefined |
| ... | @@ -759,28 +719,15 @@ export function ProviderInstanceCard({ | ... | @@ -759,28 +719,15 @@ export function ProviderInstanceCard({ |
| 759 | /> | 719 | /> |
| 760 | </div> | 720 | </div> |
| 761 | 721 | ||
| 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 | <DraftInput | 726 | 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 | ))} | ||
| 784 | 731 | ||
| 785 | {driverOption !== undefined ? ( | 732 | {driverOption !== undefined ? ( |
| 786 | <ProviderModelsSection | 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,6 +1,6 @@ |
| 1 | import { ArchiveIcon, ArchiveX, LoaderIcon, PlusIcon, RefreshCwIcon } from "lucide-react"; | 1 | import { ArchiveIcon, ArchiveX, LoaderIcon, PlusIcon, RefreshCwIcon } from "lucide-react"; |
| 2 | import { useQueryClient } from "@tanstack/react-query"; | 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 | import { | 4 | import { |
| 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"; |
| 57 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | 57 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 58 | import { AddProviderInstanceDialog } from "./AddProviderInstanceDialog"; | 58 | import { AddProviderInstanceDialog } from "./AddProviderInstanceDialog"; |
| 59 | import { ProviderInstanceCard } from "./ProviderInstanceCard"; | 59 | import { ProviderInstanceCard } from "./ProviderInstanceCard"; |
| 60 | import { getDriverOption } from "./providerDriverMeta"; | 60 | import { DRIVER_OPTIONS, getDriverOption } from "./providerDriverMeta"; |
| 61 | import { buildProviderInstanceUpdatePatch } from "./SettingsPanels.logic"; | 61 | import { buildProviderInstanceUpdatePatch } from "./SettingsPanels.logic"; |
| 62 | import { | 62 | import { |
| 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 | } |
| 115 | 115 | ||
| 116 | type InstallProviderSettings = { | 116 | const 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 | |||
| 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; | ||
| 166 | 119 | ||
| 167 | function ProviderLastChecked({ lastCheckedAt }: { lastCheckedAt: string | null }) { | 120 | function 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 @@ |
| 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 | import { ClaudeAI, CursorIcon, type Icon, OpenAI, OpenCodeIcon } from "../Icons"; | 9 | import { ClaudeAI, CursorIcon, type Icon, OpenAI, OpenCodeIcon } from "../Icons"; |
| 3 | 10 | ||
| 4 | /** | 11 | type ProviderSettingsSchema = { |
| 5 | * A single editable field exposed on a provider instance. `key` must match | 12 | readonly fields: Readonly<Record<string, Schema.Top>>; |
| 6 | * the corresponding driver config schema in | 13 | } & 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 | */ | ||
| 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 | } | ||
| 18 | 14 | ||
| 19 | /** | 15 | /** |
| 20 | * Presentation + editable-field metadata for a registered driver. Shared | 16 | * Browser-safe provider definition. This is deliberately shaped like the |
| 21 | * between the Add-Instance dialog and the per-instance settings card so | 17 | * 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 | */ |
| 24 | export interface DriverOption { | 21 | export 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 to | 27 | * Optional short label rendered as a `variant="warning"` badge next to |
| 31 | * the instance title. Used to flag drivers that still ship under an | 28 | * 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 | } |
| 38 | 35 | ||
| 39 | export const DRIVER_OPTIONS: readonly DriverOption[] = [ | 36 | export 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 | ]; |
| 139 | 63 | ||
| 140 | export const DRIVER_OPTION_BY_VALUE: Partial<Record<ProviderDriverKind, DriverOption>> = | 64 | export 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 | |||
| 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 | * Look up the driver metadata for an instance's `driver` field. Accepts | 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,5 +78,5 @@ export const DRIVER_OPTION_BY_VALUE: Partial<Record<ProviderDriverKind, DriverOp |
| 147 | */ | 78 | */ |
| 148 | export function getDriverOption(driver: ProviderDriverKind | undefined): DriverOption | undefined { | 79 | export 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 | ); |
| 98 | 98 | ||
| 99 | export const CodexSettings = Schema.Struct({ | 99 | export type ProviderSettingsFormControl = "text" | "password" | "textarea" | "switch"; |
| 100 | enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), | 100 | |
| 101 | binaryPath: makeBinaryPathSetting("codex"), | 101 | export 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 | |||
| 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 | export type CodexSettings = typeof CodexSettings.Type; | 185 | export type CodexSettings = typeof CodexSettings.Type; |
| 107 | 186 | ||
| 108 | export const ClaudeSettings = Schema.Struct({ | 187 | export 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 | ); | ||
| 115 | export type ClaudeSettings = typeof ClaudeSettings.Type; | 229 | export type ClaudeSettings = typeof ClaudeSettings.Type; |
| 116 | 230 | ||
| 117 | export const CursorSettings = Schema.Struct({ | 231 | export 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 | ); | ||
| 123 | export type CursorSettings = typeof CursorSettings.Type; | 264 | export type CursorSettings = typeof CursorSettings.Type; |
| 124 | export const OpenCodeSettings = Schema.Struct({ | 265 | export 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 | ); | ||
| 131 | export type OpenCodeSettings = typeof OpenCodeSettings.Type; | 313 | export type OpenCodeSettings = typeof OpenCodeSettings.Type; |
| 132 | 314 | ||
| 133 | export const ObservabilitySettings = Schema.Struct({ | 315 | export const ObservabilitySettings = Schema.Struct({ |