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 (
);
}
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 && (
)}
{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
}
>
{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,
});