import { writable, derived } from "svelte/store"; import { invoke } from "@tauri-apps/api/core"; import { readFile } from "@tauri-apps/plugin-fs"; export type Theme = | "dark" | "light" | "high-contrast" | "custom" | "dracula" | "catppuccin" | "nord" | "solarized" | "solarized-light" | "catppuccin-latte" | "gruvbox-light" | "rose-pine-dawn"; export type BudgetAction = "warn" | "block"; export interface CustomThemeColors { bg_primary: string | null; bg_secondary: string | null; bg_terminal: string | null; accent_primary: string | null; accent_secondary: string | null; text_primary: string | null; text_secondary: string | null; border_color: string | null; } export interface HikariConfig { model: string | null; api_key: string | null; custom_instructions: string | null; mcp_servers_json: string | null; auto_granted_tools: string[]; theme: Theme; greeting_enabled: boolean; greeting_custom_prompt: string | null; notifications_enabled: boolean; notification_volume: number; always_on_top: boolean; update_checks_enabled: boolean; character_panel_width: number | null; font_size: number; streamer_mode: boolean; streamer_hide_paths: boolean; compact_mode: boolean; profile_name: string | null; profile_avatar_path: string | null; profile_bio: string | null; custom_theme_colors: CustomThemeColors; // Budget settings budget_enabled: boolean; session_token_budget: number | null; session_cost_budget: number | null; budget_action: BudgetAction; budget_warning_threshold: number; // Discord RPC settings discord_rpc_enabled: boolean; // Thinking blocks settings show_thinking_blocks: boolean; // Worktree isolation use_worktree: boolean; // Disable 1M context window disable_1m_context: boolean; // Max output tokens for Claude Code responses max_output_tokens: number | null; // Workspaces the user has explicitly trusted trusted_workspaces: string[]; // Background image settings background_image_path: string | null; background_image_opacity: number; // Custom terminal font settings custom_font_path: string | null; custom_font_family: string | null; // Custom UI font settings custom_ui_font_path: string | null; custom_ui_font_family: string | null; // Task Loop auto-commit settings task_loop_auto_commit: boolean; task_loop_commit_prefix: string; task_loop_include_summary: boolean; // Disable cron scheduling disable_cron: boolean; // Git instructions setting include_git_instructions: boolean; // Claude.ai MCP servers setting enable_claudeai_mcp_servers: boolean; // Auto-memory directory auto_memory_directory: string | null; // Model overrides for provider-specific model IDs (AWS Bedrock, Google Vertex, etc.) model_overrides: Record | null; // Prevents skill scripts from executing shell commands (Claude Code v2.1.91+) disable_skill_shell_execution: boolean; // Bare mode — suppress UI chrome for scripted headless -p calls (v2.1.81+) bare_mode: boolean; // Show clear context dialog when accepting a plan (v2.1.81+) show_clear_context_on_plan_accept: boolean; // Custom model option env var (v2.1.81+) custom_model_option: string | null; } const defaultConfig: HikariConfig = { model: null, api_key: null, custom_instructions: null, mcp_servers_json: null, auto_granted_tools: [], theme: "dark", greeting_enabled: true, greeting_custom_prompt: null, notifications_enabled: true, notification_volume: 0.7, always_on_top: false, update_checks_enabled: true, character_panel_width: null, font_size: 14, streamer_mode: false, streamer_hide_paths: false, compact_mode: false, profile_name: null, profile_avatar_path: null, profile_bio: null, custom_theme_colors: { bg_primary: null, bg_secondary: null, bg_terminal: null, accent_primary: null, accent_secondary: null, text_primary: null, text_secondary: null, border_color: null, }, budget_enabled: false, session_token_budget: null, session_cost_budget: null, budget_action: "warn", budget_warning_threshold: 0.8, discord_rpc_enabled: true, show_thinking_blocks: true, use_worktree: false, disable_1m_context: false, max_output_tokens: null, trusted_workspaces: [], background_image_path: null, background_image_opacity: 0.3, custom_font_path: null, custom_font_family: null, custom_ui_font_path: null, custom_ui_font_family: null, task_loop_auto_commit: false, task_loop_commit_prefix: "feat", task_loop_include_summary: false, disable_cron: false, include_git_instructions: true, enable_claudeai_mcp_servers: true, auto_memory_directory: null, model_overrides: null, disable_skill_shell_execution: false, bare_mode: false, show_clear_context_on_plan_accept: true, custom_model_option: null, }; function createConfigStore() { const config = writable(defaultConfig); const isLoading = writable(true); const isSidebarOpen = writable(false); const saveError = writable(null); // Internal function to get current config synchronously function getCurrentConfig(): HikariConfig { let currentConfig: HikariConfig = defaultConfig; const unsubscribe = config.subscribe((c) => (currentConfig = c)); unsubscribe(); return currentConfig; } async function loadConfig() { isLoading.set(true); try { const savedConfig = await invoke("get_config"); config.set(savedConfig); } catch (error) { console.error("Failed to load config:", error); config.set(defaultConfig); } finally { isLoading.set(false); } } async function saveConfig(newConfig: HikariConfig) { saveError.set(null); try { await invoke("save_config", { config: newConfig }); config.set(newConfig); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); saveError.set(errorMessage); console.error("Failed to save config:", error); throw error; } } async function updateConfig(updates: Partial) { const currentConfig = getCurrentConfig(); const newConfig = { ...currentConfig, ...updates }; await saveConfig(newConfig); } return { config: { subscribe: config.subscribe }, isLoading: { subscribe: isLoading.subscribe }, isSidebarOpen: { subscribe: isSidebarOpen.subscribe }, saveError: { subscribe: saveError.subscribe }, loadConfig, saveConfig, updateConfig, openSidebar: () => isSidebarOpen.set(true), closeSidebar: () => isSidebarOpen.set(false), toggleSidebar: () => isSidebarOpen.update((open) => !open), setTheme: async (theme: Theme, customColors?: CustomThemeColors) => { const updates: Partial = { theme }; if (customColors) { updates.custom_theme_colors = customColors; } await updateConfig(updates); const currentConfig = getCurrentConfig(); applyTheme(theme, currentConfig.custom_theme_colors); }, setCustomThemeColors: async (colors: CustomThemeColors) => { await updateConfig({ custom_theme_colors: colors }); const currentConfig = getCurrentConfig(); if (currentConfig.theme === "custom") { applyCustomThemeColors(colors); } }, setFontSize: async (size: number) => { const clampedSize = Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, size)); await updateConfig({ font_size: clampedSize }); applyFontSize(clampedSize); }, increaseFontSize: async () => { const currentConfig = getCurrentConfig(); const newSize = Math.min(MAX_FONT_SIZE, currentConfig.font_size + 2); await updateConfig({ font_size: newSize }); applyFontSize(newSize); }, decreaseFontSize: async () => { const currentConfig = getCurrentConfig(); const newSize = Math.max(MIN_FONT_SIZE, currentConfig.font_size - 2); await updateConfig({ font_size: newSize }); applyFontSize(newSize); }, resetFontSize: async () => { await updateConfig({ font_size: DEFAULT_FONT_SIZE }); applyFontSize(DEFAULT_FONT_SIZE); }, addAutoGrantedTool: async (tool: string) => { const currentConfig = getCurrentConfig(); if (!currentConfig.auto_granted_tools.includes(tool)) { const newTools = [...currentConfig.auto_granted_tools, tool]; await updateConfig({ auto_granted_tools: newTools }); } }, removeAutoGrantedTool: async (tool: string) => { const currentConfig = getCurrentConfig(); const newTools = currentConfig.auto_granted_tools.filter((t) => t !== tool); await updateConfig({ auto_granted_tools: newTools }); }, getConfig: (): HikariConfig => { return getCurrentConfig(); }, toggleStreamerMode: async () => { const currentConfig = getCurrentConfig(); await updateConfig({ streamer_mode: !currentConfig.streamer_mode }); }, toggleCompactMode: async () => { const currentConfig = getCurrentConfig(); await updateConfig({ compact_mode: !currentConfig.compact_mode }); }, setCompactMode: async (enabled: boolean) => { await updateConfig({ compact_mode: enabled }); }, setCustomFont: async (path: string | null, family: string | null) => { await updateConfig({ custom_font_path: path, custom_font_family: family }); await applyCustomFont(path, family); }, setCustomUiFont: async (path: string | null, family: string | null) => { await updateConfig({ custom_ui_font_path: path, custom_ui_font_family: family }); await applyCustomUiFont(path, family); }, }; } export function applyTheme(theme: Theme, customColors?: CustomThemeColors) { if (typeof document !== "undefined") { // For custom theme, we use dark as the base and override with custom colors document.documentElement.setAttribute("data-theme", theme === "custom" ? "dark" : theme); // Clear any previously applied custom colors clearCustomThemeColors(); // Apply custom colors if theme is custom if (theme === "custom" && customColors) { applyCustomThemeColors(customColors); } } } export function applyCustomThemeColors(colors: CustomThemeColors) { if (typeof document === "undefined") return; const root = document.documentElement; if (colors.bg_primary) root.style.setProperty("--bg-primary", colors.bg_primary); if (colors.bg_secondary) root.style.setProperty("--bg-secondary", colors.bg_secondary); if (colors.bg_terminal) root.style.setProperty("--bg-terminal", colors.bg_terminal); if (colors.accent_primary) root.style.setProperty("--accent-primary", colors.accent_primary); if (colors.accent_secondary) root.style.setProperty("--accent-secondary", colors.accent_secondary); if (colors.text_primary) root.style.setProperty("--text-primary", colors.text_primary); if (colors.text_secondary) root.style.setProperty("--text-secondary", colors.text_secondary); if (colors.border_color) root.style.setProperty("--border-color", colors.border_color); } export function clearCustomThemeColors() { if (typeof document === "undefined") return; const root = document.documentElement; const customProperties = [ "--bg-primary", "--bg-secondary", "--bg-terminal", "--accent-primary", "--accent-secondary", "--text-primary", "--text-secondary", "--border-color", ]; customProperties.forEach((prop) => root.style.removeProperty(prop)); } const MIN_FONT_SIZE = 10; const MAX_FONT_SIZE = 24; const DEFAULT_FONT_SIZE = 14; export function applyFontSize(size: number) { if (typeof document !== "undefined") { const clampedSize = Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, size)); document.documentElement.style.setProperty("--terminal-font-size", `${clampedSize}px`); } } export function clampFontSize(size: number): number { return Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, size)); } const DIRECT_FONT_EXTENSIONS = new Set(["woff", "woff2", "ttf", "otf", "eot"]); const FONT_MIME_MAP: Record = { woff: "font/woff", woff2: "font/woff2", ttf: "font/ttf", otf: "font/otf", eot: "application/vnd.ms-fontobject", }; async function applyFontFromSource(path: string, family: string, styleId: string): Promise { const style = document.createElement("style"); style.id = styleId; if (path.startsWith("http://") || path.startsWith("https://")) { const ext = path.split(".").pop()?.toLowerCase() ?? ""; if (DIRECT_FONT_EXTENSIONS.has(ext)) { style.textContent = `@font-face { font-family: '${family}'; src: url('${path}'); }`; } else { style.textContent = `@import url('${path}');`; } } else { const data = await readFile(path); const chunks: string[] = []; const chunkSize = 8192; for (let i = 0; i < data.length; i += chunkSize) { chunks.push(String.fromCharCode(...data.slice(i, i + chunkSize))); } const ext = path.split(".").pop()?.toLowerCase() ?? "ttf"; const mime = FONT_MIME_MAP[ext] ?? "font/ttf"; const dataUrl = `data:${mime};base64,${btoa(chunks.join(""))}`; style.textContent = `@font-face { font-family: '${family}'; src: url('${dataUrl}'); }`; } document.head.appendChild(style); } export async function applyCustomFont(path: string | null, family: string | null): Promise { if (typeof document === "undefined") return; const styleId = "hikari-custom-font"; const cssVar = "--terminal-font-family"; const fallbackFamily = "HikariCustomFont"; document.getElementById(styleId)?.remove(); const trimmedPath = path?.trim() ?? ""; const trimmedFamily = family?.trim() ?? ""; if (!trimmedPath && !trimmedFamily) { document.documentElement.style.removeProperty(cssVar); return; } if (trimmedPath) { await applyFontFromSource(trimmedPath, trimmedFamily || fallbackFamily, styleId); } if (trimmedFamily) { document.documentElement.style.setProperty(cssVar, `'${trimmedFamily}', monospace`); } } export async function applyCustomUiFont(path: string | null, family: string | null): Promise { if (typeof document === "undefined") return; const styleId = "hikari-custom-ui-font"; const cssVar = "--ui-font-family"; const fallbackFamily = "HikariCustomUiFont"; document.getElementById(styleId)?.remove(); const trimmedPath = path?.trim() ?? ""; const trimmedFamily = family?.trim() ?? ""; if (!trimmedPath && !trimmedFamily) { document.documentElement.style.removeProperty(cssVar); document.body?.style.removeProperty("font-family"); return; } const effectiveFamily = trimmedFamily || fallbackFamily; if (trimmedPath) { await applyFontFromSource(trimmedPath, effectiveFamily, styleId); } const fontValue = `'${effectiveFamily}', sans-serif`; document.documentElement.style.setProperty(cssVar, fontValue); document.body?.style.setProperty("font-family", fontValue); } export { MIN_FONT_SIZE, MAX_FONT_SIZE, DEFAULT_FONT_SIZE }; export const configStore = createConfigStore(); export const isDarkTheme = derived(configStore.config, ($config) => $config.theme === "dark"); export const isStreamerMode = derived(configStore.config, ($config) => $config.streamer_mode); export const isCompactMode = derived(configStore.config, ($config) => $config.compact_mode); export const shouldHidePaths = derived( configStore.config, ($config) => $config.streamer_mode && $config.streamer_hide_paths ); export const showThinkingBlocks = derived( configStore.config, ($config) => $config.show_thinking_blocks ); /** * Masks file paths in text when streamer mode with hide paths is enabled. * Replaces username portion of paths with asterisks. */ export function maskPaths(text: string, hidePaths: boolean): string { if (!hidePaths) return text; // Match Unix paths like /home/username/... or /Users/username/... // and Windows paths like C:\Users\username\... return text .replace(/\/home\/([^/\s]+)/g, "/home/****") .replace(/\/Users\/([^/\s]+)/g, "/Users/****") .replace(/C:\\Users\\([^\\\s]+)/gi, "C:\\Users\\****") .replace(/~\//g, "****/"); }