diff --git a/apps/web/src/appSettings.test.ts b/apps/web/src/appSettings.test.ts index 26d231537d..16b0a840f8 100644 --- a/apps/web/src/appSettings.test.ts +++ b/apps/web/src/appSettings.test.ts @@ -9,6 +9,7 @@ import { getCustomModelsByProvider, getCustomModelsForProvider, getDefaultCustomModelsForProvider, + getProviderStartOptions, MODEL_PROVIDER_SETTINGS, normalizeCustomModelSlugs, patchCustomModels, @@ -118,6 +119,35 @@ describe("provider-specific custom models", () => { }); }); +describe("getProviderStartOptions", () => { + it("returns only populated provider overrides", () => { + expect( + getProviderStartOptions({ + claudeBinaryPath: "/usr/local/bin/claude", + codexBinaryPath: "", + codexHomePath: "/Users/you/.codex", + }), + ).toEqual({ + claudeAgent: { + binaryPath: "/usr/local/bin/claude", + }, + codex: { + homePath: "/Users/you/.codex", + }, + }); + }); + + it("returns undefined when no provider overrides are configured", () => { + expect( + getProviderStartOptions({ + claudeBinaryPath: "", + codexBinaryPath: "", + codexHomePath: "", + }), + ).toBeUndefined(); + }); +}); + describe("provider-indexed custom model settings", () => { const settings = { customCodexModels: ["custom/codex-model"], @@ -209,6 +239,7 @@ describe("AppSettingsSchema", () => { }), ), ).toMatchObject({ + claudeBinaryPath: "", codexBinaryPath: "/usr/local/bin/codex", codexHomePath: "", defaultThreadEnvMode: "local", diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index 14b6a6a92d..99a62f663f 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -1,6 +1,10 @@ import { useCallback } from "react"; import { Option, Schema } from "effect"; -import { TrimmedNonEmptyString, type ProviderKind } from "@t3tools/contracts"; +import { + TrimmedNonEmptyString, + type ProviderKind, + type ProviderStartOptions, +} from "@t3tools/contracts"; import { getDefaultModel, getModelOptions, @@ -47,6 +51,7 @@ const withDefaults = ); export const AppSettingsSchema = Schema.Struct({ + claudeBinaryPath: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")), codexBinaryPath: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")), codexHomePath: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")), defaultThreadEnvMode: EnvMode.pipe(withDefaults(() => "local" as const satisfies EnvMode)), @@ -221,6 +226,30 @@ export function getCustomModelOptionsByProvider( }; } +export function getProviderStartOptions( + settings: Pick, +): ProviderStartOptions | undefined { + const providerOptions: ProviderStartOptions = { + ...(settings.codexBinaryPath || settings.codexHomePath + ? { + codex: { + ...(settings.codexBinaryPath ? { binaryPath: settings.codexBinaryPath } : {}), + ...(settings.codexHomePath ? { homePath: settings.codexHomePath } : {}), + }, + } + : {}), + ...(settings.claudeBinaryPath + ? { + claudeAgent: { + binaryPath: settings.claudeBinaryPath, + }, + } + : {}), + }; + + return Object.keys(providerOptions).length > 0 ? providerOptions : undefined; +} + export function useAppSettings() { const [settings, setSettings] = useLocalStorage( APP_SETTINGS_STORAGE_KEY, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index e628f6ea6a..3484375244 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -122,6 +122,7 @@ import { readNativeApi } from "~/nativeApi"; import { getCustomModelOptionsByProvider, getCustomModelsByProvider, + getProviderStartOptions, resolveAppModelSelection, useAppSettings, } from "../appSettings"; @@ -618,17 +619,7 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const selectedPromptEffort = composerProviderState.promptEffort; const selectedModelOptionsForDispatch = composerProviderState.modelOptionsForDispatch; - const providerOptionsForDispatch = useMemo(() => { - if (!settings.codexBinaryPath && !settings.codexHomePath) { - return undefined; - } - return { - codex: { - ...(settings.codexBinaryPath ? { binaryPath: settings.codexBinaryPath } : {}), - ...(settings.codexHomePath ? { homePath: settings.codexHomePath } : {}), - }, - }; - }, [settings.codexBinaryPath, settings.codexHomePath]); + const providerOptionsForDispatch = useMemo(() => getProviderStartOptions(settings), [settings]); const selectedModelForPicker = selectedModel; const modelOptionsByProvider = useMemo( () => getCustomModelOptionsByProvider(settings), diff --git a/apps/web/src/components/ui/select.tsx b/apps/web/src/components/ui/select.tsx index e90b415820..5c423ef05b 100644 --- a/apps/web/src/components/ui/select.tsx +++ b/apps/web/src/components/ui/select.tsx @@ -164,32 +164,45 @@ function SelectPopup({ ); } -function SelectItem({ className, children, ...props }: SelectPrimitive.Item.Props) { +function SelectItem({ + className, + children, + hideIndicator = false, + ...props +}: SelectPrimitive.Item.Props & { + hideIndicator?: boolean; +}) { return ( - - - - - - + {hideIndicator ? null : ( + + + + + + )} + {children} diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index acc8763fb4..0fbff1cdad 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -1,23 +1,20 @@ import { createFileRoute } from "@tanstack/react-router"; import { useQuery } from "@tanstack/react-query"; -import { useCallback, useState } from "react"; +import { ChevronDownIcon, PlusIcon, RotateCcwIcon, Undo2Icon, XIcon } from "lucide-react"; +import { type ReactNode, useCallback, useState } from "react"; import { type ProviderKind, DEFAULT_GIT_TEXT_GENERATION_MODEL } from "@t3tools/contracts"; import { getModelOptions, normalizeModelSlug } from "@t3tools/shared/model"; import { getAppModelOptions, getCustomModelsForProvider, - getDefaultCustomModelsForProvider, MAX_CUSTOM_MODEL_LENGTH, MODEL_PROVIDER_SETTINGS, patchCustomModels, useAppSettings, } from "../appSettings"; -import { resolveAndPersistPreferredEditor } from "../editorPreferences"; -import { isElectron } from "../env"; -import { useTheme } from "../hooks/useTheme"; -import { serverConfigQueryOptions } from "../lib/serverReactQuery"; -import { ensureNativeApi } from "../nativeApi"; +import { APP_VERSION } from "../branding"; import { Button } from "../components/ui/button"; +import { Collapsible, CollapsibleContent } from "../components/ui/collapsible"; import { Input } from "../components/ui/input"; import { Select, @@ -26,9 +23,16 @@ import { SelectTrigger, SelectValue, } from "../components/ui/select"; +import { SidebarTrigger } from "../components/ui/sidebar"; import { Switch } from "../components/ui/switch"; -import { APP_VERSION } from "../branding"; -import { SidebarInset } from "~/components/ui/sidebar"; +import { SidebarInset } from "../components/ui/sidebar"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../components/ui/tooltip"; +import { resolveAndPersistPreferredEditor } from "../editorPreferences"; +import { isElectron } from "../env"; +import { useTheme } from "../hooks/useTheme"; +import { serverConfigQueryOptions } from "../lib/serverReactQuery"; +import { cn } from "../lib/utils"; +import { ensureNativeApi, readNativeApi } from "../nativeApi"; const THEME_OPTIONS = [ { @@ -54,12 +58,145 @@ const TIMESTAMP_FORMAT_LABELS = { "24-hour": "24-hour", } as const; +type InstallBinarySettingsKey = "claudeBinaryPath" | "codexBinaryPath"; +type InstallProviderSettings = { + provider: ProviderKind; + title: string; + binaryPathKey: InstallBinarySettingsKey; + binaryPlaceholder: string; + binaryDescription: ReactNode; + homePathKey?: "codexHomePath"; + homePlaceholder?: string; + homeDescription?: ReactNode; +}; + +const INSTALL_PROVIDER_SETTINGS: readonly InstallProviderSettings[] = [ + { + provider: "codex", + title: "Codex", + binaryPathKey: "codexBinaryPath", + binaryPlaceholder: "Codex binary path", + binaryDescription: ( + <> + Leave blank to use codex from your PATH. + + ), + homePathKey: "codexHomePath", + homePlaceholder: "CODEX_HOME", + homeDescription: "Optional custom Codex home and config directory.", + }, + { + provider: "claudeAgent", + title: "Claude", + binaryPathKey: "claudeBinaryPath", + binaryPlaceholder: "Claude binary path", + binaryDescription: ( + <> + Leave blank to use claude from your PATH. + + ), + }, +]; + +function SettingsSection({ title, children }: { title: string; children: ReactNode }) { + return ( +
+

+ {title} +

+
+ {children} +
+
+ ); +} + +function SettingsRow({ + title, + description, + status, + resetAction, + control, + children, + onClick, +}: { + title: string; + description: string; + status?: ReactNode; + resetAction?: ReactNode; + control?: ReactNode; + children?: ReactNode; + onClick?: () => void; +}) { + return ( +
+
+
+
+

{title}

+ + {resetAction} + +
+

{description}

+ {status ?
{status}
: null} +
+ {control ? ( +
+ {control} +
+ ) : null} +
+ {children} +
+ ); +} + +function SettingResetButton({ label, onClick }: { label: string; onClick: () => void }) { + return ( + + { + event.stopPropagation(); + onClick(); + }} + > + + + } + /> + Reset to default + + ); +} + function SettingsRouteView() { - const { theme, setTheme, resolvedTheme } = useTheme(); - const { settings, defaults, updateSettings } = useAppSettings(); + const { theme, setTheme } = useTheme(); + const { settings, defaults, updateSettings, resetSettings } = useAppSettings(); const serverConfigQuery = useQuery(serverConfigQueryOptions()); const [isOpeningKeybindings, setIsOpeningKeybindings] = useState(false); const [openKeybindingsError, setOpenKeybindingsError] = useState(null); + const [openInstallProviders, setOpenInstallProviders] = useState>({ + codex: Boolean(settings.codexBinaryPath || settings.codexHomePath), + claudeAgent: Boolean(settings.claudeBinaryPath), + }); + const [selectedCustomModelProvider, setSelectedCustomModelProvider] = + useState("codex"); const [customModelInputByProvider, setCustomModelInputByProvider] = useState< Record >({ @@ -69,9 +206,11 @@ function SettingsRouteView() { const [customModelErrorByProvider, setCustomModelErrorByProvider] = useState< Partial> >({}); + const [showAllCustomModels, setShowAllCustomModels] = useState(false); const codexBinaryPath = settings.codexBinaryPath; const codexHomePath = settings.codexHomePath; + const claudeBinaryPath = settings.claudeBinaryPath; const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null; const availableEditors = serverConfigQuery.data?.availableEditors; @@ -80,11 +219,52 @@ function SettingsRouteView() { settings.customCodexModels, settings.textGenerationModel, ); + const currentGitTextGenerationModel = + settings.textGenerationModel ?? DEFAULT_GIT_TEXT_GENERATION_MODEL; + const defaultGitTextGenerationModel = + defaults.textGenerationModel ?? DEFAULT_GIT_TEXT_GENERATION_MODEL; + const isGitTextGenerationModelDirty = + currentGitTextGenerationModel !== defaultGitTextGenerationModel; const selectedGitTextGenerationModelLabel = - gitTextGenerationModelOptions.find( - (option) => - option.slug === (settings.textGenerationModel ?? DEFAULT_GIT_TEXT_GENERATION_MODEL), - )?.name ?? settings.textGenerationModel; + gitTextGenerationModelOptions.find((option) => option.slug === currentGitTextGenerationModel) + ?.name ?? currentGitTextGenerationModel; + const selectedCustomModelProviderSettings = MODEL_PROVIDER_SETTINGS.find( + (providerSettings) => providerSettings.provider === selectedCustomModelProvider, + )!; + const selectedCustomModelInput = customModelInputByProvider[selectedCustomModelProvider]; + const selectedCustomModelError = customModelErrorByProvider[selectedCustomModelProvider] ?? null; + const totalCustomModels = settings.customCodexModels.length + settings.customClaudeModels.length; + const savedCustomModelRows = MODEL_PROVIDER_SETTINGS.flatMap((providerSettings) => + getCustomModelsForProvider(settings, providerSettings.provider).map((slug) => ({ + key: `${providerSettings.provider}:${slug}`, + provider: providerSettings.provider, + providerTitle: providerSettings.title, + slug, + })), + ); + const visibleCustomModelRows = showAllCustomModels + ? savedCustomModelRows + : savedCustomModelRows.slice(0, 5); + const isInstallSettingsDirty = + settings.claudeBinaryPath !== defaults.claudeBinaryPath || + settings.codexBinaryPath !== defaults.codexBinaryPath || + settings.codexHomePath !== defaults.codexHomePath; + const changedSettingLabels = [ + ...(theme !== "system" ? ["Theme"] : []), + ...(settings.timestampFormat !== defaults.timestampFormat ? ["Time format"] : []), + ...(settings.enableAssistantStreaming !== defaults.enableAssistantStreaming + ? ["Assistant output"] + : []), + ...(settings.defaultThreadEnvMode !== defaults.defaultThreadEnvMode ? ["New thread mode"] : []), + ...(settings.confirmThreadDelete !== defaults.confirmThreadDelete + ? ["Delete confirmation"] + : []), + ...(isGitTextGenerationModelDirty ? ["Git writing model"] : []), + ...(settings.customCodexModels.length > 0 || settings.customClaudeModels.length > 0 + ? ["Custom models"] + : []), + ...(isInstallSettingsDirty ? ["Provider installs"] : []), + ]; const openKeybindingsFile = useCallback(() => { if (!keybindingsConfigPath) return; @@ -173,550 +353,601 @@ function SettingsRouteView() { [settings, updateSettings], ); + async function restoreDefaults() { + if (changedSettingLabels.length === 0) return; + + const api = readNativeApi(); + const confirmed = await (api ?? ensureNativeApi()).dialogs.confirm( + ["Restore default settings?", `This will reset: ${changedSettingLabels.join(", ")}.`].join( + "\n", + ), + ); + if (!confirmed) return; + + setTheme("system"); + resetSettings(); + setOpenInstallProviders({ + codex: false, + claudeAgent: false, + }); + setSelectedCustomModelProvider("codex"); + setCustomModelInputByProvider({ + codex: "", + claudeAgent: "", + }); + setCustomModelErrorByProvider({}); + } + return (
+ {!isElectron && ( +
+
+ + Settings +
+ +
+
+
+ )} + {isElectron && (
Settings +
+ +
)}
-
-
-

Settings

-

- Configure app-level preferences for this device. -

-
- -
-
-

Appearance

-

- Choose how T3 Code looks across the app. -

-
- -
-
- {THEME_OPTIONS.map((option) => { - const selected = theme === option.value; - return ( - - ); - })} -
- -

- Active theme: {resolvedTheme} -

- -
-
-

Timestamp format

-

- System default follows your browser or OS time format. 12-hour{" "} - and 24-hour force the hour cycle. -

-
+
+ + setTheme("system")} /> + ) : null + } + control={ + + } + /> + + + updateSettings({ + timestampFormat: defaults.timestampFormat, + }) + } + /> + ) : null + } + control={ -
- - {settings.timestampFormat !== defaults.timestampFormat ? ( -
- -
- ) : null} -
-
- -
-
-

Codex App Server

-

- These overrides apply to new sessions and let you use a non-default Codex install. -

-
- -
- - - - -
-
-

Binary source

-

- {codexBinaryPath || "PATH"} -

-
- -
-
-
- -
-
-

Models

-

- Save additional provider model slugs so they appear in the chat model picker and - `/model` command suggestions. -

-
- -
- {MODEL_PROVIDER_SETTINGS.map((providerSettings) => { - const provider = providerSettings.provider; - const customModels = getCustomModelsForProvider(settings, provider); - const customModelInput = customModelInputByProvider[provider]; - const customModelError = customModelErrorByProvider[provider] ?? null; - return ( -
-
-

- {providerSettings.title} -

-

- {providerSettings.description} -

-
- -
-
- - - -
- - {customModelError ? ( -

{customModelError}

- ) : null} - -
-
-

Saved custom models: {customModels.length}

- {customModels.length > 0 ? ( - - ) : null} -
- - {customModels.length > 0 ? ( -
- {customModels.map((slug) => ( -
- - {slug} - - -
- ))} -
- ) : ( -
- No custom models saved yet. -
- )} -
-
-
- ); - })} -
-
- -
-
-

Git

-

- Configure the model used for generating commit messages, PR titles, and branch - names. -

-
- -
-
-

Text generation model

-

- Model used for auto-generated git content. -

-
- { + if (value !== "local" && value !== "worktree") return; updateSettings({ - textGenerationModel: value, + defaultThreadEnvMode: value, }); - } - }} - > - - {selectedGitTextGenerationModelLabel} - - - {gitTextGenerationModelOptions.map((option) => ( - - {option.name} + + + {settings.defaultThreadEnvMode === "worktree" ? "New worktree" : "Local"} + + + + + Local - ))} - - -
- - {settings.textGenerationModel !== defaults.textGenerationModel ? ( -
- -
- ) : null} -
- -
-
-

Threads

-

- Choose the default workspace mode for newly created draft threads. -

-
- -
-
-

Default to New worktree

-

- New threads start in New worktree mode instead of Local. -

-
- - updateSettings({ - defaultThreadEnvMode: checked ? "worktree" : "local", - }) - } - aria-label="Default new threads to New worktree mode" - /> -
- - {settings.defaultThreadEnvMode !== defaults.defaultThreadEnvMode ? ( -
- +
-
-
-

Stream assistant messages

-

- Show token-by-token output while a response is in progress. -

-
- - updateSettings({ - enableAssistantStreaming: Boolean(checked), - }) - } - aria-label="Stream assistant messages" - /> -
+ {selectedCustomModelError ? ( +

{selectedCustomModelError}

+ ) : null} + + {totalCustomModels > 0 ? ( +
+
+ {visibleCustomModelRows.map((row) => ( +
+ + {row.providerTitle} + + + {row.slug} + + +
+ ))} +
- {settings.enableAssistantStreaming !== defaults.enableAssistantStreaming ? ( -
- + {savedCustomModelRows.length > 5 ? ( + + ) : null} +
+ ) : null}
- ) : null} -
- -
-
-

Keybindings

-

- Open the persisted keybindings.json file to edit advanced bindings - directly. -

-
- -
-
-
-

Config file path

-

- {keybindingsConfigPath ?? "Resolving keybindings path..."} -

+ + + + + { + updateSettings({ + claudeBinaryPath: defaults.claudeBinaryPath, + codexBinaryPath: defaults.codexBinaryPath, + codexHomePath: defaults.codexHomePath, + }); + setOpenInstallProviders({ + codex: false, + claudeAgent: false, + }); + }} + /> + ) : null + } + > +
+
+ {INSTALL_PROVIDER_SETTINGS.map((providerSettings) => { + const isOpen = openInstallProviders[providerSettings.provider]; + const isDirty = + providerSettings.provider === "codex" + ? settings.codexBinaryPath !== defaults.codexBinaryPath || + settings.codexHomePath !== defaults.codexHomePath + : settings.claudeBinaryPath !== defaults.claudeBinaryPath; + const binaryPathValue = + providerSettings.binaryPathKey === "claudeBinaryPath" + ? claudeBinaryPath + : codexBinaryPath; + + return ( + + setOpenInstallProviders((existing) => ({ + ...existing, + [providerSettings.provider]: open, + })) + } + > +
+ + + +
+
+ + + {providerSettings.homePathKey ? ( + + ) : null} +
+
+
+
+
+ ); + })}
+
+
+ + + + {keybindingsConfigPath ?? "Resolving keybindings path..."} + + {openKeybindingsError ? ( + {openKeybindingsError} + ) : ( + Opens in your preferred editor. + )} + + } + control={ -
- -

- Opens in your preferred editor selection. -

- {openKeybindingsError ? ( -

{openKeybindingsError}

- ) : null} -
-
- -
-
-

Safety

-

- Additional guardrails for destructive local actions. -

-
- -
-
-

Confirm thread deletion

-

- Ask for confirmation before deleting a thread and its chat history. -

-
- - updateSettings({ - confirmThreadDelete: Boolean(checked), - }) - } - aria-label="Confirm thread deletion" - /> -
- - {settings.confirmThreadDelete !== defaults.confirmThreadDelete ? ( -
- -
- ) : null} -
-
-
-

About

-

- Application version and environment information. -

-
- -
-
-

Version

-

- Current version of the application. -

-
- {APP_VERSION} -
-
+ } + /> + + {APP_VERSION} + } + /> +