import { createContext, createMemo, createSignal, onMount, useContext } from "solid-js" import type { Accessor, ParentComponent } from "solid-js" import { storage, type ConfigData } from "../lib/storage" import { ensureInstanceConfigLoaded, getInstanceConfig, updateInstanceConfig as updateInstanceData, } from "./instance-config" type DeepReadonly = T extends (...args: any[]) => unknown ? T : T extends Array ? ReadonlyArray> : T extends object ? { readonly [K in keyof T]: DeepReadonly } : T export interface ModelPreference { providerId: string modelId: string } export interface AgentModelSelections { [instanceId: string]: Record } export type DiffViewMode = "split" | "unified" export type ExpansionPreference = "expanded" | "collapsed" export interface Preferences { showThinkingBlocks: boolean lastUsedBinary?: string environmentVariables: Record modelRecents: ModelPreference[] diffViewMode: DiffViewMode toolOutputExpansion: ExpansionPreference diagnosticsExpansion: ExpansionPreference showUsageMetrics: boolean } export interface OpenCodeBinary { path: string version?: string lastUsed: number } export interface RecentFolder { path: string lastAccessed: number } export type ThemePreference = NonNullable const MAX_RECENT_FOLDERS = 20 const MAX_RECENT_MODELS = 5 const defaultPreferences: Preferences = { showThinkingBlocks: false, environmentVariables: {}, modelRecents: [], diffViewMode: "split", toolOutputExpansion: "expanded", diagnosticsExpansion: "expanded", showUsageMetrics: true, } function deepEqual(a: unknown, b: unknown): boolean { if (a === b) return true if (typeof a === "object" && a !== null && typeof b === "object" && b !== null) { try { return JSON.stringify(a) === JSON.stringify(b) } catch (error) { console.warn("Failed to compare preference values", error) } } return false } function normalizePreferences(pref?: Partial & { agentModelSelections?: unknown }): Preferences { const sanitized = pref ?? {} const environmentVariables = { ...defaultPreferences.environmentVariables, ...(sanitized.environmentVariables ?? {}), } const sourceModelRecents = sanitized.modelRecents ?? defaultPreferences.modelRecents const modelRecents = sourceModelRecents.map((item) => ({ ...item })) return { showThinkingBlocks: sanitized.showThinkingBlocks ?? defaultPreferences.showThinkingBlocks, lastUsedBinary: sanitized.lastUsedBinary ?? defaultPreferences.lastUsedBinary, environmentVariables, modelRecents, diffViewMode: sanitized.diffViewMode ?? defaultPreferences.diffViewMode, toolOutputExpansion: sanitized.toolOutputExpansion ?? defaultPreferences.toolOutputExpansion, diagnosticsExpansion: sanitized.diagnosticsExpansion ?? defaultPreferences.diagnosticsExpansion, showUsageMetrics: sanitized.showUsageMetrics ?? defaultPreferences.showUsageMetrics, } } const [internalConfig, setInternalConfig] = createSignal(buildFallbackConfig()) const config = createMemo>(() => internalConfig()) const [isConfigLoaded, setIsConfigLoaded] = createSignal(false) const preferences = createMemo(() => internalConfig().preferences) const recentFolders = createMemo(() => internalConfig().recentFolders ?? []) const opencodeBinaries = createMemo(() => internalConfig().opencodeBinaries ?? []) const themePreference = createMemo(() => internalConfig().theme ?? "dark") let loadPromise: Promise | null = null function normalizeConfig(config?: ConfigData | null): ConfigData { return { preferences: normalizePreferences(config?.preferences), recentFolders: (config?.recentFolders ?? []).map((folder) => ({ ...folder })), opencodeBinaries: (config?.opencodeBinaries ?? []).map((binary) => ({ ...binary })), theme: config?.theme ?? "dark", } } function buildFallbackConfig(): ConfigData { return normalizeConfig() } function removeLegacyAgentSelections(config?: ConfigData | null): { cleaned: ConfigData; migrated: boolean } { const migrated = Boolean((config?.preferences as { agentModelSelections?: unknown } | undefined)?.agentModelSelections) const cleanedConfig = normalizeConfig(config) return { cleaned: cleanedConfig, migrated } } async function syncConfig(source?: ConfigData): Promise { try { const loaded = source ?? (await storage.loadConfig()) const { cleaned, migrated } = removeLegacyAgentSelections(loaded) applyConfig(cleaned) if (migrated) { void storage.updateConfig(cleaned).catch((error: unknown) => { console.error("Failed to persist legacy config cleanup:", error) }) } } catch (error) { console.error("Failed to load config:", error) applyConfig(buildFallbackConfig()) } } function applyConfig(next: ConfigData) { setInternalConfig(normalizeConfig(next)) setIsConfigLoaded(true) } function cloneConfigForUpdate(): ConfigData { return normalizeConfig(internalConfig()) } function logConfigDiff(previous: ConfigData, next: ConfigData) { if (deepEqual(previous, next)) { return } const changes = diffObjects(previous, next) if (changes.length > 0) { console.debug("[Config] Changes", changes) } } function diffObjects(previous: unknown, next: unknown, path: string[] = []): string[] { if (previous === next) { return [] } if (typeof previous !== "object" || previous === null || typeof next !== "object" || next === null) { return [path.join(".")] } const prevKeys = Object.keys(previous as Record) const nextKeys = Object.keys(next as Record) const allKeys = new Set([...prevKeys, ...nextKeys]) const changes: string[] = [] for (const key of allKeys) { const childPath = [...path, key] const prevValue = (previous as Record)[key] const nextValue = (next as Record)[key] changes.push(...diffObjects(prevValue, nextValue, childPath)) } return changes } function updateConfig(mutator: (draft: ConfigData) => void): void { const previous = internalConfig() const draft = cloneConfigForUpdate() mutator(draft) logConfigDiff(previous, draft) applyConfig(draft) void persistFullConfig(draft) } async function persistFullConfig(next: ConfigData): Promise { try { await ensureConfigLoaded() await storage.updateConfig(next) } catch (error) { console.error("Failed to save config:", error) void syncConfig().catch((syncError: unknown) => { console.error("Failed to refresh config:", syncError) }) } } function setThemePreference(preference: ThemePreference): void { if (themePreference() === preference) { return } updateConfig((draft) => { draft.theme = preference }) } async function ensureConfigLoaded(): Promise { if (isConfigLoaded()) return if (!loadPromise) { loadPromise = syncConfig().finally(() => { loadPromise = null }) } await loadPromise } function buildRecentFolderList(path: string, source: RecentFolder[]): RecentFolder[] { const folders = source.filter((f) => f.path !== path) folders.unshift({ path, lastAccessed: Date.now() }) return folders.slice(0, MAX_RECENT_FOLDERS) } function buildBinaryList(path: string, version: string | undefined, source: OpenCodeBinary[]): OpenCodeBinary[] { const timestamp = Date.now() const existing = source.find((b) => b.path === path) if (existing) { const updatedEntry: OpenCodeBinary = { ...existing, lastUsed: timestamp } const remaining = source.filter((b) => b.path !== path) return [updatedEntry, ...remaining] } const nextEntry: OpenCodeBinary = version ? { path, version, lastUsed: timestamp } : { path, lastUsed: timestamp } return [nextEntry, ...source].slice(0, 10) } function updatePreferences(updates: Partial): void { const current = internalConfig().preferences const merged = normalizePreferences({ ...current, ...updates }) if (deepEqual(current, merged)) { return } updateConfig((draft) => { draft.preferences = merged }) } function setDiffViewMode(mode: DiffViewMode): void { if (preferences().diffViewMode === mode) return updatePreferences({ diffViewMode: mode }) } function setToolOutputExpansion(mode: ExpansionPreference): void { if (preferences().toolOutputExpansion === mode) return updatePreferences({ toolOutputExpansion: mode }) } function setDiagnosticsExpansion(mode: ExpansionPreference): void { if (preferences().diagnosticsExpansion === mode) return updatePreferences({ diagnosticsExpansion: mode }) } function toggleShowThinkingBlocks(): void { updatePreferences({ showThinkingBlocks: !preferences().showThinkingBlocks }) } function toggleUsageMetrics(): void { updatePreferences({ showUsageMetrics: !preferences().showUsageMetrics }) } function addRecentFolder(path: string): void { updateConfig((draft) => { draft.recentFolders = buildRecentFolderList(path, draft.recentFolders) }) } function removeRecentFolder(path: string): void { updateConfig((draft) => { draft.recentFolders = draft.recentFolders.filter((f) => f.path !== path) }) } function addOpenCodeBinary(path: string, version?: string): void { updateConfig((draft) => { draft.opencodeBinaries = buildBinaryList(path, version, draft.opencodeBinaries) }) } function removeOpenCodeBinary(path: string): void { updateConfig((draft) => { draft.opencodeBinaries = draft.opencodeBinaries.filter((b) => b.path !== path) }) } function updateLastUsedBinary(path: string): void { const target = path || preferences().lastUsedBinary || "opencode" updateConfig((draft) => { draft.preferences = normalizePreferences({ ...draft.preferences, lastUsedBinary: target }) draft.opencodeBinaries = buildBinaryList(target, undefined, draft.opencodeBinaries) }) } function recordWorkspaceLaunch(folderPath: string, binaryPath?: string): void { updateConfig((draft) => { const targetBinary = binaryPath && binaryPath.trim().length > 0 ? binaryPath : draft.preferences.lastUsedBinary || "opencode" draft.recentFolders = buildRecentFolderList(folderPath, draft.recentFolders) draft.preferences = normalizePreferences({ ...draft.preferences, lastUsedBinary: targetBinary }) draft.opencodeBinaries = buildBinaryList(targetBinary, undefined, draft.opencodeBinaries) }) } function updateEnvironmentVariables(envVars: Record): void { updatePreferences({ environmentVariables: envVars }) } function addEnvironmentVariable(key: string, value: string): void { const current = preferences().environmentVariables || {} const updated = { ...current, [key]: value } updateEnvironmentVariables(updated) } function removeEnvironmentVariable(key: string): void { const current = preferences().environmentVariables || {} const { [key]: removed, ...rest } = current updateEnvironmentVariables(rest) } function addRecentModelPreference(model: ModelPreference): void { if (!model.providerId || !model.modelId) return const recents = preferences().modelRecents ?? [] const filtered = recents.filter((item) => item.providerId !== model.providerId || item.modelId !== model.modelId) const updated = [model, ...filtered].slice(0, MAX_RECENT_MODELS) updatePreferences({ modelRecents: updated }) } async function setAgentModelPreference(instanceId: string, agent: string, model: ModelPreference): Promise { if (!instanceId || !agent || !model.providerId || !model.modelId) return await ensureInstanceConfigLoaded(instanceId) await updateInstanceData(instanceId, (draft) => { const selections = { ...(draft.agentModelSelections ?? {}) } const existing = selections[agent] if (existing && existing.providerId === model.providerId && existing.modelId === model.modelId) { return } selections[agent] = model draft.agentModelSelections = selections }) } async function getAgentModelPreference(instanceId: string, agent: string): Promise { if (!instanceId || !agent) return undefined await ensureInstanceConfigLoaded(instanceId) const selections = getInstanceConfig(instanceId).agentModelSelections ?? {} return selections[agent] } void ensureConfigLoaded().catch((error: unknown) => { console.error("Failed to initialize config:", error) }) interface ConfigContextValue { isLoaded: Accessor config: typeof config preferences: typeof preferences recentFolders: typeof recentFolders opencodeBinaries: typeof opencodeBinaries themePreference: typeof themePreference setThemePreference: typeof setThemePreference updateConfig: typeof updateConfig toggleShowThinkingBlocks: typeof toggleShowThinkingBlocks toggleUsageMetrics: typeof toggleUsageMetrics setDiffViewMode: typeof setDiffViewMode setToolOutputExpansion: typeof setToolOutputExpansion setDiagnosticsExpansion: typeof setDiagnosticsExpansion addRecentFolder: typeof addRecentFolder removeRecentFolder: typeof removeRecentFolder addOpenCodeBinary: typeof addOpenCodeBinary removeOpenCodeBinary: typeof removeOpenCodeBinary updateLastUsedBinary: typeof updateLastUsedBinary recordWorkspaceLaunch: typeof recordWorkspaceLaunch updatePreferences: typeof updatePreferences updateEnvironmentVariables: typeof updateEnvironmentVariables addEnvironmentVariable: typeof addEnvironmentVariable removeEnvironmentVariable: typeof removeEnvironmentVariable addRecentModelPreference: typeof addRecentModelPreference setAgentModelPreference: typeof setAgentModelPreference getAgentModelPreference: typeof getAgentModelPreference } const ConfigContext = createContext() const configContextValue: ConfigContextValue = { isLoaded: isConfigLoaded, config, preferences, recentFolders, opencodeBinaries, themePreference, setThemePreference, updateConfig, toggleShowThinkingBlocks, toggleUsageMetrics, setDiffViewMode, setToolOutputExpansion, setDiagnosticsExpansion, addRecentFolder, removeRecentFolder, addOpenCodeBinary, removeOpenCodeBinary, updateLastUsedBinary, recordWorkspaceLaunch, updatePreferences, updateEnvironmentVariables, addEnvironmentVariable, removeEnvironmentVariable, addRecentModelPreference, setAgentModelPreference, getAgentModelPreference, } const ConfigProvider: ParentComponent = (props) => { onMount(() => { ensureConfigLoaded().catch((error: unknown) => { console.error("Failed to initialize config:", error) }) const unsubscribe = storage.onConfigChanged((config) => { syncConfig(config).catch((error: unknown) => { console.error("Failed to refresh config:", error) }) }) return () => { unsubscribe() } }) return {props.children} } function useConfig(): ConfigContextValue { const context = useContext(ConfigContext) if (!context) { throw new Error("useConfig must be used within ConfigProvider") } return context } export { ConfigProvider, useConfig, config, preferences, updateConfig, updatePreferences, toggleShowThinkingBlocks, toggleUsageMetrics, recentFolders, addRecentFolder, removeRecentFolder, opencodeBinaries, addOpenCodeBinary, removeOpenCodeBinary, updateLastUsedBinary, updateEnvironmentVariables, addEnvironmentVariable, removeEnvironmentVariable, addRecentModelPreference, setAgentModelPreference, getAgentModelPreference, setDiffViewMode, setToolOutputExpansion, setDiagnosticsExpansion, themePreference, setThemePreference, recordWorkspaceLaunch, }