import { useCallback, useSyncExternalStore } from "react"; import { Option, Schema } from "effect"; import { type ProviderKind } from "@t3tools/contracts"; import { getModelOptions, normalizeModelSlug } from "@t3tools/shared/model"; const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1"; const MAX_CUSTOM_MODEL_COUNT = 32; export const MAX_CUSTOM_MODEL_LENGTH = 256; const BUILT_IN_MODEL_SLUGS_BY_PROVIDER: Record> = { codex: new Set(getModelOptions("codex").map((option) => option.slug)), claudeCode: new Set(getModelOptions("claudeCode").map((option) => option.slug)), cursor: new Set(getModelOptions("cursor").map((option) => option.slug)), }; const AppSettingsSchema = Schema.Struct({ codexBinaryPath: Schema.String.check(Schema.isMaxLength(4096)).pipe( Schema.withConstructorDefault(() => Option.some("")), ), codexHomePath: Schema.String.check(Schema.isMaxLength(4096)).pipe( Schema.withConstructorDefault(() => Option.some("")), ), confirmThreadDelete: Schema.Boolean.pipe(Schema.withConstructorDefault(() => Option.some(true))), enableAssistantStreaming: Schema.Boolean.pipe( Schema.withConstructorDefault(() => Option.some(false)), ), customCodexModels: Schema.Array(Schema.String).pipe( Schema.withConstructorDefault(() => Option.some([])), ), customClaudeModels: Schema.Array(Schema.String).pipe( Schema.withConstructorDefault(() => Option.some([])), ), customCursorModels: Schema.Array(Schema.String).pipe( Schema.withConstructorDefault(() => Option.some([])), ), }); export type AppSettings = typeof AppSettingsSchema.Type; export interface AppModelOption { slug: string; name: string; isCustom: boolean; } const DEFAULT_APP_SETTINGS = AppSettingsSchema.makeUnsafe({}); let listeners: Array<() => void> = []; let cachedRawSettings: string | null | undefined; let cachedSnapshot: AppSettings = DEFAULT_APP_SETTINGS; 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, "claudeCode"), customCursorModels: normalizeCustomModelSlugs(settings.customCursorModels, "cursor"), }; } 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)); 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); if (normalizedSelectedModel && !seen.has(normalizedSelectedModel)) { options.push({ slug: normalizedSelectedModel, name: normalizedSelectedModel, isCustom: true, }); } return options; } export function getSlashModelOptions( provider: ProviderKind, customModels: readonly string[], query: string, selectedModel?: string | null, ): AppModelOption[] { const normalizedQuery = query.trim().toLowerCase(); const options = getAppModelOptions(provider, customModels, selectedModel); if (!normalizedQuery) { return options; } return options.filter((option) => { const searchSlug = option.slug.toLowerCase(); const searchName = option.name.toLowerCase(); return searchSlug.includes(normalizedQuery) || searchName.includes(normalizedQuery); }); } function emitChange(): void { for (const listener of listeners) { listener(); } } function parsePersistedSettings(value: string | null): AppSettings { if (!value) { return DEFAULT_APP_SETTINGS; } try { return normalizeAppSettings(Schema.decodeSync(Schema.fromJsonString(AppSettingsSchema))(value)); } catch { return DEFAULT_APP_SETTINGS; } } export function getAppSettingsSnapshot(): AppSettings { if (typeof window === "undefined") { return DEFAULT_APP_SETTINGS; } const raw = window.localStorage.getItem(APP_SETTINGS_STORAGE_KEY); if (raw === cachedRawSettings) { return cachedSnapshot; } cachedRawSettings = raw; cachedSnapshot = parsePersistedSettings(raw); return cachedSnapshot; } function persistSettings(next: AppSettings): void { if (typeof window === "undefined") return; const raw = JSON.stringify(next); try { if (raw !== cachedRawSettings) { window.localStorage.setItem(APP_SETTINGS_STORAGE_KEY, raw); } } catch { // Best-effort persistence only. } cachedRawSettings = raw; cachedSnapshot = next; } function subscribe(listener: () => void): () => void { listeners.push(listener); const onStorage = (event: StorageEvent) => { if (event.key === APP_SETTINGS_STORAGE_KEY) { emitChange(); } }; window.addEventListener("storage", onStorage); return () => { listeners = listeners.filter((entry) => entry !== listener); window.removeEventListener("storage", onStorage); }; } export function useAppSettings() { const settings = useSyncExternalStore( subscribe, getAppSettingsSnapshot, () => DEFAULT_APP_SETTINGS, ); const updateSettings = useCallback((patch: Partial) => { const next = normalizeAppSettings( Schema.decodeSync(AppSettingsSchema)({ ...getAppSettingsSnapshot(), ...patch, }), ); persistSettings(next); emitChange(); }, []); const resetSettings = useCallback(() => { persistSettings(DEFAULT_APP_SETTINGS); emitChange(); }, []); return { settings, updateSettings, resetSettings, defaults: DEFAULT_APP_SETTINGS, } as const; }