sandbox-agent/factory/packages/cli/src/theme.ts
Nathan Flurry d2346bafb3
Configure lefthook formatter checks (#231)
* Add lefthook formatter checks

* Fix SDK mode hydration

* Stabilize SDK mode integration test
2026-03-10 23:03:11 -07:00

792 lines
20 KiB
TypeScript

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<string, unknown>;
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<string, unknown>;
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<string, string> | 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<string, string> = {};
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);
}