import { createFileRoute } from "@tanstack/react-router"; import { useQuery } from "@tanstack/react-query"; import { ChevronDownIcon, PlusIcon, RotateCcwIcon, Undo2Icon, XIcon } from "lucide-react"; import { type ReactNode, useCallback, useState } from "react"; import { type ProviderKind } from "@t3tools/contracts"; import { getModelOptions, normalizeModelSlug } from "@t3tools/shared/model"; import { useAppSettings } from "../appSettings"; import { getCustomModelOptionsByProvider, MAX_CUSTOM_MODEL_LENGTH, MODEL_PROVIDER_SETTINGS, resolveAppModelSelectionState, } from "../modelSelection"; 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, SelectItem, SelectPopup, SelectTrigger, SelectValue, } from "../components/ui/select"; import { SidebarTrigger } from "../components/ui/sidebar"; import { Switch } from "../components/ui/switch"; import { ProviderModelPicker } from "../components/chat/ProviderModelPicker"; import { TraitsPicker } from "../components/chat/TraitsPicker"; 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 = [ { value: "system", label: "System", description: "Match your OS appearance setting.", }, { value: "light", label: "Light", description: "Always use the light theme.", }, { value: "dark", label: "Dark", description: "Always use the dark theme.", }, ] as const; const TIMESTAMP_FORMAT_LABELS = { locale: "System default", "12-hour": "12-hour", "24-hour": "24-hour", } as const; type InstallProviderSettings = { provider: ProviderKind; title: string; binaryPlaceholder: string; binaryDescription: ReactNode; homePathKey?: "codexHomePath"; homePlaceholder?: string; homeDescription?: ReactNode; }; const INSTALL_PROVIDER_SETTINGS: readonly InstallProviderSettings[] = [ { provider: "codex", title: "Codex", 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", 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 } = 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.providers.codex.binaryPath || settings.providers.codex.homePath), claudeAgent: Boolean(settings.providers.claudeAgent.binaryPath), }); const [selectedCustomModelProvider, setSelectedCustomModelProvider] = useState("codex"); const [customModelInputByProvider, setCustomModelInputByProvider] = useState< Record >({ codex: "", claudeAgent: "", }); const [customModelErrorByProvider, setCustomModelErrorByProvider] = useState< Partial> >({}); const [showAllCustomModels, setShowAllCustomModels] = useState(false); const codexHomePath = settings.providers.codex.homePath; const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null; const availableEditors = serverConfigQuery.data?.availableEditors; const textGenerationModelSelection = resolveAppModelSelectionState(settings); const textGenProvider = textGenerationModelSelection.provider; const textGenModel = textGenerationModelSelection.model; const textGenModelOptions = textGenerationModelSelection.options; const gitModelOptionsByProvider = getCustomModelOptionsByProvider( settings, textGenProvider, textGenModel, ); const selectedCustomModelProviderSettings = MODEL_PROVIDER_SETTINGS.find( (providerSettings) => providerSettings.provider === selectedCustomModelProvider, )!; const selectedCustomModelInput = customModelInputByProvider[selectedCustomModelProvider]; const selectedCustomModelError = customModelErrorByProvider[selectedCustomModelProvider] ?? null; const totalCustomModels = settings.providers.codex.customModels.length + settings.providers.claudeAgent.customModels.length; const savedCustomModelRows = MODEL_PROVIDER_SETTINGS.flatMap((providerSettings) => settings.providers[providerSettings.provider].customModels.map((slug) => ({ key: `${providerSettings.provider}:${slug}`, provider: providerSettings.provider, providerTitle: providerSettings.title, slug, })), ); const visibleCustomModelRows = showAllCustomModels ? savedCustomModelRows : savedCustomModelRows.slice(0, 5); const isInstallSettingsDirty = settings.providers.claudeAgent.binaryPath !== defaults.providers.claudeAgent.binaryPath || settings.providers.codex.binaryPath !== defaults.providers.codex.binaryPath || settings.providers.codex.homePath !== defaults.providers.codex.homePath; const changedSettingLabels = [ ...(theme !== "system" ? ["Theme"] : []), ...(settings.timestampFormat !== defaults.timestampFormat ? ["Time format"] : []), ...(settings.diffWordWrap !== defaults.diffWordWrap ? ["Diff line wrapping"] : []), ...(settings.enableAssistantStreaming !== defaults.enableAssistantStreaming ? ["Assistant output"] : []), ...(settings.defaultThreadEnvMode !== defaults.defaultThreadEnvMode ? ["New thread mode"] : []), ...(settings.confirmThreadDelete !== defaults.confirmThreadDelete ? ["Delete confirmation"] : []), ...(JSON.stringify(settings.textGenerationModelSelection ?? null) !== JSON.stringify(defaults.textGenerationModelSelection ?? null) ? ["Git writing model"] : []), ...(settings.providers.codex.customModels.length > 0 || settings.providers.claudeAgent.customModels.length > 0 ? ["Custom models"] : []), ...(isInstallSettingsDirty ? ["Provider installs"] : []), ]; const openKeybindingsFile = useCallback(() => { if (!keybindingsConfigPath) return; setOpenKeybindingsError(null); setIsOpeningKeybindings(true); const api = ensureNativeApi(); const editor = resolveAndPersistPreferredEditor(availableEditors ?? []); if (!editor) { setOpenKeybindingsError("No available editors found."); setIsOpeningKeybindings(false); return; } void api.shell .openInEditor(keybindingsConfigPath, editor) .catch((error) => { setOpenKeybindingsError( error instanceof Error ? error.message : "Unable to open keybindings file.", ); }) .finally(() => { setIsOpeningKeybindings(false); }); }, [availableEditors, keybindingsConfigPath]); const addCustomModel = useCallback( (provider: ProviderKind) => { const customModelInput = customModelInputByProvider[provider]; const customModels = settings.providers[provider].customModels; const normalized = normalizeModelSlug(customModelInput, provider); if (!normalized) { setCustomModelErrorByProvider((existing) => ({ ...existing, [provider]: "Enter a model slug.", })); return; } if (getModelOptions(provider).some((option) => option.slug === normalized)) { setCustomModelErrorByProvider((existing) => ({ ...existing, [provider]: "That model is already built in.", })); return; } if (normalized.length > MAX_CUSTOM_MODEL_LENGTH) { setCustomModelErrorByProvider((existing) => ({ ...existing, [provider]: `Model slugs must be ${MAX_CUSTOM_MODEL_LENGTH} characters or less.`, })); return; } if (customModels.includes(normalized)) { setCustomModelErrorByProvider((existing) => ({ ...existing, [provider]: "That custom model is already saved.", })); return; } updateSettings({ providers: { ...settings.providers, [provider]: { ...settings.providers[provider], customModels: [...customModels, normalized], }, }, }); setCustomModelInputByProvider((existing) => ({ ...existing, [provider]: "", })); setCustomModelErrorByProvider((existing) => ({ ...existing, [provider]: null, })); }, [customModelInputByProvider, settings, updateSettings], ); const removeCustomModel = useCallback( (provider: ProviderKind, slug: string) => { const customModels = settings.providers[provider].customModels; updateSettings({ providers: { ...settings.providers, [provider]: { ...settings.providers[provider], customModels: customModels.filter((model) => model !== slug), }, }, }); setCustomModelErrorByProvider((existing) => ({ ...existing, [provider]: null, })); }, [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
)}
setTheme("system")} /> ) : null } control={ } /> updateSettings({ timestampFormat: defaults.timestampFormat, }) } /> ) : null } control={ } /> updateSettings({ diffWordWrap: defaults.diffWordWrap, }) } /> ) : null } control={ updateSettings({ diffWordWrap: Boolean(checked), }) } aria-label="Wrap diff lines by default" /> } /> updateSettings({ enableAssistantStreaming: defaults.enableAssistantStreaming, }) } /> ) : null } control={ updateSettings({ enableAssistantStreaming: Boolean(checked), }) } aria-label="Stream assistant messages" /> } /> updateSettings({ defaultThreadEnvMode: defaults.defaultThreadEnvMode, }) } /> ) : null } control={ } /> updateSettings({ confirmThreadDelete: defaults.confirmThreadDelete, }) } /> ) : null } control={ updateSettings({ confirmThreadDelete: Boolean(checked), }) } aria-label="Confirm thread deletion" /> } /> { updateSettings({ textGenerationModelSelection: defaults.textGenerationModelSelection, }); }} /> ) : null } control={
{ updateSettings({ textGenerationModelSelection: resolveAppModelSelectionState({ ...settings, textGenerationModelSelection: { provider, model }, }), }); }} /> {}} modelOptions={textGenModelOptions} allowPromptInjectedEffort={false} triggerVariant="outline" triggerClassName="min-w-0 max-w-none shrink-0 text-foreground/90 hover:text-foreground" onModelOptionsChange={(nextOptions) => { updateSettings({ textGenerationModelSelection: resolveAppModelSelectionState({ ...settings, textGenerationModelSelection: { provider: textGenProvider, model: textGenModel, ...(nextOptions ? { options: nextOptions } : {}), }, }), }); }} />
} /> 0 ? ( { updateSettings({ providers: { ...settings.providers, codex: { ...settings.providers.codex, customModels: defaults.providers.codex.customModels, }, claudeAgent: { ...settings.providers.claudeAgent, customModels: defaults.providers.claudeAgent.customModels, }, }, }); setCustomModelErrorByProvider({}); setShowAllCustomModels(false); }} /> ) : null } >
{ const value = event.target.value; setCustomModelInputByProvider((existing) => ({ ...existing, [selectedCustomModelProvider]: value, })); if (selectedCustomModelError) { setCustomModelErrorByProvider((existing) => ({ ...existing, [selectedCustomModelProvider]: null, })); } }} onKeyDown={(event) => { if (event.key !== "Enter") return; event.preventDefault(); addCustomModel(selectedCustomModelProvider); }} placeholder={selectedCustomModelProviderSettings.example} spellCheck={false} />
{selectedCustomModelError ? (

{selectedCustomModelError}

) : null} {totalCustomModels > 0 ? (
{visibleCustomModelRows.map((row) => (
{row.providerTitle} {row.slug}
))}
{savedCustomModelRows.length > 5 ? ( ) : null}
) : null}
{ updateSettings({ providers: { ...settings.providers, claudeAgent: { ...settings.providers.claudeAgent, binaryPath: defaults.providers.claudeAgent.binaryPath, }, codex: { ...settings.providers.codex, binaryPath: defaults.providers.codex.binaryPath, homePath: defaults.providers.codex.homePath, }, }, }); setOpenInstallProviders({ codex: false, claudeAgent: false, }); }} /> ) : null } >
{INSTALL_PROVIDER_SETTINGS.map((providerSettings) => { const isOpen = openInstallProviders[providerSettings.provider]; const isDirty = providerSettings.provider === "codex" ? settings.providers.codex.binaryPath !== defaults.providers.codex.binaryPath || settings.providers.codex.homePath !== defaults.providers.codex.homePath : settings.providers.claudeAgent.binaryPath !== defaults.providers.claudeAgent.binaryPath; const binaryPathValue = providerSettings.provider === "claudeAgent" ? settings.providers.claudeAgent.binaryPath : settings.providers.codex.binaryPath; return ( setOpenInstallProviders((existing) => ({ ...existing, [providerSettings.provider]: open, })) } >
{providerSettings.homePathKey ? ( ) : null}
); })}
{keybindingsConfigPath ?? "Resolving keybindings path..."} {openKeybindingsError ? ( {openKeybindingsError} ) : ( Opens in your preferred editor. )} } control={ } /> {APP_VERSION} } />
); } export const Route = createFileRoute("/_chat/settings")({ component: SettingsRouteView, });