import { useCallback } from "react"; import { Option, Schema } from "effect"; import { TrimmedNonEmptyString, type ProviderKind } from "@t3tools/contracts"; import { getDefaultModel, getModelOptions, normalizeModelSlug, resolveSelectableModel, } from "@t3tools/shared/model"; import { useLocalStorage } from "./hooks/useLocalStorage"; import { EnvMode } from "./components/BranchToolbar.logic"; const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1"; const MAX_CUSTOM_MODEL_COUNT = 32; export const MAX_CUSTOM_MODEL_LENGTH = 256; export const TimestampFormat = Schema.Literals(["locale", "12-hour", "24-hour"]); export type TimestampFormat = typeof TimestampFormat.Type; export const DEFAULT_TIMESTAMP_FORMAT: TimestampFormat = "locale"; type CustomModelSettingsKey = "customCodexModels" | "customClaudeModels"; export type ProviderCustomModelConfig = { provider: ProviderKind; settingsKey: CustomModelSettingsKey; defaultSettingsKey: CustomModelSettingsKey; title: string; description: string; placeholder: string; example: string; }; const BUILT_IN_MODEL_SLUGS_BY_PROVIDER: Record> = { codex: new Set(getModelOptions("codex").map((option) => option.slug)), claudeAgent: new Set(getModelOptions("claudeAgent").map((option) => option.slug)), }; const withDefaults = < S extends Schema.Top & Schema.WithoutConstructorDefault, D extends S["~type.make.in"] & S["Encoded"], >( fallback: () => D, ) => (schema: S) => schema.pipe( Schema.withConstructorDefault(() => Option.some(fallback())), Schema.withDecodingDefault(() => fallback()), ); export const AppSettingsSchema = Schema.Struct({ codexBinaryPath: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")), codexHomePath: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")), newProjectBasePath: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")), defaultThreadEnvMode: EnvMode.pipe(withDefaults(() => "local" as const satisfies EnvMode)), confirmThreadDelete: Schema.Boolean.pipe(withDefaults(() => true)), enableAssistantStreaming: Schema.Boolean.pipe(withDefaults(() => false)), timestampFormat: TimestampFormat.pipe(withDefaults(() => DEFAULT_TIMESTAMP_FORMAT)), customCodexModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])), customClaudeModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])), textGenerationModel: Schema.optional(TrimmedNonEmptyString), }); export type AppSettings = typeof AppSettingsSchema.Type; export interface AppModelOption { slug: string; name: string; isCustom: boolean; } const DEFAULT_APP_SETTINGS = AppSettingsSchema.makeUnsafe({}); const PROVIDER_CUSTOM_MODEL_CONFIG: Record = { codex: { provider: "codex", settingsKey: "customCodexModels", defaultSettingsKey: "customCodexModels", title: "Codex", description: "Save additional Codex model slugs for the picker and `/model` command.", placeholder: "your-codex-model-slug", example: "gpt-6.7-codex-ultra-preview", }, claudeAgent: { provider: "claudeAgent", settingsKey: "customClaudeModels", defaultSettingsKey: "customClaudeModels", title: "Claude", description: "Save additional Claude model slugs for the picker and `/model` command.", placeholder: "your-claude-model-slug", example: "claude-sonnet-5-0", }, }; export const MODEL_PROVIDER_SETTINGS = Object.values(PROVIDER_CUSTOM_MODEL_CONFIG); export function normalizeCustomModelSlugs( models: Iterable, provider: ProviderKind = "codex", ): string[] { const normalizedModels: string[] = []; const seen = new Set(); const builtInModelSlugs = BUILT_IN_MODEL_SLUGS_BY_PROVIDER[provider]; for (const candidate of models) { const normalized = normalizeModelSlug(candidate, provider); if ( !normalized || normalized.length > MAX_CUSTOM_MODEL_LENGTH || builtInModelSlugs.has(normalized) || seen.has(normalized) ) { continue; } seen.add(normalized); normalizedModels.push(normalized); if (normalizedModels.length >= MAX_CUSTOM_MODEL_COUNT) { break; } } return normalizedModels; } function normalizeAppSettings(settings: AppSettings): AppSettings { return { ...settings, customCodexModels: normalizeCustomModelSlugs(settings.customCodexModels, "codex"), customClaudeModels: normalizeCustomModelSlugs(settings.customClaudeModels, "claudeAgent"), }; } export function getCustomModelsForProvider( settings: Pick, provider: ProviderKind, ): readonly string[] { return settings[PROVIDER_CUSTOM_MODEL_CONFIG[provider].settingsKey]; } export function getDefaultCustomModelsForProvider( defaults: Pick, provider: ProviderKind, ): readonly string[] { return defaults[PROVIDER_CUSTOM_MODEL_CONFIG[provider].defaultSettingsKey]; } export function patchCustomModels( provider: ProviderKind, models: string[], ): Partial> { return { [PROVIDER_CUSTOM_MODEL_CONFIG[provider].settingsKey]: models, }; } export function getCustomModelsByProvider( settings: Pick, ): Record { return { codex: getCustomModelsForProvider(settings, "codex"), claudeAgent: getCustomModelsForProvider(settings, "claudeAgent"), }; } export function getAppModelOptions( provider: ProviderKind, customModels: readonly string[], selectedModel?: string | null, ): AppModelOption[] { const options: AppModelOption[] = getModelOptions(provider).map(({ slug, name }) => ({ slug, name, isCustom: false, })); const seen = new Set(options.map((option) => option.slug)); const trimmedSelectedModel = selectedModel?.trim().toLowerCase(); for (const slug of normalizeCustomModelSlugs(customModels, provider)) { if (seen.has(slug)) { continue; } seen.add(slug); options.push({ slug, name: slug, isCustom: true, }); } const normalizedSelectedModel = normalizeModelSlug(selectedModel, provider); const selectedModelMatchesExistingName = typeof trimmedSelectedModel === "string" && options.some((option) => option.name.toLowerCase() === trimmedSelectedModel); if ( normalizedSelectedModel && !seen.has(normalizedSelectedModel) && !selectedModelMatchesExistingName ) { options.push({ slug: normalizedSelectedModel, name: normalizedSelectedModel, isCustom: true, }); } return options; } export function resolveAppModelSelection( provider: ProviderKind, customModels: Record, selectedModel: string | null | undefined, ): string { const customModelsForProvider = customModels[provider]; const options = getAppModelOptions(provider, customModelsForProvider, selectedModel); return resolveSelectableModel(provider, selectedModel, options) ?? getDefaultModel(provider); } export function getCustomModelOptionsByProvider( settings: Pick, ): Record> { const customModelsByProvider = getCustomModelsByProvider(settings); return { codex: getAppModelOptions("codex", customModelsByProvider.codex), claudeAgent: getAppModelOptions("claudeAgent", customModelsByProvider.claudeAgent), }; } export function useAppSettings() { const [settings, setSettings] = useLocalStorage( APP_SETTINGS_STORAGE_KEY, DEFAULT_APP_SETTINGS, AppSettingsSchema, ); const updateSettings = useCallback( (patch: Partial) => { setSettings((prev) => normalizeAppSettings({ ...prev, ...patch })); }, [setSettings], ); const resetSettings = useCallback(() => { setSettings(DEFAULT_APP_SETTINGS); }, [setSettings]); return { settings, updateSettings, resetSettings, defaults: DEFAULT_APP_SETTINGS, } as const; }