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 {...@@ -9,6 +9,7 @@ import {
9 getCustomModelsByProvider,9 getCustomModelsByProvider,
10 getCustomModelsForProvider,10 getCustomModelsForProvider,
11 getDefaultCustomModelsForProvider,11 getDefaultCustomModelsForProvider,
12 getProviderStartOptions,
12 MODEL_PROVIDER_SETTINGS,13 MODEL_PROVIDER_SETTINGS,
13 normalizeCustomModelSlugs,14 normalizeCustomModelSlugs,
14 patchCustomModels,15 patchCustomModels,
...@@ -118,6 +119,35 @@ describe("provider-specific custom models", () => {...@@ -118,6 +119,35 @@ describe("provider-specific custom models", () => {
118 });119 });
119});120});
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
121describe("provider-indexed custom model settings", () => {151describe("provider-indexed custom model settings", () => {
122 const settings = {152 const settings = {
123 customCodexModels: ["custom/codex-model"],153 customCodexModels: ["custom/codex-model"],
...@@ -209,6 +239,7 @@ describe("AppSettingsSchema", () => {...@@ -209,6 +239,7 @@ describe("AppSettingsSchema", () => {
209 }),239 }),
210 ),240 ),
211 ).toMatchObject({241 ).toMatchObject({
242 claudeBinaryPath: "",
212 codexBinaryPath: "/usr/local/bin/codex",243 codexBinaryPath: "/usr/local/bin/codex",
213 codexHomePath: "",244 codexHomePath: "",
214 defaultThreadEnvMode: "local",245 defaultThreadEnvMode: "local",
apps/web/src/appSettings.ts+30-1
...@@ -1,6 +1,10 @@...@@ -1,6 +1,10 @@
1import { useCallback } from "react";1import { useCallback } from "react";
2import { Option, Schema } from "effect";2import { Option, Schema } from "effect";
3import { TrimmedNonEmptyString, type ProviderKind } from "@t3tools/contracts";3import {
4 TrimmedNonEmptyString,
5 type ProviderKind,
6 type ProviderStartOptions,
7} from "@t3tools/contracts";
4import {8import {
5 getDefaultModel,9 getDefaultModel,
6 getModelOptions,10 getModelOptions,
...@@ -47,6 +51,7 @@ const withDefaults =...@@ -47,6 +51,7 @@ const withDefaults =
47 );51 );
4852
49export const AppSettingsSchema = Schema.Struct({53export const AppSettingsSchema = Schema.Struct({
54 claudeBinaryPath: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")),
50 codexBinaryPath: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")),55 codexBinaryPath: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")),
51 codexHomePath: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")),56 codexHomePath: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")),
52 defaultThreadEnvMode: EnvMode.pipe(withDefaults(() => "local" as const satisfies EnvMode)),57 defaultThreadEnvMode: EnvMode.pipe(withDefaults(() => "local" as const satisfies EnvMode)),
...@@ -221,6 +226,30 @@ export function getCustomModelOptionsByProvider(...@@ -221,6 +226,30 @@ export function getCustomModelOptionsByProvider(
221 };226 };
222}227}
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
224export function useAppSettings() {253export function useAppSettings() {
225 const [settings, setSettings] = useLocalStorage(254 const [settings, setSettings] = useLocalStorage(
226 APP_SETTINGS_STORAGE_KEY,255 APP_SETTINGS_STORAGE_KEY,
apps/web/src/components/ChatView.tsx+2-11
...@@ -122,6 +122,7 @@ import { readNativeApi } from "~/nativeApi";...@@ -122,6 +122,7 @@ import { readNativeApi } from "~/nativeApi";
122import {122import {
123 getCustomModelOptionsByProvider,123 getCustomModelOptionsByProvider,
124 getCustomModelsByProvider,124 getCustomModelsByProvider,
125 getProviderStartOptions,
125 resolveAppModelSelection,126 resolveAppModelSelection,
126 useAppSettings,127 useAppSettings,
127} from "../appSettings";128} from "../appSettings";
...@@ -618,17 +619,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -618,17 +619,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
618 );619 );
619 const selectedPromptEffort = composerProviderState.promptEffort;620 const selectedPromptEffort = composerProviderState.promptEffort;
620 const selectedModelOptionsForDispatch = composerProviderState.modelOptionsForDispatch;621 const selectedModelOptionsForDispatch = composerProviderState.modelOptionsForDispatch;
621 const providerOptionsForDispatch = useMemo(() => {622 const providerOptionsForDispatch = useMemo(() => getProviderStartOptions(settings), [settings]);
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]);
632 const selectedModelForPicker = selectedModel;623 const selectedModelForPicker = selectedModel;
633 const modelOptionsByProvider = useMemo(624 const modelOptionsByProvider = useMemo(
634 () => getCustomModelOptionsByProvider(settings),625 () => getCustomModelOptionsByProvider(settings),
apps/web/src/components/ui/select.tsx+31-18
...@@ -164,32 +164,45 @@ function SelectPopup({...@@ -164,32 +164,45 @@ function SelectPopup({
164 );164 );
165}165}
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}) {
168 return (175 return (
169 <SelectPrimitive.Item176 <SelectPrimitive.Item
170 className={cn(177 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",
172 className,180 className,
173 )}181 )}
174 data-slot="select-item"182 data-slot="select-item"
175 {...props}183 {...props}
176 >184 >
177 <SelectPrimitive.ItemIndicator className="col-start-1">185 {hideIndicator ? null : (
178 <svg186 <SelectPrimitive.ItemIndicator className="col-start-1" data-slot="select-item-indicator">
179 fill="none"187 <svg
180 height="24"188 fill="none"
181 stroke="currentColor"189 height="24"
182 strokeLinecap="round"190 stroke="currentColor"
183 strokeLinejoin="round"191 strokeLinecap="round"
184 strokeWidth="2"192 strokeLinejoin="round"
185 viewBox="0 0 24 24"193 strokeWidth="2"
186 width="24"194 viewBox="0 0 24 24"
187 xmlns="http://www.w3.org/1500/svg"195 width="24"
188 >196 xmlns="http://www.w3.org/1500/svg"
189 <path d="M5.252 12.7 10.2 18.63 18.748 5.37" />197 >
190 </svg>198 <path d="M5.252 12.7 10.2 18.63 18.748 5.37" />
191 </SelectPrimitive.ItemIndicator>199 </svg>
192 <SelectPrimitive.ItemText className="col-start-2 min-w-0">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 >
193 {children}206 {children}
194 </SelectPrimitive.ItemText>207 </SelectPrimitive.ItemText>
195 </SelectPrimitive.Item>208 </SelectPrimitive.Item>
apps/web/src/routes/_chat.settings.tsx+739-508
...@@ -1,23 +1,20 @@...@@ -1,23 +1,20 @@
1import { createFileRoute } from "@tanstack/react-router";1import { createFileRoute } from "@tanstack/react-router";
2import { useQuery } from "@tanstack/react-query";2import { 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";
4import { type ProviderKind, DEFAULT_GIT_TEXT_GENERATION_MODEL } from "@t3tools/contracts";5import { type ProviderKind, DEFAULT_GIT_TEXT_GENERATION_MODEL } from "@t3tools/contracts";
5import { getModelOptions, normalizeModelSlug } from "@t3tools/shared/model";6import { getModelOptions, normalizeModelSlug } from "@t3tools/shared/model";
6import {7import {
7 getAppModelOptions,8 getAppModelOptions,
8 getCustomModelsForProvider,9 getCustomModelsForProvider,
9 getDefaultCustomModelsForProvider,
10 MAX_CUSTOM_MODEL_LENGTH,10 MAX_CUSTOM_MODEL_LENGTH,
11 MODEL_PROVIDER_SETTINGS,11 MODEL_PROVIDER_SETTINGS,
12 patchCustomModels,12 patchCustomModels,
13 useAppSettings,13 useAppSettings,
14} from "../appSettings";14} from "../appSettings";
15import { resolveAndPersistPreferredEditor } from "../editorPreferences";15import { APP_VERSION } from "../branding";
16import { isElectron } from "../env";
17import { useTheme } from "../hooks/useTheme";
18import { serverConfigQueryOptions } from "../lib/serverReactQuery";
19import { ensureNativeApi } from "../nativeApi";
20import { Button } from "../components/ui/button";16import { Button } from "../components/ui/button";
17import { Collapsible, CollapsibleContent } from "../components/ui/collapsible";
21import { Input } from "../components/ui/input";18import { Input } from "../components/ui/input";
22import {19import {
23 Select,20 Select,
...@@ -26,9 +23,16 @@ import {...@@ -26,9 +23,16 @@ import {
26 SelectTrigger,23 SelectTrigger,
27 SelectValue,24 SelectValue,
28} from "../components/ui/select";25} from "../components/ui/select";
26import { SidebarTrigger } from "../components/ui/sidebar";
29import { Switch } from "../components/ui/switch";27import { Switch } from "../components/ui/switch";
30import { APP_VERSION } from "../branding";28import { SidebarInset } from "../components/ui/sidebar";
31import { 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
33const THEME_OPTIONS = [37const THEME_OPTIONS = [
34 {38 {
...@@ -54,12 +58,145 @@ const TIMESTAMP_FORMAT_LABELS = {...@@ -54,12 +58,145 @@ const TIMESTAMP_FORMAT_LABELS = {
54 "24-hour": "24-hour",58 "24-hour": "24-hour",
55} as const;59} 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
57function SettingsRouteView() {188function SettingsRouteView() {
58 const { theme, setTheme, resolvedTheme } = useTheme();189 const { theme, setTheme } = useTheme();
59 const { settings, defaults, updateSettings } = useAppSettings();190 const { settings, defaults, updateSettings, resetSettings } = useAppSettings();
60 const serverConfigQuery = useQuery(serverConfigQueryOptions());191 const serverConfigQuery = useQuery(serverConfigQueryOptions());
61 const [isOpeningKeybindings, setIsOpeningKeybindings] = useState(false);192 const [isOpeningKeybindings, setIsOpeningKeybindings] = useState(false);
62 const [openKeybindingsError, setOpenKeybindingsError] = useState<string | null>(null);193 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");
63 const [customModelInputByProvider, setCustomModelInputByProvider] = useState<200 const [customModelInputByProvider, setCustomModelInputByProvider] = useState<
64 Record<ProviderKind, string>201 Record<ProviderKind, string>
65 >({202 >({
...@@ -69,9 +206,11 @@ function SettingsRouteView() {...@@ -69,9 +206,11 @@ function SettingsRouteView() {
69 const [customModelErrorByProvider, setCustomModelErrorByProvider] = useState<206 const [customModelErrorByProvider, setCustomModelErrorByProvider] = useState<
70 Partial<Record<ProviderKind, string | null>>207 Partial<Record<ProviderKind, string | null>>
71 >({});208 >({});
209 const [showAllCustomModels, setShowAllCustomModels] = useState(false);
72210
73 const codexBinaryPath = settings.codexBinaryPath;211 const codexBinaryPath = settings.codexBinaryPath;
74 const codexHomePath = settings.codexHomePath;212 const codexHomePath = settings.codexHomePath;
213 const claudeBinaryPath = settings.claudeBinaryPath;
75 const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null;214 const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null;
76 const availableEditors = serverConfigQuery.data?.availableEditors;215 const availableEditors = serverConfigQuery.data?.availableEditors;
77216
...@@ -80,11 +219,52 @@ function SettingsRouteView() {...@@ -80,11 +219,52 @@ function SettingsRouteView() {
80 settings.customCodexModels,219 settings.customCodexModels,
81 settings.textGenerationModel,220 settings.textGenerationModel,
82 );221 );
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;
83 const selectedGitTextGenerationModelLabel =228 const selectedGitTextGenerationModelLabel =
84 gitTextGenerationModelOptions.find(229 gitTextGenerationModelOptions.find((option) => option.slug === currentGitTextGenerationModel)
85 (option) =>230 ?.name ?? currentGitTextGenerationModel;
86 option.slug === (settings.textGenerationModel ?? DEFAULT_GIT_TEXT_GENERATION_MODEL),231 const selectedCustomModelProviderSettings = MODEL_PROVIDER_SETTINGS.find(
87 )?.name ?? settings.textGenerationModel;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
89 const openKeybindingsFile = useCallback(() => {269 const openKeybindingsFile = useCallback(() => {
90 if (!keybindingsConfigPath) return;270 if (!keybindingsConfigPath) return;
...@@ -173,550 +353,601 @@ function SettingsRouteView() {...@@ -173,550 +353,601 @@ function SettingsRouteView() {
173 [settings, updateSettings],353 [settings, updateSettings],
174 );354 );
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
176 return (381 return (
177 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground isolate">382 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground isolate">
178 <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-foreground">383 <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
179 {isElectron && (404 {isElectron && (
180 <div className="drag-region flex h-[52px] shrink-0 items-center border-b border-border px-5">405 <div className="drag-region flex h-[52px] shrink-0 items-center border-b border-border px-5">
181 <span className="text-xs font-medium tracking-wide text-muted-foreground/70">406 <span className="text-xs font-medium tracking-wide text-muted-foreground/70">
182 Settings407 Settings
183 </span>408 </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>
184 </div>420 </div>
185 )}421 )}
186422
187 <div className="flex-1 overflow-y-auto p-6">423 <div className="flex-1 overflow-y-auto p-6">
188 <div className="mx-auto flex w-full max-w-3xl flex-col gap-6">424 <div className="mx-auto flex w-full max-w-2xl flex-col gap-6">
189 <header className="space-y-1">425 <SettingsSection title="General">
190 <h1 className="text-2xl font-semibold tracking-tight text-foreground">Settings</h1>426 <SettingsRow
191 <p className="text-sm text-muted-foreground">427 title="Theme"
192 Configure app-level preferences for this device.428 description="Choose how T3 Code looks across the app."
193 </p>429 resetAction={
194 </header>430 theme !== "system" ? (
195431 <SettingResetButton label="theme" onClick={() => setTheme("system")} />
196 <section className="rounded-2xl border border-border bg-card p-5">432 ) : null
197 <div className="mb-4">433 }
198 <h2 className="text-sm font-medium text-foreground">Appearance</h2>434 control={
199 <p className="mt-1 text-xs text-muted-foreground">435 <Select
200 Choose how T3 Code looks across the app.436 value={theme}
201 </p>437 onValueChange={(value) => {
202 </div>438 if (value !== "system" && value !== "light" && value !== "dark") return;
203439 setTheme(value);
204 <div className="space-y-4">440 }}
205 <div className="space-y-2" role="radiogroup" aria-label="Theme preference">441 >
206 {THEME_OPTIONS.map((option) => {442 <SelectTrigger className="w-full sm:w-40" aria-label="Theme preference">
207 const selected = theme === option.value;443 <SelectValue>
208 return (444 {THEME_OPTIONS.find((option) => option.value === theme)?.label ?? "System"}
209 <button445 </SelectValue>
210 key={option.value}446 </SelectTrigger>
211 type="button"447 <SelectPopup align="end" alignItemWithTrigger={false}>
212 role="radio"448 {THEME_OPTIONS.map((option) => (
213 aria-checked={selected}449 <SelectItem hideIndicator key={option.value} value={option.value}>
214 className={`flex w-full items-start justify-between rounded-lg border px-3 py-2 text-left transition-colors ${450 {option.label}
215 selected451 </SelectItem>
216 ? "border-primary/60 bg-primary/8 text-foreground"452 ))}
217 : "border-border bg-background text-muted-foreground hover:bg-accent"453 </SelectPopup>
218 }`}454 </Select>
219 onClick={() => setTheme(option.value)}455 }
220 >456 />
221 <span className="flex flex-col">457
222 <span className="text-sm font-medium">{option.label}</span>458 <SettingsRow
223 <span className="text-xs">{option.description}</span>459 title="Time format"
224 </span>460 description="System default follows your browser or OS clock preference."
225 {selected ? (461 resetAction={
226 <span className="rounded bg-primary/14 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-primary">462 settings.timestampFormat !== defaults.timestampFormat ? (
227 Selected463 <SettingResetButton
228 </span>464 label="time format"
229 ) : null}465 onClick={() =>
230 </button>466 updateSettings({
231 );467 timestampFormat: defaults.timestampFormat,
232 })}468 })
233 </div>469 }
234470 />
235 <p className="text-xs text-muted-foreground">471 ) : null
236 Active theme: <span className="font-medium text-foreground">{resolvedTheme}</span>472 }
237 </p>473 control={
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>
247 <Select474 <Select
248 value={settings.timestampFormat}475 value={settings.timestampFormat}
249 onValueChange={(value) => {476 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 }
251 updateSettings({480 updateSettings({
252 timestampFormat: value,481 timestampFormat: value,
253 });482 });
254 }}483 }}
255 >484 >
256 <SelectTrigger className="w-40" aria-label="Timestamp format">485 <SelectTrigger className="w-full sm:w-40" aria-label="Timestamp format">
257 <SelectValue>{TIMESTAMP_FORMAT_LABELS[settings.timestampFormat]}</SelectValue>486 <SelectValue>{TIMESTAMP_FORMAT_LABELS[settings.timestampFormat]}</SelectValue>
258 </SelectTrigger>487 </SelectTrigger>
259 <SelectPopup align="end">488 <SelectPopup align="end" alignItemWithTrigger={false}>
260 <SelectItem value="locale">{TIMESTAMP_FORMAT_LABELS.locale}</SelectItem>489 <SelectItem hideIndicator value="locale">
261 <SelectItem value="12-hour">{TIMESTAMP_FORMAT_LABELS["12-hour"]}</SelectItem>490 {TIMESTAMP_FORMAT_LABELS.locale}
262 <SelectItem value="24-hour">{TIMESTAMP_FORMAT_LABELS["24-hour"]}</SelectItem>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>
263 </SelectPopup>498 </SelectPopup>
264 </Select>499 </Select>
265 </div>500 }
266501 />
267 {settings.timestampFormat !== defaults.timestampFormat ? (502
268 <div className="flex justify-end">503 <SettingsRow
269 <Button504 title="Assistant output"
270 size="xs"505 description="Show token-by-token output while a response is in progress."
271 variant="outline"506 resetAction={
507 settings.enableAssistantStreaming !== defaults.enableAssistantStreaming ? (
508 <SettingResetButton
509 label="assistant output"
272 onClick={() =>510 onClick={() =>
273 updateSettings({511 updateSettings({
274 timestampFormat: defaults.timestampFormat,512 enableAssistantStreaming: defaults.enableAssistantStreaming,
275 })513 })
276 }514 }
277 >515 />
278 Restore default516 ) : null
279 </Button>517 }
280 </div>518 control={
281 ) : null}519 <Switch
282 </div>520 checked={settings.enableAssistantStreaming}
283 </section>521 onCheckedChange={(checked) =>
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={() =>
334 updateSettings({522 updateSettings({
335 codexBinaryPath: defaults.codexBinaryPath,523 enableAssistantStreaming: Boolean(checked),
336 codexHomePath: defaults.codexHomePath,
337 })524 })
338 }525 }
339 >526 aria-label="Stream assistant messages"
340 Reset codex overrides527 />
341 </Button>528 }
342 </div>529 />
343 </div>530
344 </section>531 <SettingsRow
345532 title="New threads"
346 <section className="rounded-2xl border border-border bg-card p-5">533 description="Pick the default workspace mode for newly created draft threads."
347 <div className="mb-4">534 resetAction={
348 <h2 className="text-sm font-medium text-foreground">Models</h2>535 settings.defaultThreadEnvMode !== defaults.defaultThreadEnvMode ? (
349 <p className="mt-1 text-xs text-muted-foreground">536 <SettingResetButton
350 Save additional provider model slugs so they appear in the chat model picker and537 label="new threads"
351 `/model` command suggestions.538 onClick={() =>
352 </p>539 updateSettings({
353 </div>540 defaultThreadEnvMode: defaults.defaultThreadEnvMode,
354541 })
355 <div className="space-y-5">542 }
356 {MODEL_PROVIDER_SETTINGS.map((providerSettings) => {543 />
357 const provider = providerSettings.provider;544 ) : null
358 const customModels = getCustomModelsForProvider(settings, provider);545 }
359 const customModelInput = customModelInputByProvider[provider];546 control={
360 const customModelError = customModelErrorByProvider[provider] ?? null;547 <Select
361 return (548 value={settings.defaultThreadEnvMode}
362 <div549 onValueChange={(value) => {
363 key={provider}550 if (value !== "local" && value !== "worktree") return;
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) {
499 updateSettings({551 updateSettings({
500 textGenerationModel: value,552 defaultThreadEnvMode: value,
501 });553 });
502 }554 }}
503 }}
504 >
505 <SelectTrigger
506 className="w-full shrink-0 sm:w-48"
507 aria-label="Git text generation model"
508 >555 >
509 <SelectValue>{selectedGitTextGenerationModelLabel}</SelectValue>556 <SelectTrigger className="w-full sm:w-44" aria-label="Default thread mode">
510 </SelectTrigger>557 <SelectValue>
511 <SelectPopup align="end">558 {settings.defaultThreadEnvMode === "worktree" ? "New worktree" : "Local"}
512 {gitTextGenerationModelOptions.map((option) => (559 </SelectValue>
513 <SelectItem key={option.slug} value={option.slug}>560 </SelectTrigger>
514 {option.name}561 <SelectPopup align="end" alignItemWithTrigger={false}>
562 <SelectItem hideIndicator value="local">
563 Local
515 </SelectItem>564 </SelectItem>
516 ))}565 <SelectItem hideIndicator value="worktree">
517 </SelectPopup>566 New worktree
518 </Select>567 </SelectItem>
519 </div>568 </SelectPopup>
520569 </Select>
521 {settings.textGenerationModel !== defaults.textGenerationModel ? (570 }
522 <div className="mt-3 flex justify-end">571 />
523 <Button572
524 size="xs"573 <SettingsRow
525 variant="outline"574 title="Delete confirmation"
526 onClick={() =>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) =>
527 updateSettings({592 updateSettings({
528 textGenerationModel: defaults.textGenerationModel,593 confirmThreadDelete: Boolean(checked),
529 })594 })
530 }595 }
531 >596 aria-label="Confirm thread deletion"
532 Restore default597 />
533 </Button>598 }
534 </div>599 />
535 ) : null}600 </SettingsSection>
536 </section>601
537602 <SettingsSection title="Models">
538 <section className="rounded-2xl border border-border bg-card p-5">603 <SettingsRow
539 <div className="mb-4">604 title="Git writing model"
540 <h2 className="text-sm font-medium text-foreground">Threads</h2>605 description="Used for generated commit messages, PR titles, and branch names."
541 <p className="mt-1 text-xs text-muted-foreground">606 resetAction={
542 Choose the default workspace mode for newly created draft threads.607 isGitTextGenerationModelDirty ? (
543 </p>608 <SettingResetButton
544 </div>609 label="git writing model"
545610 onClick={() =>
546 <div className="flex items-center justify-between rounded-lg border border-border bg-background px-3 py-2">611 updateSettings({
547 <div>612 textGenerationModel: defaults.textGenerationModel,
548 <p className="text-sm font-medium text-foreground">Default to New worktree</p>613 })
549 <p className="text-xs text-muted-foreground">614 }
550 New threads start in New worktree mode instead of Local.615 />
551 </p>616 ) : null
552 </div>617 }
553 <Switch618 control={
554 checked={settings.defaultThreadEnvMode === "worktree"}619 <Select
555 onCheckedChange={(checked) =>620 value={currentGitTextGenerationModel}
556 updateSettings({621 onValueChange={(value) => {
557 defaultThreadEnvMode: checked ? "worktree" : "local",622 if (!value) return;
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={() =>
570 updateSettings({623 updateSettings({
571 defaultThreadEnvMode: defaults.defaultThreadEnvMode,624 textGenerationModel: value,
572 })625 });
573 }626 }}
574 >627 >
575 Restore default628 <SelectTrigger
576 </Button>629 className="w-full sm:w-52"
577 </div>630 aria-label="Git text generation model"
578 ) : null}631 >
579 </section>632 <SelectValue>{selectedGitTextGenerationModelLabel}</SelectValue>
580633 </SelectTrigger>
581 <section className="rounded-2xl border border-border bg-card p-5">634 <SelectPopup align="end" alignItemWithTrigger={false}>
582 <div className="mb-4">635 {gitTextGenerationModelOptions.map((option) => (
583 <h2 className="text-sm font-medium text-foreground">Responses</h2>636 <SelectItem hideIndicator key={option.slug} value={option.slug}>
584 <p className="mt-1 text-xs text-muted-foreground">637 {option.name}
585 Control how assistant output is rendered during a turn.638 </SelectItem>
586 </p>639 ))}
587 </div>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">729 {selectedCustomModelError ? (
590 <div>730 <p className="mt-2 text-xs text-destructive">{selectedCustomModelError}</p>
591 <p className="text-sm font-medium text-foreground">Stream assistant messages</p>731 ) : null}
592 <p className="text-xs text-muted-foreground">732
593 Show token-by-token output while a response is in progress.733 {totalCustomModels > 0 ? (
594 </p>734 <div className="mt-3">
595 </div>735 <div>
596 <Switch736 {visibleCustomModelRows.map((row) => (
597 checked={settings.enableAssistantStreaming}737 <div
598 onCheckedChange={(checked) =>738 key={row.key}
599 updateSettings({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"
600 enableAssistantStreaming: Boolean(checked),740 >
601 })741 <span className="truncate text-xs text-muted-foreground">
602 }742 {row.providerTitle}
603 aria-label="Stream assistant messages"743 </span>
604 />744 <code className="min-w-0 truncate text-sm text-foreground">
605 </div>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 ? (759 {savedCustomModelRows.length > 5 ? (
608 <div className="mt-3 flex justify-end">760 <button
609 <Button761 type="button"
610 size="xs"762 className="mt-2 text-xs text-muted-foreground transition-colors hover:text-foreground"
611 variant="outline"763 onClick={() => setShowAllCustomModels((value) => !value)}
612 onClick={() =>764 >
613 updateSettings({765 {showAllCustomModels
614 enableAssistantStreaming: defaults.enableAssistantStreaming,766 ? "Show less"
615 })767 : `Show more (${savedCustomModelRows.length - 5})`}
616 }768 </button>
617 >769 ) : null}
618 Restore default770 </div>
619 </Button>771 ) : null}
620 </div>772 </div>
621 ) : null}773 </SettingsRow>
622 </section>774 </SettingsSection>
623775
624 <section className="rounded-2xl border border-border bg-card p-5">776 <SettingsSection title="Advanced">
625 <div className="mb-4">777 <SettingsRow
626 <h2 className="text-sm font-medium text-foreground">Keybindings</h2>778 title="Provider installs"
627 <p className="mt-1 text-xs text-muted-foreground">779 description="Override the CLI used for new sessions."
628 Open the persisted <code>keybindings.json</code> file to edit advanced bindings780 resetAction={
629 directly.781 isInstallSettingsDirty ? (
630 </p>782 <SettingResetButton
631 </div>783 label="provider installs"
632784 onClick={() => {
633 <div className="space-y-3">785 updateSettings({
634 <div className="flex items-center justify-between gap-3 rounded-lg border border-border bg-background px-3 py-2">786 claudeBinaryPath: defaults.claudeBinaryPath,
635 <div className="min-w-0 flex-1">787 codexBinaryPath: defaults.codexBinaryPath,
636 <p className="text-xs font-medium text-foreground">Config file path</p>788 codexHomePath: defaults.codexHomePath,
637 <p className="mt-1 break-all font-mono text-[11px] text-muted-foreground">789 });
638 {keybindingsConfigPath ?? "Resolving keybindings path..."}790 setOpenInstallProviders({
639 </p>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 })}
640 </div>912 </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={
641 <Button932 <Button
642 size="xs"933 size="xs"
643 variant="outline"934 variant="outline"
644 disabled={!keybindingsConfigPath || isOpeningKeybindings}935 disabled={!keybindingsConfigPath || isOpeningKeybindings}
645 onClick={openKeybindingsFile}936 onClick={openKeybindingsFile}
646 >937 >
647 {isOpeningKeybindings ? "Opening..." : "Open keybindings.json"}938 {isOpeningKeybindings ? "Opening..." : "Open file"}
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
698 </Button>939 </Button>
699 </div>940 }
700 ) : null}941 />
701 </section>942
702 <section className="rounded-2xl border border-border bg-card p-5">943 <SettingsRow
703 <div className="mb-4">944 title="Version"
704 <h2 className="text-sm font-medium text-foreground">About</h2>945 description="Current application version."
705 <p className="mt-1 text-xs text-muted-foreground">946 control={
706 Application version and environment information.947 <code className="text-xs font-medium text-muted-foreground">{APP_VERSION}</code>
707 </p>948 }
708 </div>949 />
709950 </SettingsSection>
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>
720 </div>951 </div>
721 </div>952 </div>
722 </div>953 </div>