authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-03-24 04:34:11-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-03-24 00:34:11-07:00
logd6408a29494c27d55a71fbb62f9ea9a8c6241786
treeaca959b334167214fcf1511e4f39b08742c6a5d8
parente823f8f14ecb99c92345dbd72ba82b836858ba09
signature Signed by PGP key B5690EEEBB952194

refactor(settings): simplify settings layout and controls (#1288)


5 files changed, 833 insertions(+), 538 deletions(-)

apps/web/src/appSettings.test.ts+31
......@@ -9,6 +9,7 @@ import {
99 getCustomModelsByProvider,
1010 getCustomModelsForProvider,
1111 getDefaultCustomModelsForProvider,
12 getProviderStartOptions,
1213 MODEL_PROVIDER_SETTINGS,
1314 normalizeCustomModelSlugs,
1415 patchCustomModels,
......@@ -118,6 +119,35 @@ describe("provider-specific custom models", () => {
118119 });
119120});
120121
122describe("getProviderStartOptions", () => {
123 it("returns only populated provider overrides", () => {
124 expect(
125 getProviderStartOptions({
126 claudeBinaryPath: "/usr/local/bin/claude",
127 codexBinaryPath: "",
128 codexHomePath: "/Users/you/.codex",
129 }),
130 ).toEqual({
131 claudeAgent: {
132 binaryPath: "/usr/local/bin/claude",
133 },
134 codex: {
135 homePath: "/Users/you/.codex",
136 },
137 });
138 });
139
140 it("returns undefined when no provider overrides are configured", () => {
141 expect(
142 getProviderStartOptions({
143 claudeBinaryPath: "",
144 codexBinaryPath: "",
145 codexHomePath: "",
146 }),
147 ).toBeUndefined();
148 });
149});
150
121151describe("provider-indexed custom model settings", () => {
122152 const settings = {
123153 customCodexModels: ["custom/codex-model"],
......@@ -209,6 +239,7 @@ describe("AppSettingsSchema", () => {
209239 }),
210240 ),
211241 ).toMatchObject({
242 claudeBinaryPath: "",
212243 codexBinaryPath: "/usr/local/bin/codex",
213244 codexHomePath: "",
214245 defaultThreadEnvMode: "local",
apps/web/src/appSettings.ts+30-1
......@@ -1,6 +1,10 @@
11import { useCallback } from "react";
22import { Option, Schema } from "effect";
3import { TrimmedNonEmptyString, type ProviderKind } from "@t3tools/contracts";
3import {
4 TrimmedNonEmptyString,
5 type ProviderKind,
6 type ProviderStartOptions,
7} from "@t3tools/contracts";
48import {
59 getDefaultModel,
610 getModelOptions,
......@@ -47,6 +51,7 @@ const withDefaults =
4751 );
4852
4953export const AppSettingsSchema = Schema.Struct({
54 claudeBinaryPath: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")),
5055 codexBinaryPath: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")),
5156 codexHomePath: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")),
5257 defaultThreadEnvMode: EnvMode.pipe(withDefaults(() => "local" as const satisfies EnvMode)),
......@@ -221,6 +226,30 @@ export function getCustomModelOptionsByProvider(
221226 };
222227}
223228
229export function getProviderStartOptions(
230 settings: Pick<AppSettings, "claudeBinaryPath" | "codexBinaryPath" | "codexHomePath">,
231): ProviderStartOptions | undefined {
232 const providerOptions: ProviderStartOptions = {
233 ...(settings.codexBinaryPath || settings.codexHomePath
234 ? {
235 codex: {
236 ...(settings.codexBinaryPath ? { binaryPath: settings.codexBinaryPath } : {}),
237 ...(settings.codexHomePath ? { homePath: settings.codexHomePath } : {}),
238 },
239 }
240 : {}),
241 ...(settings.claudeBinaryPath
242 ? {
243 claudeAgent: {
244 binaryPath: settings.claudeBinaryPath,
245 },
246 }
247 : {}),
248 };
249
250 return Object.keys(providerOptions).length > 0 ? providerOptions : undefined;
251}
252
224253export function useAppSettings() {
225254 const [settings, setSettings] = useLocalStorage(
226255 APP_SETTINGS_STORAGE_KEY,
apps/web/src/components/ChatView.tsx+2-11
......@@ -122,6 +122,7 @@ import { readNativeApi } from "~/nativeApi";
122122import {
123123 getCustomModelOptionsByProvider,
124124 getCustomModelsByProvider,
125 getProviderStartOptions,
125126 resolveAppModelSelection,
126127 useAppSettings,
127128} from "../appSettings";
......@@ -618,17 +619,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
618619 );
619620 const selectedPromptEffort = composerProviderState.promptEffort;
620621 const selectedModelOptionsForDispatch = composerProviderState.modelOptionsForDispatch;
621 const providerOptionsForDispatch = useMemo(() => {
622 if (!settings.codexBinaryPath && !settings.codexHomePath) {
623 return undefined;
624 }
625 return {
626 codex: {
627 ...(settings.codexBinaryPath ? { binaryPath: settings.codexBinaryPath } : {}),
628 ...(settings.codexHomePath ? { homePath: settings.codexHomePath } : {}),
629 },
630 };
631 }, [settings.codexBinaryPath, settings.codexHomePath]);
622 const providerOptionsForDispatch = useMemo(() => getProviderStartOptions(settings), [settings]);
632623 const selectedModelForPicker = selectedModel;
633624 const modelOptionsByProvider = useMemo(
634625 () => getCustomModelOptionsByProvider(settings),
apps/web/src/components/ui/select.tsx+31-18
......@@ -164,32 +164,45 @@ function SelectPopup({
164164 );
165165}
166166
167function SelectItem({ className, children, ...props }: SelectPrimitive.Item.Props) {
167function SelectItem({
168 className,
169 children,
170 hideIndicator = false,
171 ...props
172}: SelectPrimitive.Item.Props & {
173 hideIndicator?: boolean;
174}) {
168175 return (
169176 <SelectPrimitive.Item
170177 className={cn(
171 "grid min-h-8 in-data-[side=none]:min-w-[calc(var(--anchor-width)+1.25rem)] cursor-default grid-cols-[1rem_1fr] items-center gap-2 rounded-sm py-1 ps-2 pe-4 text-base outline-none data-disabled:pointer-events-none data-highlighted:bg-accent data-highlighted:text-accent-foreground data-disabled:opacity-64 sm:min-h-7 sm:text-sm [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
178 "grid min-h-8 in-data-[side=none]:min-w-[calc(var(--anchor-width)+1.25rem)] cursor-default items-center gap-2 rounded-sm py-1 text-base outline-none data-disabled:pointer-events-none data-highlighted:bg-accent data-highlighted:text-accent-foreground data-disabled:opacity-64 sm:min-h-7 sm:text-sm [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
179 hideIndicator ? "grid-cols-[1fr] ps-3 pe-3" : "grid-cols-[1rem_1fr] ps-2 pe-4",
172180 className,
173181 )}
174182 data-slot="select-item"
175183 {...props}
176184 >
177 <SelectPrimitive.ItemIndicator className="col-start-1">
178 <svg
179 fill="none"
180 height="24"
181 stroke="currentColor"
182 strokeLinecap="round"
183 strokeLinejoin="round"
184 strokeWidth="2"
185 viewBox="0 0 24 24"
186 width="24"
187 xmlns="http://www.w3.org/1500/svg"
188 >
189 <path d="M5.252 12.7 10.2 18.63 18.748 5.37" />
190 </svg>
191 </SelectPrimitive.ItemIndicator>
192 <SelectPrimitive.ItemText className="col-start-2 min-w-0">
185 {hideIndicator ? null : (
186 <SelectPrimitive.ItemIndicator className="col-start-1" data-slot="select-item-indicator">
187 <svg
188 fill="none"
189 height="24"
190 stroke="currentColor"
191 strokeLinecap="round"
192 strokeLinejoin="round"
193 strokeWidth="2"
194 viewBox="0 0 24 24"
195 width="24"
196 xmlns="http://www.w3.org/1500/svg"
197 >
198 <path d="M5.252 12.7 10.2 18.63 18.748 5.37" />
199 </svg>
200 </SelectPrimitive.ItemIndicator>
201 )}
202 <SelectPrimitive.ItemText
203 className={cn("min-w-0", hideIndicator ? "col-start-1" : "col-start-2")}
204 data-slot="select-item-text"
205 >
193206 {children}
194207 </SelectPrimitive.ItemText>
195208 </SelectPrimitive.Item>
apps/web/src/routes/_chat.settings.tsx+739-508
......@@ -1,23 +1,20 @@
11import { createFileRoute } from "@tanstack/react-router";
22import { useQuery } from "@tanstack/react-query";
3import { useCallback, useState } from "react";
3import { ChevronDownIcon, PlusIcon, RotateCcwIcon, Undo2Icon, XIcon } from "lucide-react";
4import { type ReactNode, useCallback, useState } from "react";
45import { type ProviderKind, DEFAULT_GIT_TEXT_GENERATION_MODEL } from "@t3tools/contracts";
56import { getModelOptions, normalizeModelSlug } from "@t3tools/shared/model";
67import {
78 getAppModelOptions,
89 getCustomModelsForProvider,
9 getDefaultCustomModelsForProvider,
1010 MAX_CUSTOM_MODEL_LENGTH,
1111 MODEL_PROVIDER_SETTINGS,
1212 patchCustomModels,
1313 useAppSettings,
1414} from "../appSettings";
15import { resolveAndPersistPreferredEditor } from "../editorPreferences";
16import { isElectron } from "../env";
17import { useTheme } from "../hooks/useTheme";
18import { serverConfigQueryOptions } from "../lib/serverReactQuery";
19import { ensureNativeApi } from "../nativeApi";
15import { APP_VERSION } from "../branding";
2016import { Button } from "../components/ui/button";
17import { Collapsible, CollapsibleContent } from "../components/ui/collapsible";
2118import { Input } from "../components/ui/input";
2219import {
2320 Select,
......@@ -26,9 +23,16 @@ import {
2623 SelectTrigger,
2724 SelectValue,
2825} from "../components/ui/select";
26import { SidebarTrigger } from "../components/ui/sidebar";
2927import { Switch } from "../components/ui/switch";
30import { APP_VERSION } from "../branding";
31import { SidebarInset } from "~/components/ui/sidebar";
28import { SidebarInset } from "../components/ui/sidebar";
29import { Tooltip, TooltipPopup, TooltipTrigger } from "../components/ui/tooltip";
30import { resolveAndPersistPreferredEditor } from "../editorPreferences";
31import { isElectron } from "../env";
32import { useTheme } from "../hooks/useTheme";
33import { serverConfigQueryOptions } from "../lib/serverReactQuery";
34import { cn } from "../lib/utils";
35import { ensureNativeApi, readNativeApi } from "../nativeApi";
3236
3337const THEME_OPTIONS = [
3438 {
......@@ -54,12 +58,145 @@ const TIMESTAMP_FORMAT_LABELS = {
5458 "24-hour": "24-hour",
5559} as const;
5660
61type InstallBinarySettingsKey = "claudeBinaryPath" | "codexBinaryPath";
62type InstallProviderSettings = {
63 provider: ProviderKind;
64 title: string;
65 binaryPathKey: InstallBinarySettingsKey;
66 binaryPlaceholder: string;
67 binaryDescription: ReactNode;
68 homePathKey?: "codexHomePath";
69 homePlaceholder?: string;
70 homeDescription?: ReactNode;
71};
72
73const INSTALL_PROVIDER_SETTINGS: readonly InstallProviderSettings[] = [
74 {
75 provider: "codex",
76 title: "Codex",
77 binaryPathKey: "codexBinaryPath",
78 binaryPlaceholder: "Codex binary path",
79 binaryDescription: (
80 <>
81 Leave blank to use <code>codex</code> from your PATH.
82 </>
83 ),
84 homePathKey: "codexHomePath",
85 homePlaceholder: "CODEX_HOME",
86 homeDescription: "Optional custom Codex home and config directory.",
87 },
88 {
89 provider: "claudeAgent",
90 title: "Claude",
91 binaryPathKey: "claudeBinaryPath",
92 binaryPlaceholder: "Claude binary path",
93 binaryDescription: (
94 <>
95 Leave blank to use <code>claude</code> from your PATH.
96 </>
97 ),
98 },
99];
100
101function SettingsSection({ title, children }: { title: string; children: ReactNode }) {
102 return (
103 <section className="space-y-3">
104 <h2 className="text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
105 {title}
106 </h2>
107 <div className="relative overflow-hidden rounded-2xl border bg-card not-dark:bg-clip-padding text-card-foreground shadow-xs/5 before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-2xl)-1px)] before:shadow-[0_1px_--theme(--color-black/4%)] dark:before:shadow-[0_-1px_--theme(--color-white/6%)]">
108 {children}
109 </div>
110 </section>
111 );
112}
113
114function SettingsRow({
115 title,
116 description,
117 status,
118 resetAction,
119 control,
120 children,
121 onClick,
122}: {
123 title: string;
124 description: string;
125 status?: ReactNode;
126 resetAction?: ReactNode;
127 control?: ReactNode;
128 children?: ReactNode;
129 onClick?: () => void;
130}) {
131 return (
132 <div
133 className="border-t border-border px-4 py-4 first:border-t-0 sm:px-5"
134 data-slot="settings-row"
135 >
136 <div
137 className={cn(
138 "flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between",
139 onClick && "cursor-pointer",
140 )}
141 onClick={onClick}
142 >
143 <div className="min-w-0 flex-1 space-y-1">
144 <div className="flex min-h-5 items-center gap-1.5">
145 <h3 className="text-sm font-medium text-foreground">{title}</h3>
146 <span className="inline-flex h-5 w-5 shrink-0 items-center justify-center">
147 {resetAction}
148 </span>
149 </div>
150 <p className="text-xs text-muted-foreground">{description}</p>
151 {status ? <div className="pt-1 text-[11px] text-muted-foreground">{status}</div> : null}
152 </div>
153 {control ? (
154 <div className="flex w-full shrink-0 items-center gap-2 sm:w-auto sm:justify-end">
155 {control}
156 </div>
157 ) : null}
158 </div>
159 {children}
160 </div>
161 );
162}
163
164function SettingResetButton({ label, onClick }: { label: string; onClick: () => void }) {
165 return (
166 <Tooltip>
167 <TooltipTrigger
168 render={
169 <Button
170 size="icon-xs"
171 variant="ghost"
172 aria-label={`Reset ${label} to default`}
173 className="size-5 rounded-sm p-0 text-muted-foreground hover:text-foreground"
174 onClick={(event) => {
175 event.stopPropagation();
176 onClick();
177 }}
178 >
179 <Undo2Icon className="size-3" />
180 </Button>
181 }
182 />
183 <TooltipPopup side="top">Reset to default</TooltipPopup>
184 </Tooltip>
185 );
186}
187
57188function SettingsRouteView() {
58 const { theme, setTheme, resolvedTheme } = useTheme();
59 const { settings, defaults, updateSettings } = useAppSettings();
189 const { theme, setTheme } = useTheme();
190 const { settings, defaults, updateSettings, resetSettings } = useAppSettings();
60191 const serverConfigQuery = useQuery(serverConfigQueryOptions());
61192 const [isOpeningKeybindings, setIsOpeningKeybindings] = useState(false);
62193 const [openKeybindingsError, setOpenKeybindingsError] = useState<string | null>(null);
194 const [openInstallProviders, setOpenInstallProviders] = useState<Record<ProviderKind, boolean>>({
195 codex: Boolean(settings.codexBinaryPath || settings.codexHomePath),
196 claudeAgent: Boolean(settings.claudeBinaryPath),
197 });
198 const [selectedCustomModelProvider, setSelectedCustomModelProvider] =
199 useState<ProviderKind>("codex");
63200 const [customModelInputByProvider, setCustomModelInputByProvider] = useState<
64201 Record<ProviderKind, string>
65202 >({
......@@ -69,9 +206,11 @@ function SettingsRouteView() {
69206 const [customModelErrorByProvider, setCustomModelErrorByProvider] = useState<
70207 Partial<Record<ProviderKind, string | null>>
71208 >({});
209 const [showAllCustomModels, setShowAllCustomModels] = useState(false);
72210
73211 const codexBinaryPath = settings.codexBinaryPath;
74212 const codexHomePath = settings.codexHomePath;
213 const claudeBinaryPath = settings.claudeBinaryPath;
75214 const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null;
76215 const availableEditors = serverConfigQuery.data?.availableEditors;
77216
......@@ -80,11 +219,52 @@ function SettingsRouteView() {
80219 settings.customCodexModels,
81220 settings.textGenerationModel,
82221 );
222 const currentGitTextGenerationModel =
223 settings.textGenerationModel ?? DEFAULT_GIT_TEXT_GENERATION_MODEL;
224 const defaultGitTextGenerationModel =
225 defaults.textGenerationModel ?? DEFAULT_GIT_TEXT_GENERATION_MODEL;
226 const isGitTextGenerationModelDirty =
227 currentGitTextGenerationModel !== defaultGitTextGenerationModel;
83228 const selectedGitTextGenerationModelLabel =
84 gitTextGenerationModelOptions.find(
85 (option) =>
86 option.slug === (settings.textGenerationModel ?? DEFAULT_GIT_TEXT_GENERATION_MODEL),
87 )?.name ?? settings.textGenerationModel;
229 gitTextGenerationModelOptions.find((option) => option.slug === currentGitTextGenerationModel)
230 ?.name ?? currentGitTextGenerationModel;
231 const selectedCustomModelProviderSettings = MODEL_PROVIDER_SETTINGS.find(
232 (providerSettings) => providerSettings.provider === selectedCustomModelProvider,
233 )!;
234 const selectedCustomModelInput = customModelInputByProvider[selectedCustomModelProvider];
235 const selectedCustomModelError = customModelErrorByProvider[selectedCustomModelProvider] ?? null;
236 const totalCustomModels = settings.customCodexModels.length + settings.customClaudeModels.length;
237 const savedCustomModelRows = MODEL_PROVIDER_SETTINGS.flatMap((providerSettings) =>
238 getCustomModelsForProvider(settings, providerSettings.provider).map((slug) => ({
239 key: `${providerSettings.provider}:${slug}`,
240 provider: providerSettings.provider,
241 providerTitle: providerSettings.title,
242 slug,
243 })),
244 );
245 const visibleCustomModelRows = showAllCustomModels
246 ? savedCustomModelRows
247 : savedCustomModelRows.slice(0, 5);
248 const isInstallSettingsDirty =
249 settings.claudeBinaryPath !== defaults.claudeBinaryPath ||
250 settings.codexBinaryPath !== defaults.codexBinaryPath ||
251 settings.codexHomePath !== defaults.codexHomePath;
252 const changedSettingLabels = [
253 ...(theme !== "system" ? ["Theme"] : []),
254 ...(settings.timestampFormat !== defaults.timestampFormat ? ["Time format"] : []),
255 ...(settings.enableAssistantStreaming !== defaults.enableAssistantStreaming
256 ? ["Assistant output"]
257 : []),
258 ...(settings.defaultThreadEnvMode !== defaults.defaultThreadEnvMode ? ["New thread mode"] : []),
259 ...(settings.confirmThreadDelete !== defaults.confirmThreadDelete
260 ? ["Delete confirmation"]
261 : []),
262 ...(isGitTextGenerationModelDirty ? ["Git writing model"] : []),
263 ...(settings.customCodexModels.length > 0 || settings.customClaudeModels.length > 0
264 ? ["Custom models"]
265 : []),
266 ...(isInstallSettingsDirty ? ["Provider installs"] : []),
267 ];
88268
89269 const openKeybindingsFile = useCallback(() => {
90270 if (!keybindingsConfigPath) return;
......@@ -173,550 +353,601 @@ function SettingsRouteView() {
173353 [settings, updateSettings],
174354 );
175355
356 async function restoreDefaults() {
357 if (changedSettingLabels.length === 0) return;
358
359 const api = readNativeApi();
360 const confirmed = await (api ?? ensureNativeApi()).dialogs.confirm(
361 ["Restore default settings?", `This will reset: ${changedSettingLabels.join(", ")}.`].join(
362 "\n",
363 ),
364 );
365 if (!confirmed) return;
366
367 setTheme("system");
368 resetSettings();
369 setOpenInstallProviders({
370 codex: false,
371 claudeAgent: false,
372 });
373 setSelectedCustomModelProvider("codex");
374 setCustomModelInputByProvider({
375 codex: "",
376 claudeAgent: "",
377 });
378 setCustomModelErrorByProvider({});
379 }
380
176381 return (
177382 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground isolate">
178383 <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-foreground">
384 {!isElectron && (
385 <header className="border-b border-border px-3 py-2 sm:px-5">
386 <div className="flex items-center gap-2">
387 <SidebarTrigger className="size-7 shrink-0 md:hidden" />
388 <span className="text-sm font-medium text-foreground">Settings</span>
389 <div className="ms-auto flex items-center gap-2">
390 <Button
391 size="xs"
392 variant="outline"
393 disabled={changedSettingLabels.length === 0}
394 onClick={() => void restoreDefaults()}
395 >
396 <RotateCcwIcon className="size-3.5" />
397 Restore defaults
398 </Button>
399 </div>
400 </div>
401 </header>
402 )}
403
179404 {isElectron && (
180405 <div className="drag-region flex h-[52px] shrink-0 items-center border-b border-border px-5">
181406 <span className="text-xs font-medium tracking-wide text-muted-foreground/70">
182407 Settings
183408 </span>
409 <div className="ms-auto flex items-center gap-2">
410 <Button
411 size="xs"
412 variant="outline"
413 disabled={changedSettingLabels.length === 0}
414 onClick={() => void restoreDefaults()}
415 >
416 <RotateCcwIcon className="size-3.5" />
417 Restore defaults
418 </Button>
419 </div>
184420 </div>
185421 )}
186422
187423 <div className="flex-1 overflow-y-auto p-6">
188 <div className="mx-auto flex w-full max-w-3xl flex-col gap-6">
189 <header className="space-y-1">
190 <h1 className="text-2xl font-semibold tracking-tight text-foreground">Settings</h1>
191 <p className="text-sm text-muted-foreground">
192 Configure app-level preferences for this device.
193 </p>
194 </header>
195
196 <section className="rounded-2xl border border-border bg-card p-5">
197 <div className="mb-4">
198 <h2 className="text-sm font-medium text-foreground">Appearance</h2>
199 <p className="mt-1 text-xs text-muted-foreground">
200 Choose how T3 Code looks across the app.
201 </p>
202 </div>
203
204 <div className="space-y-4">
205 <div className="space-y-2" role="radiogroup" aria-label="Theme preference">
206 {THEME_OPTIONS.map((option) => {
207 const selected = theme === option.value;
208 return (
209 <button
210 key={option.value}
211 type="button"
212 role="radio"
213 aria-checked={selected}
214 className={`flex w-full items-start justify-between rounded-lg border px-3 py-2 text-left transition-colors ${
215 selected
216 ? "border-primary/60 bg-primary/8 text-foreground"
217 : "border-border bg-background text-muted-foreground hover:bg-accent"
218 }`}
219 onClick={() => setTheme(option.value)}
220 >
221 <span className="flex flex-col">
222 <span className="text-sm font-medium">{option.label}</span>
223 <span className="text-xs">{option.description}</span>
224 </span>
225 {selected ? (
226 <span className="rounded bg-primary/14 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-primary">
227 Selected
228 </span>
229 ) : null}
230 </button>
231 );
232 })}
233 </div>
234
235 <p className="text-xs text-muted-foreground">
236 Active theme: <span className="font-medium text-foreground">{resolvedTheme}</span>
237 </p>
238
239 <div className="flex items-center justify-between rounded-lg border border-border bg-background px-3 py-2">
240 <div>
241 <p className="text-sm font-medium text-foreground">Timestamp format</p>
242 <p className="text-xs text-muted-foreground">
243 System default follows your browser or OS time format. <code>12-hour</code>{" "}
244 and <code>24-hour</code> force the hour cycle.
245 </p>
246 </div>
424 <div className="mx-auto flex w-full max-w-2xl flex-col gap-6">
425 <SettingsSection title="General">
426 <SettingsRow
427 title="Theme"
428 description="Choose how T3 Code looks across the app."
429 resetAction={
430 theme !== "system" ? (
431 <SettingResetButton label="theme" onClick={() => setTheme("system")} />
432 ) : null
433 }
434 control={
435 <Select
436 value={theme}
437 onValueChange={(value) => {
438 if (value !== "system" && value !== "light" && value !== "dark") return;
439 setTheme(value);
440 }}
441 >
442 <SelectTrigger className="w-full sm:w-40" aria-label="Theme preference">
443 <SelectValue>
444 {THEME_OPTIONS.find((option) => option.value === theme)?.label ?? "System"}
445 </SelectValue>
446 </SelectTrigger>
447 <SelectPopup align="end" alignItemWithTrigger={false}>
448 {THEME_OPTIONS.map((option) => (
449 <SelectItem hideIndicator key={option.value} value={option.value}>
450 {option.label}
451 </SelectItem>
452 ))}
453 </SelectPopup>
454 </Select>
455 }
456 />
457
458 <SettingsRow
459 title="Time format"
460 description="System default follows your browser or OS clock preference."
461 resetAction={
462 settings.timestampFormat !== defaults.timestampFormat ? (
463 <SettingResetButton
464 label="time format"
465 onClick={() =>
466 updateSettings({
467 timestampFormat: defaults.timestampFormat,
468 })
469 }
470 />
471 ) : null
472 }
473 control={
247474 <Select
248475 value={settings.timestampFormat}
249476 onValueChange={(value) => {
250 if (value !== "locale" && value !== "12-hour" && value !== "24-hour") return;
477 if (value !== "locale" && value !== "12-hour" && value !== "24-hour") {
478 return;
479 }
251480 updateSettings({
252481 timestampFormat: value,
253482 });
254483 }}
255484 >
256 <SelectTrigger className="w-40" aria-label="Timestamp format">
485 <SelectTrigger className="w-full sm:w-40" aria-label="Timestamp format">
257486 <SelectValue>{TIMESTAMP_FORMAT_LABELS[settings.timestampFormat]}</SelectValue>
258487 </SelectTrigger>
259 <SelectPopup align="end">
260 <SelectItem value="locale">{TIMESTAMP_FORMAT_LABELS.locale}</SelectItem>
261 <SelectItem value="12-hour">{TIMESTAMP_FORMAT_LABELS["12-hour"]}</SelectItem>
262 <SelectItem value="24-hour">{TIMESTAMP_FORMAT_LABELS["24-hour"]}</SelectItem>
488 <SelectPopup align="end" alignItemWithTrigger={false}>
489 <SelectItem hideIndicator value="locale">
490 {TIMESTAMP_FORMAT_LABELS.locale}
491 </SelectItem>
492 <SelectItem hideIndicator value="12-hour">
493 {TIMESTAMP_FORMAT_LABELS["12-hour"]}
494 </SelectItem>
495 <SelectItem hideIndicator value="24-hour">
496 {TIMESTAMP_FORMAT_LABELS["24-hour"]}
497 </SelectItem>
263498 </SelectPopup>
264499 </Select>
265 </div>
266
267 {settings.timestampFormat !== defaults.timestampFormat ? (
268 <div className="flex justify-end">
269 <Button
270 size="xs"
271 variant="outline"
500 }
501 />
502
503 <SettingsRow
504 title="Assistant output"
505 description="Show token-by-token output while a response is in progress."
506 resetAction={
507 settings.enableAssistantStreaming !== defaults.enableAssistantStreaming ? (
508 <SettingResetButton
509 label="assistant output"
272510 onClick={() =>
273511 updateSettings({
274 timestampFormat: defaults.timestampFormat,
512 enableAssistantStreaming: defaults.enableAssistantStreaming,
275513 })
276514 }
277 >
278 Restore default
279 </Button>
280 </div>
281 ) : null}
282 </div>
283 </section>
284
285 <section className="rounded-2xl border border-border bg-card p-5">
286 <div className="mb-4">
287 <h2 className="text-sm font-medium text-foreground">Codex App Server</h2>
288 <p className="mt-1 text-xs text-muted-foreground">
289 These overrides apply to new sessions and let you use a non-default Codex install.
290 </p>
291 </div>
292
293 <div className="space-y-4">
294 <label htmlFor="codex-binary-path" className="block space-y-1">
295 <span className="text-xs font-medium text-foreground">Codex binary path</span>
296 <Input
297 id="codex-binary-path"
298 value={codexBinaryPath}
299 onChange={(event) => updateSettings({ codexBinaryPath: event.target.value })}
300 placeholder="codex"
301 spellCheck={false}
302 />
303 <span className="text-xs text-muted-foreground">
304 Leave blank to use <code>codex</code> from your PATH.
305 </span>
306 </label>
307
308 <label htmlFor="codex-home-path" className="block space-y-1">
309 <span className="text-xs font-medium text-foreground">CODEX_HOME path</span>
310 <Input
311 id="codex-home-path"
312 value={codexHomePath}
313 onChange={(event) => updateSettings({ codexHomePath: event.target.value })}
314 placeholder="/Users/you/.codex"
315 spellCheck={false}
316 />
317 <span className="text-xs text-muted-foreground">
318 Optional custom Codex home/config directory.
319 </span>
320 </label>
321
322 <div className="flex flex-col gap-3 text-xs text-muted-foreground sm:flex-row sm:items-start sm:justify-between">
323 <div className="min-w-0 flex-1">
324 <p>Binary source</p>
325 <p className="mt-1 break-all font-mono text-[11px] text-foreground">
326 {codexBinaryPath || "PATH"}
327 </p>
328 </div>
329 <Button
330 size="xs"
331 variant="outline"
332 className="self-start"
333 onClick={() =>
515 />
516 ) : null
517 }
518 control={
519 <Switch
520 checked={settings.enableAssistantStreaming}
521 onCheckedChange={(checked) =>
334522 updateSettings({
335 codexBinaryPath: defaults.codexBinaryPath,
336 codexHomePath: defaults.codexHomePath,
523 enableAssistantStreaming: Boolean(checked),
337524 })
338525 }
339 >
340 Reset codex overrides
341 </Button>
342 </div>
343 </div>
344 </section>
345
346 <section className="rounded-2xl border border-border bg-card p-5">
347 <div className="mb-4">
348 <h2 className="text-sm font-medium text-foreground">Models</h2>
349 <p className="mt-1 text-xs text-muted-foreground">
350 Save additional provider model slugs so they appear in the chat model picker and
351 `/model` command suggestions.
352 </p>
353 </div>
354
355 <div className="space-y-5">
356 {MODEL_PROVIDER_SETTINGS.map((providerSettings) => {
357 const provider = providerSettings.provider;
358 const customModels = getCustomModelsForProvider(settings, provider);
359 const customModelInput = customModelInputByProvider[provider];
360 const customModelError = customModelErrorByProvider[provider] ?? null;
361 return (
362 <div
363 key={provider}
364 className="rounded-xl border border-border bg-background/50 p-4"
365 >
366 <div className="mb-4">
367 <h3 className="text-sm font-medium text-foreground">
368 {providerSettings.title}
369 </h3>
370 <p className="mt-1 text-xs text-muted-foreground">
371 {providerSettings.description}
372 </p>
373 </div>
374
375 <div className="space-y-4">
376 <div className="flex flex-col gap-3 sm:flex-row sm:items-start">
377 <label
378 htmlFor={`custom-model-slug-${provider}`}
379 className="block flex-1 space-y-1"
380 >
381 <span className="text-xs font-medium text-foreground">
382 Custom model slug
383 </span>
384 <Input
385 id={`custom-model-slug-${provider}`}
386 value={customModelInput}
387 onChange={(event) => {
388 const value = event.target.value;
389 setCustomModelInputByProvider((existing) => ({
390 ...existing,
391 [provider]: value,
392 }));
393 if (customModelError) {
394 setCustomModelErrorByProvider((existing) => ({
395 ...existing,
396 [provider]: null,
397 }));
398 }
399 }}
400 onKeyDown={(event) => {
401 if (event.key !== "Enter") return;
402 event.preventDefault();
403 addCustomModel(provider);
404 }}
405 placeholder={providerSettings.placeholder}
406 spellCheck={false}
407 />
408 <span className="text-xs text-muted-foreground">
409 Example: <code>{providerSettings.example}</code>
410 </span>
411 </label>
412
413 <Button
414 className="sm:mt-6"
415 type="button"
416 onClick={() => addCustomModel(provider)}
417 >
418 Add model
419 </Button>
420 </div>
421
422 {customModelError ? (
423 <p className="text-xs text-destructive">{customModelError}</p>
424 ) : null}
425
426 <div className="space-y-2">
427 <div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
428 <p>Saved custom models: {customModels.length}</p>
429 {customModels.length > 0 ? (
430 <Button
431 size="xs"
432 variant="outline"
433 onClick={() =>
434 updateSettings(
435 patchCustomModels(provider, [
436 ...getDefaultCustomModelsForProvider(defaults, provider),
437 ]),
438 )
439 }
440 >
441 Reset custom models
442 </Button>
443 ) : null}
444 </div>
445
446 {customModels.length > 0 ? (
447 <div className="space-y-2">
448 {customModels.map((slug) => (
449 <div
450 key={`${provider}:${slug}`}
451 className="flex items-center justify-between gap-3 rounded-lg border border-border bg-background px-3 py-2"
452 >
453 <code className="min-w-0 flex-1 truncate text-xs text-foreground">
454 {slug}
455 </code>
456 <Button
457 size="xs"
458 variant="ghost"
459 onClick={() => removeCustomModel(provider, slug)}
460 >
461 Remove
462 </Button>
463 </div>
464 ))}
465 </div>
466 ) : (
467 <div className="rounded-lg border border-dashed border-border bg-background px-3 py-4 text-xs text-muted-foreground">
468 No custom models saved yet.
469 </div>
470 )}
471 </div>
472 </div>
473 </div>
474 );
475 })}
476 </div>
477 </section>
478
479 <section className="rounded-2xl border border-border bg-card p-5">
480 <div className="mb-4">
481 <h2 className="text-sm font-medium text-foreground">Git</h2>
482 <p className="mt-1 text-xs text-muted-foreground">
483 Configure the model used for generating commit messages, PR titles, and branch
484 names.
485 </p>
486 </div>
487
488 <div className="flex flex-col gap-4 rounded-lg border border-border bg-background px-3 py-3 sm:flex-row sm:items-center sm:justify-between">
489 <div className="min-w-0 flex-1">
490 <p className="text-sm font-medium text-foreground">Text generation model</p>
491 <p className="text-xs text-muted-foreground">
492 Model used for auto-generated git content.
493 </p>
494 </div>
495 <Select
496 value={settings.textGenerationModel ?? DEFAULT_GIT_TEXT_GENERATION_MODEL}
497 onValueChange={(value) => {
498 if (value) {
526 aria-label="Stream assistant messages"
527 />
528 }
529 />
530
531 <SettingsRow
532 title="New threads"
533 description="Pick the default workspace mode for newly created draft threads."
534 resetAction={
535 settings.defaultThreadEnvMode !== defaults.defaultThreadEnvMode ? (
536 <SettingResetButton
537 label="new threads"
538 onClick={() =>
539 updateSettings({
540 defaultThreadEnvMode: defaults.defaultThreadEnvMode,
541 })
542 }
543 />
544 ) : null
545 }
546 control={
547 <Select
548 value={settings.defaultThreadEnvMode}
549 onValueChange={(value) => {
550 if (value !== "local" && value !== "worktree") return;
499551 updateSettings({
500 textGenerationModel: value,
552 defaultThreadEnvMode: value,
501553 });
502 }
503 }}
504 >
505 <SelectTrigger
506 className="w-full shrink-0 sm:w-48"
507 aria-label="Git text generation model"
554 }}
508555 >
509 <SelectValue>{selectedGitTextGenerationModelLabel}</SelectValue>
510 </SelectTrigger>
511 <SelectPopup align="end">
512 {gitTextGenerationModelOptions.map((option) => (
513 <SelectItem key={option.slug} value={option.slug}>
514 {option.name}
556 <SelectTrigger className="w-full sm:w-44" aria-label="Default thread mode">
557 <SelectValue>
558 {settings.defaultThreadEnvMode === "worktree" ? "New worktree" : "Local"}
559 </SelectValue>
560 </SelectTrigger>
561 <SelectPopup align="end" alignItemWithTrigger={false}>
562 <SelectItem hideIndicator value="local">
563 Local
515564 </SelectItem>
516 ))}
517 </SelectPopup>
518 </Select>
519 </div>
520
521 {settings.textGenerationModel !== defaults.textGenerationModel ? (
522 <div className="mt-3 flex justify-end">
523 <Button
524 size="xs"
525 variant="outline"
526 onClick={() =>
565 <SelectItem hideIndicator value="worktree">
566 New worktree
567 </SelectItem>
568 </SelectPopup>
569 </Select>
570 }
571 />
572
573 <SettingsRow
574 title="Delete confirmation"
575 description="Ask before deleting a thread and its chat history."
576 resetAction={
577 settings.confirmThreadDelete !== defaults.confirmThreadDelete ? (
578 <SettingResetButton
579 label="delete confirmation"
580 onClick={() =>
581 updateSettings({
582 confirmThreadDelete: defaults.confirmThreadDelete,
583 })
584 }
585 />
586 ) : null
587 }
588 control={
589 <Switch
590 checked={settings.confirmThreadDelete}
591 onCheckedChange={(checked) =>
527592 updateSettings({
528 textGenerationModel: defaults.textGenerationModel,
593 confirmThreadDelete: Boolean(checked),
529594 })
530595 }
531 >
532 Restore default
533 </Button>
534 </div>
535 ) : null}
536 </section>
537
538 <section className="rounded-2xl border border-border bg-card p-5">
539 <div className="mb-4">
540 <h2 className="text-sm font-medium text-foreground">Threads</h2>
541 <p className="mt-1 text-xs text-muted-foreground">
542 Choose the default workspace mode for newly created draft threads.
543 </p>
544 </div>
545
546 <div className="flex items-center justify-between rounded-lg border border-border bg-background px-3 py-2">
547 <div>
548 <p className="text-sm font-medium text-foreground">Default to New worktree</p>
549 <p className="text-xs text-muted-foreground">
550 New threads start in New worktree mode instead of Local.
551 </p>
552 </div>
553 <Switch
554 checked={settings.defaultThreadEnvMode === "worktree"}
555 onCheckedChange={(checked) =>
556 updateSettings({
557 defaultThreadEnvMode: checked ? "worktree" : "local",
558 })
559 }
560 aria-label="Default new threads to New worktree mode"
561 />
562 </div>
563
564 {settings.defaultThreadEnvMode !== defaults.defaultThreadEnvMode ? (
565 <div className="mt-3 flex justify-end">
566 <Button
567 size="xs"
568 variant="outline"
569 onClick={() =>
596 aria-label="Confirm thread deletion"
597 />
598 }
599 />
600 </SettingsSection>
601
602 <SettingsSection title="Models">
603 <SettingsRow
604 title="Git writing model"
605 description="Used for generated commit messages, PR titles, and branch names."
606 resetAction={
607 isGitTextGenerationModelDirty ? (
608 <SettingResetButton
609 label="git writing model"
610 onClick={() =>
611 updateSettings({
612 textGenerationModel: defaults.textGenerationModel,
613 })
614 }
615 />
616 ) : null
617 }
618 control={
619 <Select
620 value={currentGitTextGenerationModel}
621 onValueChange={(value) => {
622 if (!value) return;
570623 updateSettings({
571 defaultThreadEnvMode: defaults.defaultThreadEnvMode,
572 })
573 }
624 textGenerationModel: value,
625 });
626 }}
574627 >
575 Restore default
576 </Button>
577 </div>
578 ) : null}
579 </section>
580
581 <section className="rounded-2xl border border-border bg-card p-5">
582 <div className="mb-4">
583 <h2 className="text-sm font-medium text-foreground">Responses</h2>
584 <p className="mt-1 text-xs text-muted-foreground">
585 Control how assistant output is rendered during a turn.
586 </p>
587 </div>
628 <SelectTrigger
629 className="w-full sm:w-52"
630 aria-label="Git text generation model"
631 >
632 <SelectValue>{selectedGitTextGenerationModelLabel}</SelectValue>
633 </SelectTrigger>
634 <SelectPopup align="end" alignItemWithTrigger={false}>
635 {gitTextGenerationModelOptions.map((option) => (
636 <SelectItem hideIndicator key={option.slug} value={option.slug}>
637 {option.name}
638 </SelectItem>
639 ))}
640 </SelectPopup>
641 </Select>
642 }
643 />
644
645 <SettingsRow
646 title="Custom models"
647 description="Add custom model slugs for supported providers."
648 resetAction={
649 totalCustomModels > 0 ? (
650 <SettingResetButton
651 label="custom models"
652 onClick={() => {
653 updateSettings({
654 customCodexModels: defaults.customCodexModels,
655 customClaudeModels: defaults.customClaudeModels,
656 });
657 setCustomModelErrorByProvider({});
658 setShowAllCustomModels(false);
659 }}
660 />
661 ) : null
662 }
663 >
664 <div className="mt-4 border-t border-border pt-4">
665 <div className="flex flex-col gap-2 sm:flex-row sm:items-center">
666 <Select
667 value={selectedCustomModelProvider}
668 onValueChange={(value) => {
669 if (value !== "codex" && value !== "claudeAgent") {
670 return;
671 }
672 setSelectedCustomModelProvider(value);
673 }}
674 >
675 <SelectTrigger
676 size="sm"
677 className="w-full sm:w-40"
678 aria-label="Custom model provider"
679 >
680 <SelectValue>{selectedCustomModelProviderSettings.title}</SelectValue>
681 </SelectTrigger>
682 <SelectPopup align="start" alignItemWithTrigger={false}>
683 {MODEL_PROVIDER_SETTINGS.map((providerSettings) => (
684 <SelectItem
685 hideIndicator
686 className="min-h-7 text-sm"
687 key={providerSettings.provider}
688 value={providerSettings.provider}
689 >
690 {providerSettings.title}
691 </SelectItem>
692 ))}
693 </SelectPopup>
694 </Select>
695 <Input
696 id="custom-model-slug"
697 value={selectedCustomModelInput}
698 onChange={(event) => {
699 const value = event.target.value;
700 setCustomModelInputByProvider((existing) => ({
701 ...existing,
702 [selectedCustomModelProvider]: value,
703 }));
704 if (selectedCustomModelError) {
705 setCustomModelErrorByProvider((existing) => ({
706 ...existing,
707 [selectedCustomModelProvider]: null,
708 }));
709 }
710 }}
711 onKeyDown={(event) => {
712 if (event.key !== "Enter") return;
713 event.preventDefault();
714 addCustomModel(selectedCustomModelProvider);
715 }}
716 placeholder={selectedCustomModelProviderSettings.example}
717 spellCheck={false}
718 />
719 <Button
720 className="shrink-0"
721 variant="outline"
722 onClick={() => addCustomModel(selectedCustomModelProvider)}
723 >
724 <PlusIcon className="size-3.5" />
725 Add
726 </Button>
727 </div>
588728
589 <div className="flex items-center justify-between rounded-lg border border-border bg-background px-3 py-2">
590 <div>
591 <p className="text-sm font-medium text-foreground">Stream assistant messages</p>
592 <p className="text-xs text-muted-foreground">
593 Show token-by-token output while a response is in progress.
594 </p>
595 </div>
596 <Switch
597 checked={settings.enableAssistantStreaming}
598 onCheckedChange={(checked) =>
599 updateSettings({
600 enableAssistantStreaming: Boolean(checked),
601 })
602 }
603 aria-label="Stream assistant messages"
604 />
605 </div>
729 {selectedCustomModelError ? (
730 <p className="mt-2 text-xs text-destructive">{selectedCustomModelError}</p>
731 ) : null}
732
733 {totalCustomModels > 0 ? (
734 <div className="mt-3">
735 <div>
736 {visibleCustomModelRows.map((row) => (
737 <div
738 key={row.key}
739 className="group grid grid-cols-[minmax(5rem,6rem)_minmax(0,1fr)_auto] items-center gap-3 border-t border-border/60 px-4 py-2 first:border-t-0"
740 >
741 <span className="truncate text-xs text-muted-foreground">
742 {row.providerTitle}
743 </span>
744 <code className="min-w-0 truncate text-sm text-foreground">
745 {row.slug}
746 </code>
747 <button
748 type="button"
749 className="shrink-0 opacity-0 transition-opacity group-hover:opacity-100 hover:opacity-100"
750 aria-label={`Remove ${row.slug}`}
751 onClick={() => removeCustomModel(row.provider, row.slug)}
752 >
753 <XIcon className="size-3.5 text-muted-foreground hover:text-foreground" />
754 </button>
755 </div>
756 ))}
757 </div>
606758
607 {settings.enableAssistantStreaming !== defaults.enableAssistantStreaming ? (
608 <div className="mt-3 flex justify-end">
609 <Button
610 size="xs"
611 variant="outline"
612 onClick={() =>
613 updateSettings({
614 enableAssistantStreaming: defaults.enableAssistantStreaming,
615 })
616 }
617 >
618 Restore default
619 </Button>
759 {savedCustomModelRows.length > 5 ? (
760 <button
761 type="button"
762 className="mt-2 text-xs text-muted-foreground transition-colors hover:text-foreground"
763 onClick={() => setShowAllCustomModels((value) => !value)}
764 >
765 {showAllCustomModels
766 ? "Show less"
767 : `Show more (${savedCustomModelRows.length - 5})`}
768 </button>
769 ) : null}
770 </div>
771 ) : null}
620772 </div>
621 ) : null}
622 </section>
623
624 <section className="rounded-2xl border border-border bg-card p-5">
625 <div className="mb-4">
626 <h2 className="text-sm font-medium text-foreground">Keybindings</h2>
627 <p className="mt-1 text-xs text-muted-foreground">
628 Open the persisted <code>keybindings.json</code> file to edit advanced bindings
629 directly.
630 </p>
631 </div>
632
633 <div className="space-y-3">
634 <div className="flex items-center justify-between gap-3 rounded-lg border border-border bg-background px-3 py-2">
635 <div className="min-w-0 flex-1">
636 <p className="text-xs font-medium text-foreground">Config file path</p>
637 <p className="mt-1 break-all font-mono text-[11px] text-muted-foreground">
638 {keybindingsConfigPath ?? "Resolving keybindings path..."}
639 </p>
773 </SettingsRow>
774 </SettingsSection>
775
776 <SettingsSection title="Advanced">
777 <SettingsRow
778 title="Provider installs"
779 description="Override the CLI used for new sessions."
780 resetAction={
781 isInstallSettingsDirty ? (
782 <SettingResetButton
783 label="provider installs"
784 onClick={() => {
785 updateSettings({
786 claudeBinaryPath: defaults.claudeBinaryPath,
787 codexBinaryPath: defaults.codexBinaryPath,
788 codexHomePath: defaults.codexHomePath,
789 });
790 setOpenInstallProviders({
791 codex: false,
792 claudeAgent: false,
793 });
794 }}
795 />
796 ) : null
797 }
798 >
799 <div className="mt-4">
800 <div className="space-y-2">
801 {INSTALL_PROVIDER_SETTINGS.map((providerSettings) => {
802 const isOpen = openInstallProviders[providerSettings.provider];
803 const isDirty =
804 providerSettings.provider === "codex"
805 ? settings.codexBinaryPath !== defaults.codexBinaryPath ||
806 settings.codexHomePath !== defaults.codexHomePath
807 : settings.claudeBinaryPath !== defaults.claudeBinaryPath;
808 const binaryPathValue =
809 providerSettings.binaryPathKey === "claudeBinaryPath"
810 ? claudeBinaryPath
811 : codexBinaryPath;
812
813 return (
814 <Collapsible
815 key={providerSettings.provider}
816 open={isOpen}
817 onOpenChange={(open) =>
818 setOpenInstallProviders((existing) => ({
819 ...existing,
820 [providerSettings.provider]: open,
821 }))
822 }
823 >
824 <div className="overflow-hidden rounded-xl border border-border/70">
825 <button
826 type="button"
827 className="flex w-full items-center gap-3 px-4 py-3 text-left"
828 onClick={() =>
829 setOpenInstallProviders((existing) => ({
830 ...existing,
831 [providerSettings.provider]: !existing[providerSettings.provider],
832 }))
833 }
834 >
835 <span className="min-w-0 flex-1 text-sm font-medium text-foreground">
836 {providerSettings.title}
837 </span>
838 {isDirty ? (
839 <span className="text-[11px] text-muted-foreground">Custom</span>
840 ) : null}
841 <ChevronDownIcon
842 className={cn(
843 "size-4 shrink-0 text-muted-foreground transition-transform",
844 isOpen && "rotate-180",
845 )}
846 />
847 </button>
848
849 <CollapsibleContent>
850 <div className="border-t border-border/70 px-4 py-4">
851 <div className="space-y-3">
852 <label
853 htmlFor={`provider-install-${providerSettings.binaryPathKey}`}
854 className="block"
855 >
856 <span className="block text-xs font-medium text-foreground">
857 {providerSettings.title} binary path
858 </span>
859 <Input
860 id={`provider-install-${providerSettings.binaryPathKey}`}
861 className="mt-1"
862 value={binaryPathValue}
863 onChange={(event) =>
864 updateSettings(
865 providerSettings.binaryPathKey === "claudeBinaryPath"
866 ? { claudeBinaryPath: event.target.value }
867 : { codexBinaryPath: event.target.value },
868 )
869 }
870 placeholder={providerSettings.binaryPlaceholder}
871 spellCheck={false}
872 />
873 <span className="mt-1 block text-xs text-muted-foreground">
874 {providerSettings.binaryDescription}
875 </span>
876 </label>
877
878 {providerSettings.homePathKey ? (
879 <label
880 htmlFor={`provider-install-${providerSettings.homePathKey}`}
881 className="block"
882 >
883 <span className="block text-xs font-medium text-foreground">
884 CODEX_HOME path
885 </span>
886 <Input
887 id={`provider-install-${providerSettings.homePathKey}`}
888 className="mt-1"
889 value={codexHomePath}
890 onChange={(event) =>
891 updateSettings({
892 codexHomePath: event.target.value,
893 })
894 }
895 placeholder={providerSettings.homePlaceholder}
896 spellCheck={false}
897 />
898 {providerSettings.homeDescription ? (
899 <span className="mt-1 block text-xs text-muted-foreground">
900 {providerSettings.homeDescription}
901 </span>
902 ) : null}
903 </label>
904 ) : null}
905 </div>
906 </div>
907 </CollapsibleContent>
908 </div>
909 </Collapsible>
910 );
911 })}
640912 </div>
913 </div>
914 </SettingsRow>
915
916 <SettingsRow
917 title="Keybindings"
918 description="Open the persisted `keybindings.json` file to edit advanced bindings directly."
919 status={
920 <>
921 <span className="block break-all font-mono text-[11px] text-foreground">
922 {keybindingsConfigPath ?? "Resolving keybindings path..."}
923 </span>
924 {openKeybindingsError ? (
925 <span className="mt-1 block text-destructive">{openKeybindingsError}</span>
926 ) : (
927 <span className="mt-1 block">Opens in your preferred editor.</span>
928 )}
929 </>
930 }
931 control={
641932 <Button
642933 size="xs"
643934 variant="outline"
644935 disabled={!keybindingsConfigPath || isOpeningKeybindings}
645936 onClick={openKeybindingsFile}
646937 >
647 {isOpeningKeybindings ? "Opening..." : "Open keybindings.json"}
648 </Button>
649 </div>
650
651 <p className="text-xs text-muted-foreground">
652 Opens in your preferred editor selection.
653 </p>
654 {openKeybindingsError ? (
655 <p className="text-xs text-destructive">{openKeybindingsError}</p>
656 ) : null}
657 </div>
658 </section>
659
660 <section className="rounded-2xl border border-border bg-card p-5">
661 <div className="mb-4">
662 <h2 className="text-sm font-medium text-foreground">Safety</h2>
663 <p className="mt-1 text-xs text-muted-foreground">
664 Additional guardrails for destructive local actions.
665 </p>
666 </div>
667
668 <div className="flex items-center justify-between rounded-lg border border-border bg-background px-3 py-2">
669 <div>
670 <p className="text-sm font-medium text-foreground">Confirm thread deletion</p>
671 <p className="text-xs text-muted-foreground">
672 Ask for confirmation before deleting a thread and its chat history.
673 </p>
674 </div>
675 <Switch
676 checked={settings.confirmThreadDelete}
677 onCheckedChange={(checked) =>
678 updateSettings({
679 confirmThreadDelete: Boolean(checked),
680 })
681 }
682 aria-label="Confirm thread deletion"
683 />
684 </div>
685
686 {settings.confirmThreadDelete !== defaults.confirmThreadDelete ? (
687 <div className="mt-3 flex justify-end">
688 <Button
689 size="xs"
690 variant="outline"
691 onClick={() =>
692 updateSettings({
693 confirmThreadDelete: defaults.confirmThreadDelete,
694 })
695 }
696 >
697 Restore default
938 {isOpeningKeybindings ? "Opening..." : "Open file"}
698939 </Button>
699 </div>
700 ) : null}
701 </section>
702 <section className="rounded-2xl border border-border bg-card p-5">
703 <div className="mb-4">
704 <h2 className="text-sm font-medium text-foreground">About</h2>
705 <p className="mt-1 text-xs text-muted-foreground">
706 Application version and environment information.
707 </p>
708 </div>
709
710 <div className="flex items-center justify-between rounded-lg border border-border bg-background px-3 py-2">
711 <div>
712 <p className="text-sm font-medium text-foreground">Version</p>
713 <p className="text-xs text-muted-foreground">
714 Current version of the application.
715 </p>
716 </div>
717 <code className="text-xs font-medium text-muted-foreground">{APP_VERSION}</code>
718 </div>
719 </section>
940 }
941 />
942
943 <SettingsRow
944 title="Version"
945 description="Current application version."
946 control={
947 <code className="text-xs font-medium text-muted-foreground">{APP_VERSION}</code>
948 }
949 />
950 </SettingsSection>
720951 </div>
721952 </div>
722953 </div>