import { emit, listen, type UnlistenFn } from "@tauri-apps/api/event"; import { LazyStore } from "@tauri-apps/plugin-store"; import { DEFAULT_AUTOCOMPLETE_MODEL, DEFAULT_MODEL_ID, LMSTUDIO_DEFAULT_BASE_URL, OPENAI_COMPATIBLE_DEFAULT_BASE_URL, type AutocompleteProviderId, type ModelId, } from "@/modules/ai/config"; import type { KeyBinding, ShortcutId } from "@/modules/shortcuts/shortcuts"; export type ThemePref = "system" | "light" | "dark"; export const EDITOR_THEMES = [ "atomone", "aura", "copilot", "github-dark", "github-light", "nord", "tokyo-night", "xcode-dark", "xcode-light", ] as const; export type EditorThemeId = (typeof EDITOR_THEMES)[number]; export const EDITOR_THEME_LABELS: Record = { atomone: "Atom One", aura: "Aura", copilot: "Copilot", "github-dark": "GitHub Dark", "github-light": "GitHub Light", nord: "Nord", "tokyo-night": "Tokyo Night", "xcode-dark": "Xcode Dark", "xcode-light": "Xcode Light", }; export type Preferences = { theme: ThemePref; defaultModelId: ModelId; editorTheme: EditorThemeId; customInstructions: string; autostart: boolean; restoreWindowState: boolean; autocompleteEnabled: boolean; autocompleteProvider: AutocompleteProviderId; autocompleteModelId: string; lmstudioBaseURL: string; lmstudioModelId: string; openaiCompatibleBaseURL: string; openaiCompatibleModelId: string; favoriteModelIds: string[]; recentModelIds: string[]; vimMode: boolean; showHidden: boolean; terminalWebglEnabled: boolean; terminalFontSize: number; shortcuts: Record; }; const STORE_PATH = "terax-settings.json"; const KEY_THEME = "theme"; const KEY_DEFAULT_MODEL = "defaultModelId"; const KEY_EDITOR_THEME = "editorTheme"; const KEY_CUSTOM_INSTRUCTIONS = "customInstructions"; const KEY_AUTOSTART = "autostart"; const KEY_RESTORE_WINDOW = "restoreWindowState"; const KEY_AUTOCOMPLETE_ENABLED = "autocompleteEnabled"; const KEY_AUTOCOMPLETE_PROVIDER = "autocompleteProvider"; const KEY_AUTOCOMPLETE_MODEL = "autocompleteModelId"; const KEY_LMSTUDIO_BASE_URL = "lmstudioBaseURL"; const KEY_LMSTUDIO_MODEL_ID = "lmstudioModelId"; const KEY_OPENAI_COMPAT_BASE_URL = "openaiCompatibleBaseURL"; const KEY_OPENAI_COMPAT_MODEL_ID = "openaiCompatibleModelId"; const KEY_FAVORITE_MODELS = "favoriteModelIds"; const KEY_RECENT_MODELS = "recentModelIds"; const KEY_VIM_MODE = "vimMode"; const KEY_SHOW_HIDDEN = "showHidden"; const LEGACY_KEY_SHOW_HIDDEN_DIRS = "showHiddenDirectories"; const KEY_TERMINAL_WEBGL_ENABLED = "terminalWebglEnabled"; const KEY_TERMINAL_FONT_SIZE = "terminalFontSize"; const KEY_SHORTCUTS = "shortcuts"; export const TERMINAL_FONT_SIZE_DEFAULT = 14; export const TERMINAL_FONT_SIZE_MIN = 8; export const TERMINAL_FONT_SIZE_MAX = 32; export const TERMINAL_FONT_SIZES = [ 10, 12, 13, 14, 15, 16, 18, 20, 22, 24, ] as const; export const DEFAULT_PREFERENCES: Preferences = { theme: "system", defaultModelId: DEFAULT_MODEL_ID, editorTheme: "atomone", customInstructions: "", autostart: false, restoreWindowState: true, autocompleteEnabled: false, autocompleteProvider: "cerebras", autocompleteModelId: DEFAULT_AUTOCOMPLETE_MODEL.cerebras ?? "", lmstudioBaseURL: LMSTUDIO_DEFAULT_BASE_URL, lmstudioModelId: "", openaiCompatibleBaseURL: OPENAI_COMPATIBLE_DEFAULT_BASE_URL, openaiCompatibleModelId: "", favoriteModelIds: [], recentModelIds: [], vimMode: false, showHidden: false, terminalWebglEnabled: true, terminalFontSize: TERMINAL_FONT_SIZE_DEFAULT, shortcuts: {} as Record, }; const store = new LazyStore(STORE_PATH, { defaults: {}, autoSave: 200 }); // LazyStore.onChange only fires within the writing process. The settings // page lives in a separate webview, so writes there never reach the main // window's subscribers. Mirror every setter through a Tauri event so any // window can listen. const PREFS_CHANGED_EVENT = "terax://prefs-changed"; async function writePref(key: string, value: T): Promise { await store.set(key, value); await store.save(); await emit(PREFS_CHANGED_EVENT, { key, value }); } export async function loadPreferences(): Promise { // Single IPC roundtrip — fetching keys individually fans out to one // `plugin:store|get` per setting and is the dominant boot cost. const entries = await store.entries(); const map = new Map(entries); const get = (k: string): T | undefined => map.get(k) as T | undefined; return { theme: get(KEY_THEME) ?? DEFAULT_PREFERENCES.theme, defaultModelId: get(KEY_DEFAULT_MODEL) ?? DEFAULT_PREFERENCES.defaultModelId, editorTheme: get(KEY_EDITOR_THEME) ?? DEFAULT_PREFERENCES.editorTheme, customInstructions: get(KEY_CUSTOM_INSTRUCTIONS) ?? DEFAULT_PREFERENCES.customInstructions, autostart: get(KEY_AUTOSTART) ?? DEFAULT_PREFERENCES.autostart, restoreWindowState: get(KEY_RESTORE_WINDOW) ?? DEFAULT_PREFERENCES.restoreWindowState, autocompleteEnabled: get(KEY_AUTOCOMPLETE_ENABLED) ?? DEFAULT_PREFERENCES.autocompleteEnabled, autocompleteProvider: get(KEY_AUTOCOMPLETE_PROVIDER) ?? DEFAULT_PREFERENCES.autocompleteProvider, autocompleteModelId: get(KEY_AUTOCOMPLETE_MODEL) ?? DEFAULT_PREFERENCES.autocompleteModelId, lmstudioBaseURL: get(KEY_LMSTUDIO_BASE_URL) ?? DEFAULT_PREFERENCES.lmstudioBaseURL, lmstudioModelId: get(KEY_LMSTUDIO_MODEL_ID) ?? DEFAULT_PREFERENCES.lmstudioModelId, openaiCompatibleBaseURL: get(KEY_OPENAI_COMPAT_BASE_URL) ?? DEFAULT_PREFERENCES.openaiCompatibleBaseURL, openaiCompatibleModelId: get(KEY_OPENAI_COMPAT_MODEL_ID) ?? DEFAULT_PREFERENCES.openaiCompatibleModelId, favoriteModelIds: get(KEY_FAVORITE_MODELS) ?? DEFAULT_PREFERENCES.favoriteModelIds, recentModelIds: get(KEY_RECENT_MODELS) ?? DEFAULT_PREFERENCES.recentModelIds, vimMode: get(KEY_VIM_MODE) ?? DEFAULT_PREFERENCES.vimMode, showHidden: get(KEY_SHOW_HIDDEN) ?? get(LEGACY_KEY_SHOW_HIDDEN_DIRS) ?? DEFAULT_PREFERENCES.showHidden, terminalWebglEnabled: get(KEY_TERMINAL_WEBGL_ENABLED) ?? DEFAULT_PREFERENCES.terminalWebglEnabled, terminalFontSize: get(KEY_TERMINAL_FONT_SIZE) ?? DEFAULT_PREFERENCES.terminalFontSize, shortcuts: get>(KEY_SHORTCUTS) ?? DEFAULT_PREFERENCES.shortcuts, }; } export async function setTheme(value: ThemePref): Promise { await writePref(KEY_THEME, value); } export async function setDefaultModel(value: ModelId): Promise { await writePref(KEY_DEFAULT_MODEL, value); } export async function setEditorTheme(value: EditorThemeId): Promise { await writePref(KEY_EDITOR_THEME, value); } export async function setCustomInstructions(value: string): Promise { await writePref(KEY_CUSTOM_INSTRUCTIONS, value); } export async function setAutostart(value: boolean): Promise { await writePref(KEY_AUTOSTART, value); } export async function setRestoreWindowState(value: boolean): Promise { await writePref(KEY_RESTORE_WINDOW, value); } export async function setAutocompleteEnabled(value: boolean): Promise { await writePref(KEY_AUTOCOMPLETE_ENABLED, value); } export async function setAutocompleteProvider( value: AutocompleteProviderId ): Promise { await writePref(KEY_AUTOCOMPLETE_PROVIDER, value); } export async function setAutocompleteModelId(value: string): Promise { await writePref(KEY_AUTOCOMPLETE_MODEL, value); } export async function setLmstudioBaseURL(value: string): Promise { await writePref(KEY_LMSTUDIO_BASE_URL, value); } export async function setLmstudioModelId(value: string): Promise { await writePref(KEY_LMSTUDIO_MODEL_ID, value); } export async function setOpenaiCompatibleBaseURL(value: string): Promise { await writePref(KEY_OPENAI_COMPAT_BASE_URL, value); } export async function setOpenaiCompatibleModelId(value: string): Promise { await writePref(KEY_OPENAI_COMPAT_MODEL_ID, value); } export async function setFavoriteModelIds(value: string[]): Promise { await writePref(KEY_FAVORITE_MODELS, value); } export async function setRecentModelIds(value: string[]): Promise { await writePref(KEY_RECENT_MODELS, value); } export async function setVimMode(value: boolean): Promise { await writePref(KEY_VIM_MODE, value); } export async function setShowHidden(value: boolean): Promise { await writePref(KEY_SHOW_HIDDEN, value); } export async function setTerminalWebglEnabled(value: boolean): Promise { await writePref(KEY_TERMINAL_WEBGL_ENABLED, value); } export async function setTerminalFontSize(value: number): Promise { const clamped = Number.isFinite(value) ? Math.min( TERMINAL_FONT_SIZE_MAX, Math.max(TERMINAL_FONT_SIZE_MIN, Math.round(value)), ) : TERMINAL_FONT_SIZE_DEFAULT; await writePref(KEY_TERMINAL_FONT_SIZE, clamped); } export async function setShortcuts( value: Record | {} ): Promise { await store.set(KEY_SHORTCUTS, value); await store.save(); } export async function resetShortcuts(): Promise { await store.set(KEY_SHORTCUTS, DEFAULT_PREFERENCES.shortcuts); await store.save(); } export type PrefKey = keyof Preferences; /** Subscribe to changes from any window (settings → main). */ export async function onPreferencesChange( cb: (key: PrefKey, value: unknown) => void, ): Promise { const map: Record = { [KEY_THEME]: "theme", [KEY_DEFAULT_MODEL]: "defaultModelId", [KEY_EDITOR_THEME]: "editorTheme", [KEY_CUSTOM_INSTRUCTIONS]: "customInstructions", [KEY_AUTOSTART]: "autostart", [KEY_RESTORE_WINDOW]: "restoreWindowState", [KEY_AUTOCOMPLETE_ENABLED]: "autocompleteEnabled", [KEY_AUTOCOMPLETE_PROVIDER]: "autocompleteProvider", [KEY_AUTOCOMPLETE_MODEL]: "autocompleteModelId", [KEY_LMSTUDIO_BASE_URL]: "lmstudioBaseURL", [KEY_LMSTUDIO_MODEL_ID]: "lmstudioModelId", [KEY_OPENAI_COMPAT_BASE_URL]: "openaiCompatibleBaseURL", [KEY_OPENAI_COMPAT_MODEL_ID]: "openaiCompatibleModelId", [KEY_FAVORITE_MODELS]: "favoriteModelIds", [KEY_RECENT_MODELS]: "recentModelIds", [KEY_VIM_MODE]: "vimMode", [KEY_SHOW_HIDDEN]: "showHidden", [KEY_TERMINAL_WEBGL_ENABLED]: "terminalWebglEnabled", [KEY_TERMINAL_FONT_SIZE]: "terminalFontSize", [KEY_SHORTCUTS]: "shortcuts", }; // Same-process writes still fire onChange immediately; cross-window writes // arrive via the Tauri event emitted by writePref(). const unsubLocal = await store.onChange((key, value) => { const mapped = map[key]; if (mapped) cb(mapped, value); }); const unsubEvent = await listen<{ key: string; value: unknown }>( PREFS_CHANGED_EVENT, (e) => { const mapped = map[e.payload.key]; if (mapped) cb(mapped, e.payload.value); }, ); return () => { unsubLocal(); unsubEvent(); }; } // API key changes are stored in OS keychain (not the prefs store), // so we broadcast via a Tauri event for cross-window listeners. const KEYS_CHANGED_EVENT = "terax://ai-keys-changed"; export async function emitKeysChanged(): Promise { await emit(KEYS_CHANGED_EVENT); } export function onKeysChanged(cb: () => void): Promise { return listen(KEYS_CHANGED_EVENT, () => cb()); }