import { existsSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, isAbsolute, join, resolve } from "node:path"; import { cwd } from "node:process"; import * as toml from "@iarna/toml"; import type { AppConfig } from "@openhandoff/shared"; import opencodeThemePackJson from "./themes/opencode-pack.json" with { type: "json" }; export type ThemeMode = "dark" | "light"; export interface TuiTheme { background: string; text: string; muted: string; header: string; status: string; highlightBg: string; highlightFg: string; selectionBorder: string; success: string; warning: string; error: string; info: string; diffAdd: string; diffDel: string; diffSep: string; agentRunning: string; agentIdle: string; agentNone: string; agentError: string; prUnpushed: string; author: string; ciRunning: string; ciPass: string; ciFail: string; ciNone: string; reviewApproved: string; reviewChanges: string; reviewPending: string; reviewNone: string; } export interface TuiThemeResolution { theme: TuiTheme; name: string; source: string; mode: ThemeMode; } interface ThemeCandidate { theme: TuiTheme; name: string; } type JsonObject = Record; type ConfigLike = AppConfig & { theme?: string }; const DEFAULT_THEME: TuiTheme = { background: "#282828", text: "#ffffff", muted: "#6b7280", header: "#6b7280", status: "#6b7280", highlightBg: "#282828", highlightFg: "#ffffff", selectionBorder: "#d946ef", success: "#22c55e", warning: "#eab308", error: "#ef4444", info: "#22d3ee", diffAdd: "#22c55e", diffDel: "#ef4444", diffSep: "#6b7280", agentRunning: "#22c55e", agentIdle: "#eab308", agentNone: "#6b7280", agentError: "#ef4444", prUnpushed: "#eab308", author: "#22d3ee", ciRunning: "#eab308", ciPass: "#22c55e", ciFail: "#ef4444", ciNone: "#6b7280", reviewApproved: "#22c55e", reviewChanges: "#ef4444", reviewPending: "#eab308", reviewNone: "#6b7280", }; const OPENCODE_THEME_PACK = opencodeThemePackJson as Record; export function resolveTuiTheme(config: AppConfig, baseDir = cwd()): TuiThemeResolution { const mode = opencodeStateThemeMode() ?? "dark"; const configWithTheme = config as ConfigLike; const override = typeof configWithTheme.theme === "string" ? configWithTheme.theme.trim() : ""; if (override) { const candidate = loadFromSpec(override, [], mode, baseDir); if (candidate) { return { theme: candidate.theme, name: candidate.name, source: "openhandoff config", mode, }; } } const fromConfig = loadOpencodeThemeFromConfig(mode, baseDir); if (fromConfig) { return fromConfig; } const fromState = loadOpencodeThemeFromState(mode, baseDir); if (fromState) { return fromState; } return { theme: DEFAULT_THEME, name: "opencode-default", source: "default", mode, }; } function loadOpencodeThemeFromConfig(mode: ThemeMode, baseDir: string): TuiThemeResolution | null { for (const path of opencodeConfigPaths(baseDir)) { if (!existsSync(path)) { continue; } const value = readJsonWithComments(path); if (!value) { continue; } const themeValue = findOpencodeThemeValue(value); if (themeValue === undefined) { continue; } const candidate = themeFromOpencodeValue(themeValue, opencodeThemeDirs(dirname(path), baseDir), mode, baseDir); if (!candidate) { continue; } return { theme: candidate.theme, name: candidate.name, source: `opencode config (${path})`, mode, }; } return null; } function loadOpencodeThemeFromState(mode: ThemeMode, baseDir: string): TuiThemeResolution | null { const path = opencodeStatePath(); if (!path || !existsSync(path)) { return null; } const value = readJsonWithComments(path); if (!isObject(value)) { return null; } const spec = value.theme; if (typeof spec !== "string" || !spec.trim()) { return null; } const candidate = loadFromSpec(spec.trim(), opencodeThemeDirs(undefined, baseDir), mode, baseDir); if (!candidate) { return null; } return { theme: candidate.theme, name: candidate.name, source: `opencode state (${path})`, mode, }; } function loadFromSpec(spec: string, searchDirs: string[], mode: ThemeMode, baseDir: string): ThemeCandidate | null { if (isDefaultThemeName(spec)) { return { theme: DEFAULT_THEME, name: "opencode-default", }; } if (isPathLike(spec)) { const resolved = resolvePath(spec, baseDir); if (existsSync(resolved)) { const candidate = loadThemeFromPath(resolved, mode); if (candidate) { return candidate; } } } for (const dir of searchDirs) { for (const ext of ["json", "toml"]) { const path = join(dir, `${spec}.${ext}`); if (!existsSync(path)) { continue; } const candidate = loadThemeFromPath(path, mode); if (candidate) { return candidate; } } } const builtIn = OPENCODE_THEME_PACK[spec]; if (builtIn !== undefined) { const theme = themeFromOpencodeJson(builtIn, mode); if (theme) { return { theme, name: spec, }; } } return null; } function loadThemeFromPath(path: string, mode: ThemeMode): ThemeCandidate | null { const content = safeReadText(path); if (!content) { return null; } const lower = path.toLowerCase(); if (lower.endsWith(".toml")) { try { const parsed = toml.parse(content); const theme = themeFromAny(parsed); if (!theme) { return null; } return { theme, name: themeNameFromPath(path), }; } catch { return null; } } const value = parseJsonWithComments(content); if (!value) { return null; } const opencodeTheme = themeFromOpencodeJson(value, mode); if (opencodeTheme) { return { theme: opencodeTheme, name: themeNameFromPath(path), }; } const paletteTheme = themeFromAny(value); if (!paletteTheme) { return null; } return { theme: paletteTheme, name: themeNameFromPath(path), }; } function themeNameFromPath(path: string): string { const base = path.split(/[\\/]/).pop() ?? path; if (base.endsWith(".json") || base.endsWith(".toml")) { return base.replace(/\.(json|toml)$/i, ""); } return base; } function themeFromOpencodeValue(value: unknown, searchDirs: string[], mode: ThemeMode, baseDir: string): ThemeCandidate | null { if (typeof value === "string") { return loadFromSpec(value, searchDirs, mode, baseDir); } if (!isObject(value)) { return null; } if (value.theme !== undefined) { const theme = themeFromOpencodeJson(value, mode); if (theme) { return { theme, name: typeof value.name === "string" ? value.name : "inline", }; } } const paletteTheme = themeFromAny(value.colors ?? value.palette ?? value); if (paletteTheme) { return { theme: paletteTheme, name: typeof value.name === "string" ? value.name : "inline", }; } if (typeof value.name === "string") { const named = loadFromSpec(value.name, searchDirs, mode, baseDir); if (named) { return named; } } const pathLike = value.path ?? value.file; if (typeof pathLike === "string") { const resolved = resolvePath(pathLike, baseDir); const candidate = loadThemeFromPath(resolved, mode); if (candidate) { return candidate; } } return null; } function themeFromOpencodeJson(value: unknown, mode: ThemeMode): TuiTheme | null { if (!isObject(value)) { return null; } const themeMap = value.theme; if (!isObject(themeMap)) { return null; } const defs = isObject(value.defs) ? value.defs : {}; const background = opencodeColor(themeMap, defs, mode, "background") ?? opencodeColor(themeMap, defs, mode, "backgroundPanel") ?? opencodeColor(themeMap, defs, mode, "backgroundElement") ?? DEFAULT_THEME.background; const text = opencodeColor(themeMap, defs, mode, "text") ?? DEFAULT_THEME.text; const muted = opencodeColor(themeMap, defs, mode, "textMuted") ?? DEFAULT_THEME.muted; const highlightBg = opencodeColor(themeMap, defs, mode, "text") ?? text; const highlightFg = opencodeColor(themeMap, defs, mode, "backgroundElement") ?? opencodeColor(themeMap, defs, mode, "backgroundPanel") ?? opencodeColor(themeMap, defs, mode, "background") ?? DEFAULT_THEME.highlightFg; const selectionBorder = opencodeColor(themeMap, defs, mode, "secondary") ?? opencodeColor(themeMap, defs, mode, "accent") ?? opencodeColor(themeMap, defs, mode, "primary") ?? DEFAULT_THEME.selectionBorder; const success = opencodeColor(themeMap, defs, mode, "success") ?? DEFAULT_THEME.success; const warning = opencodeColor(themeMap, defs, mode, "warning") ?? DEFAULT_THEME.warning; const error = opencodeColor(themeMap, defs, mode, "error") ?? DEFAULT_THEME.error; const info = opencodeColor(themeMap, defs, mode, "info") ?? DEFAULT_THEME.info; const diffAdd = opencodeColor(themeMap, defs, mode, "diffAdded") ?? success; const diffDel = opencodeColor(themeMap, defs, mode, "diffRemoved") ?? error; const diffSep = opencodeColor(themeMap, defs, mode, "diffContext") ?? opencodeColor(themeMap, defs, mode, "diffHunkHeader") ?? muted; return { background, text, muted, header: muted, status: muted, highlightBg, highlightFg, selectionBorder, success, warning, error, info, diffAdd, diffDel, diffSep, agentRunning: success, agentIdle: warning, agentNone: muted, agentError: error, prUnpushed: warning, author: info, ciRunning: warning, ciPass: success, ciFail: error, ciNone: muted, reviewApproved: success, reviewChanges: error, reviewPending: warning, reviewNone: muted, }; } function opencodeColor(themeMap: JsonObject, defs: JsonObject, mode: ThemeMode, key: string): string | null { const raw = themeMap[key]; if (raw === undefined) { return null; } return resolveOpencodeColor(raw, themeMap, defs, mode, 0); } function resolveOpencodeColor(value: unknown, themeMap: JsonObject, defs: JsonObject, mode: ThemeMode, depth: number): string | null { if (depth > 12) { return null; } if (typeof value === "string") { const trimmed = value.trim(); if (!trimmed || trimmed.toLowerCase() === "transparent" || trimmed.toLowerCase() === "none") { return null; } const fromDefs = defs[trimmed]; if (fromDefs !== undefined) { return resolveOpencodeColor(fromDefs, themeMap, defs, mode, depth + 1); } const fromTheme = themeMap[trimmed]; if (fromTheme !== undefined) { return resolveOpencodeColor(fromTheme, themeMap, defs, mode, depth + 1); } if (isColorLike(trimmed)) { return trimmed; } return null; } if (isObject(value)) { const nested = value[mode]; if (nested !== undefined) { return resolveOpencodeColor(nested, themeMap, defs, mode, depth + 1); } } return null; } function themeFromAny(value: unknown): TuiTheme | null { const palette = extractPalette(value); if (!palette) { return null; } const pick = (keys: string[], fallback: string): string => { for (const key of keys) { const v = palette[normalizeKey(key)]; if (v && isColorLike(v)) { return v; } } return fallback; }; const background = pick(["background", "bg", "base", "background_color"], DEFAULT_THEME.background); const text = pick(["text", "foreground", "fg", "primary"], DEFAULT_THEME.text); const muted = pick(["muted", "subtle", "secondary", "dim"], DEFAULT_THEME.muted); const header = pick(["header", "header_text"], muted); const status = pick(["status", "status_text"], muted); const highlightBg = pick(["highlight_bg", "selection", "highlight", "accent_bg"], DEFAULT_THEME.highlightBg); const highlightFg = pick(["highlight_fg", "selection_fg", "accent_fg"], text); const selectionBorder = pick(["selection_border", "highlight_border", "accent", "secondary"], DEFAULT_THEME.selectionBorder); const success = pick(["success", "green"], DEFAULT_THEME.success); const warning = pick(["warning", "yellow"], DEFAULT_THEME.warning); const error = pick(["error", "red"], DEFAULT_THEME.error); const info = pick(["info", "cyan", "blue"], DEFAULT_THEME.info); const diffAdd = pick(["diff_add", "diff_addition", "add"], success); const diffDel = pick(["diff_del", "diff_deletion", "delete"], error); const diffSep = pick(["diff_sep", "diff_separator", "separator"], muted); return { background, text, muted, header, status, highlightBg, highlightFg, selectionBorder, success, warning, error, info, diffAdd, diffDel, diffSep, agentRunning: pick(["agent_running", "running"], success), agentIdle: pick(["agent_idle", "idle"], warning), agentNone: pick(["agent_none", "none"], muted), agentError: pick(["agent_error", "agent_failed"], error), prUnpushed: pick(["pr_unpushed", "unpushed"], warning), author: pick(["author"], info), ciRunning: pick(["ci_running"], warning), ciPass: pick(["ci_pass", "ci_success"], success), ciFail: pick(["ci_fail", "ci_error"], error), ciNone: pick(["ci_none", "ci_unknown"], muted), reviewApproved: pick(["review_approved", "approved"], success), reviewChanges: pick(["review_changes", "changes"], error), reviewPending: pick(["review_pending", "pending"], warning), reviewNone: pick(["review_none", "review_unknown"], muted), }; } function extractPalette(value: unknown): Record | null { if (!isObject(value)) { return null; } const colors = isObject(value.colors) ? value.colors : undefined; const palette = isObject(value.palette) ? value.palette : undefined; const source = colors ?? palette ?? value; if (!isObject(source)) { return null; } const out: Record = {}; for (const [key, raw] of Object.entries(source)) { if (typeof raw !== "string") { continue; } out[normalizeKey(key)] = raw; } return Object.keys(out).length > 0 ? out : null; } function normalizeKey(key: string): string { return key.toLowerCase().replace(/[\-\s.]/g, "_"); } function isColorLike(value: string): boolean { const lower = value.trim().toLowerCase(); if (!lower) { return false; } if (/^#[0-9a-f]{3}$/.test(lower) || /^#[0-9a-f]{6}$/.test(lower) || /^#[0-9a-f]{8}$/.test(lower)) { return true; } if (/^rgba?\(\s*\d+\s*,\s*\d+\s*,\s*\d+(\s*,\s*[\d.]+)?\s*\)$/.test(lower)) { return true; } return /^[a-z_\-]+$/.test(lower); } function findOpencodeThemeValue(value: unknown): unknown { if (!isObject(value)) { return undefined; } if (value.theme !== undefined) { return value.theme; } return pointer(value, ["ui", "theme"]) ?? pointer(value, ["tui", "theme"]) ?? pointer(value, ["options", "theme"]); } function pointer(obj: JsonObject, parts: string[]): unknown { let current: unknown = obj; for (const part of parts) { if (!isObject(current)) { return undefined; } current = current[part]; } return current; } function opencodeConfigPaths(baseDir: string): string[] { const paths: string[] = []; const rootish = opencodeProjectConfigPaths(baseDir); paths.push(...rootish); const configDir = process.env.XDG_CONFIG_HOME || join(homedir(), ".config"); const opencodeDir = join(configDir, "opencode"); paths.push(join(opencodeDir, "opencode.json")); paths.push(join(opencodeDir, "opencode.jsonc")); paths.push(join(opencodeDir, "config.json")); return paths; } function opencodeThemeDirs(configDir: string | undefined, baseDir: string): string[] { const dirs: string[] = []; if (configDir) { dirs.push(join(configDir, "themes")); } const xdgConfig = process.env.XDG_CONFIG_HOME || join(homedir(), ".config"); dirs.push(join(xdgConfig, "opencode", "themes")); dirs.push(join(homedir(), ".opencode", "themes")); dirs.push(...opencodeProjectThemeDirs(baseDir)); return dirs; } function opencodeProjectConfigPaths(baseDir: string): string[] { const dirs = ancestorDirs(baseDir); const out: string[] = []; for (const dir of dirs) { out.push(join(dir, "opencode.json")); out.push(join(dir, "opencode.jsonc")); out.push(join(dir, ".opencode", "opencode.json")); out.push(join(dir, ".opencode", "opencode.jsonc")); } return out; } function opencodeProjectThemeDirs(baseDir: string): string[] { const dirs = ancestorDirs(baseDir); const out: string[] = []; for (const dir of dirs) { out.push(join(dir, ".opencode", "themes")); } return out; } function ancestorDirs(start: string): string[] { const out: string[] = []; let current = resolve(start); while (true) { out.push(current); const parent = dirname(current); if (parent === current) { break; } current = parent; } return out; } function opencodeStatePath(): string | null { const stateHome = process.env.XDG_STATE_HOME || join(homedir(), ".local", "state"); return join(stateHome, "opencode", "kv.json"); } function opencodeStateThemeMode(): ThemeMode | null { const path = opencodeStatePath(); if (!path || !existsSync(path)) { return null; } const value = readJsonWithComments(path); if (!isObject(value)) { return null; } const mode = value.theme_mode; if (typeof mode !== "string") { return null; } const lower = mode.toLowerCase(); if (lower === "dark" || lower === "light") { return lower; } return null; } function parseJsonWithComments(content: string): unknown { try { return JSON.parse(content); } catch { // Fall through. } try { return JSON.parse(stripJsoncComments(content)); } catch { return null; } } function readJsonWithComments(path: string): unknown { const content = safeReadText(path); if (!content) { return null; } return parseJsonWithComments(content); } function stripJsoncComments(input: string): string { let output = ""; let i = 0; let inString = false; let escaped = false; while (i < input.length) { const ch = input[i]; if (inString) { output += ch; if (escaped) { escaped = false; } else if (ch === "\\") { escaped = true; } else if (ch === '"') { inString = false; } i += 1; continue; } if (ch === '"') { inString = true; output += ch; i += 1; continue; } if (ch === "/" && input[i + 1] === "/") { i += 2; while (i < input.length && input[i] !== "\n") { i += 1; } continue; } if (ch === "/" && input[i + 1] === "*") { i += 2; while (i < input.length) { if (input[i] === "*" && input[i + 1] === "/") { i += 2; break; } i += 1; } continue; } output += ch; i += 1; } return output; } function safeReadText(path: string): string | null { try { return readFileSync(path, "utf8"); } catch { return null; } } function resolvePath(path: string, baseDir: string): string { if (path.startsWith("~/")) { return join(homedir(), path.slice(2)); } if (isAbsolute(path)) { return path; } return resolve(baseDir, path); } function isPathLike(spec: string): boolean { return spec.includes("/") || spec.includes("\\") || spec.endsWith(".json") || spec.endsWith(".toml"); } function isDefaultThemeName(spec: string): boolean { const lower = spec.toLowerCase(); return lower === "default" || lower === "opencode" || lower === "opencode-default" || lower === "system"; } function isObject(value: unknown): value is JsonObject { return typeof value === "object" && value !== null && !Array.isArray(value); }