/** * Interactive terminal theme loader. * * Validates theme JSON, resolves color variables, watches custom theme files, and exposes terminal styling helpers. */ import * as fs from "node:fs"; import { getCapabilities } from "@earendil-works/pi-tui"; import chalk from "chalk"; import { type Static, Type } from "typebox"; import { Compile } from "typebox/compile"; import type { SourceInfo } from "../../../sessions/source-info.js"; import { highlight, supportsLanguage } from "../../../utils/syntax-highlight.js"; // ============================================================================ // Types & Schema // ============================================================================ const ColorValueSchema = Type.Union([ Type.String(), // hex "#ff0000", var ref "primary", or empty "" Type.Integer({ minimum: 0, maximum: 255 }), // 256-color index ]); type ColorValue = Static; const ThemeJsonSchema = Type.Object({ $schema: Type.Optional(Type.String()), name: Type.String(), vars: Type.Optional(Type.Record(Type.String(), ColorValueSchema)), colors: Type.Object({ // Core UI (10 colors) accent: ColorValueSchema, border: ColorValueSchema, borderAccent: ColorValueSchema, borderMuted: ColorValueSchema, success: ColorValueSchema, error: ColorValueSchema, warning: ColorValueSchema, muted: ColorValueSchema, dim: ColorValueSchema, text: ColorValueSchema, thinkingText: ColorValueSchema, // Backgrounds & Content Text (11 colors) selectedBg: ColorValueSchema, userMessageBg: ColorValueSchema, userMessageText: ColorValueSchema, customMessageBg: ColorValueSchema, customMessageText: ColorValueSchema, customMessageLabel: ColorValueSchema, toolPendingBg: ColorValueSchema, toolSuccessBg: ColorValueSchema, toolErrorBg: ColorValueSchema, toolTitle: ColorValueSchema, toolOutput: ColorValueSchema, // Markdown (10 colors) mdHeading: ColorValueSchema, mdLink: ColorValueSchema, mdLinkUrl: ColorValueSchema, mdCode: ColorValueSchema, mdCodeBlock: ColorValueSchema, mdCodeBlockBorder: ColorValueSchema, mdQuote: ColorValueSchema, mdQuoteBorder: ColorValueSchema, mdHr: ColorValueSchema, mdListBullet: ColorValueSchema, // Tool Diffs (3 colors) toolDiffAdded: ColorValueSchema, toolDiffRemoved: ColorValueSchema, toolDiffContext: ColorValueSchema, // Syntax Highlighting (9 colors) syntaxComment: ColorValueSchema, syntaxKeyword: ColorValueSchema, syntaxFunction: ColorValueSchema, syntaxVariable: ColorValueSchema, syntaxString: ColorValueSchema, syntaxNumber: ColorValueSchema, syntaxType: ColorValueSchema, syntaxOperator: ColorValueSchema, syntaxPunctuation: ColorValueSchema, // Thinking Level Borders (6 colors) thinkingOff: ColorValueSchema, thinkingMinimal: ColorValueSchema, thinkingLow: ColorValueSchema, thinkingMedium: ColorValueSchema, thinkingHigh: ColorValueSchema, thinkingXhigh: ColorValueSchema, // Bash Mode (1 color) bashMode: ColorValueSchema, }), export: Type.Optional( Type.Object({ pageBg: Type.Optional(ColorValueSchema), cardBg: Type.Optional(ColorValueSchema), infoBg: Type.Optional(ColorValueSchema), }), ), }); type ThemeJson = Static; const validateThemeJson = Compile(ThemeJsonSchema); type ThemeColor = | "accent" | "border" | "borderAccent" | "borderMuted" | "success" | "error" | "warning" | "muted" | "dim" | "text" | "thinkingText" | "userMessageText" | "customMessageText" | "customMessageLabel" | "toolTitle" | "toolOutput" | "mdHeading" | "mdLink" | "mdLinkUrl" | "mdCode" | "mdCodeBlock" | "mdCodeBlockBorder" | "mdQuote" | "mdQuoteBorder" | "mdHr" | "mdListBullet" | "toolDiffAdded" | "toolDiffRemoved" | "toolDiffContext" | "syntaxComment" | "syntaxKeyword" | "syntaxFunction" | "syntaxVariable" | "syntaxString" | "syntaxNumber" | "syntaxType" | "syntaxOperator" | "syntaxPunctuation" | "thinkingOff" | "thinkingMinimal" | "thinkingLow" | "thinkingMedium" | "thinkingHigh" | "thinkingXhigh" | "bashMode"; type ThemeBg = | "selectedBg" | "userMessageBg" | "customMessageBg" | "toolPendingBg" | "toolSuccessBg" | "toolErrorBg"; type ColorMode = "truecolor" | "256color"; // ============================================================================ // Color Utilities // ============================================================================ function hexToRgb(hex: string): { r: number; g: number; b: number } { const cleaned = hex.replace("#", ""); if (cleaned.length !== 6) { throw new Error(`Invalid hex color: ${hex}`); } const r = Number.parseInt(cleaned.slice(0, 2), 16); const g = Number.parseInt(cleaned.slice(2, 4), 16); const b = Number.parseInt(cleaned.slice(4, 6), 16); if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) { throw new Error(`Invalid hex color: ${hex}`); } return { r, g, b }; } // The 6x6x6 color cube channel values (indices 0-5) const CUBE_VALUES = [0, 95, 135, 175, 215, 255]; // Grayscale ramp values (indices 232-255, 24 grays from 8 to 238) const GRAY_VALUES = Array.from({ length: 24 }, (_, i) => 8 + i * 10); function findClosestPaletteIndex(value: number, palette: readonly number[]): number { let minDist = Infinity; let minIdx = 0; for (const [i, paletteValue] of palette.entries()) { const dist = Math.abs(value - paletteValue); if (dist < minDist) { minDist = dist; minIdx = i; } } return minIdx; } function colorDistance( r1: number, g1: number, b1: number, r2: number, g2: number, b2: number, ): number { // Weighted Euclidean distance (human eye is more sensitive to green) const dr = r1 - r2; const dg = g1 - g2; const db = b1 - b2; return dr * dr * 0.299 + dg * dg * 0.587 + db * db * 0.114; } function rgbTo256(r: number, g: number, b: number): number { // Find closest color in the 6x6x6 cube const rIdx = findClosestPaletteIndex(r, CUBE_VALUES); const gIdx = findClosestPaletteIndex(g, CUBE_VALUES); const bIdx = findClosestPaletteIndex(b, CUBE_VALUES); const cubeR = CUBE_VALUES[rIdx]; const cubeG = CUBE_VALUES[gIdx]; const cubeB = CUBE_VALUES[bIdx]; if (cubeR === undefined || cubeG === undefined || cubeB === undefined) { throw new Error("Invalid 256-color cube index"); } const cubeIndex = 16 + 36 * rIdx + 6 * gIdx + bIdx; const cubeDist = colorDistance(r, g, b, cubeR, cubeG, cubeB); // Find closest grayscale const gray = Math.round(0.299 * r + 0.587 * g + 0.114 * b); const grayIdx = findClosestPaletteIndex(gray, GRAY_VALUES); const grayValue = GRAY_VALUES[grayIdx]; if (grayValue === undefined) { throw new Error("Invalid 256-color grayscale index"); } const grayIndex = 232 + grayIdx; const grayDist = colorDistance(r, g, b, grayValue, grayValue, grayValue); // Check if color has noticeable saturation (hue matters) // If max-min spread is significant, prefer cube to preserve tint const maxC = Math.max(r, g, b); const minC = Math.min(r, g, b); const spread = maxC - minC; // Only consider grayscale if color is nearly neutral (spread < 10) // AND grayscale is actually closer if (spread < 10 && grayDist < cubeDist) { return grayIndex; } return cubeIndex; } function hexTo256(hex: string): number { const { r, g, b } = hexToRgb(hex); return rgbTo256(r, g, b); } function colorAnsi(color: string | number, mode: ColorMode, layer: "fg" | "bg"): string { const code = layer === "fg" ? 38 : 48; if (color === "") { return layer === "fg" ? "\x1b[39m" : "\x1b[49m"; } if (typeof color === "number") { return `\x1b[${code};5;${color}m`; } if (color.startsWith("#")) { if (mode === "truecolor") { const { r, g, b } = hexToRgb(color); return `\x1b[${code};2;${r};${g};${b}m`; } const index = hexTo256(color); return `\x1b[${code};5;${index}m`; } throw new Error(`Invalid color value: ${color}`); } function resolveVarRefs( value: ColorValue, vars: Record, visited = new Set(), ): string | number { if (typeof value === "number" || value === "" || value.startsWith("#")) { return value; } if (visited.has(value)) { throw new Error(`Circular variable reference detected: ${value}`); } if (!(value in vars)) { throw new Error(`Variable reference not found: ${value}`); } visited.add(value); const resolved = vars[value]; if (resolved === undefined) { throw new Error(`Variable reference not found: ${value}`); } return resolveVarRefs(resolved, vars, visited); } function resolveThemeColors>( colors: T, vars: Record = {}, ): Record { const resolved: Record = {}; for (const [key, value] of Object.entries(colors)) { resolved[key] = resolveVarRefs(value, vars); } return resolved as Record; } // ============================================================================ // Theme Class // ============================================================================ // Keep formatting independent of overridable public ANSI getters. function getThemeAnsi(colors: ReadonlyMap, color: string, label: string): string { const ansi = colors.get(color); if (!ansi) { throw new Error(`Unknown theme ${label}: ${color}`); } return ansi; } export class Theme { readonly name?: string; readonly sourcePath?: string; sourceInfo?: SourceInfo; private fgColors: Map; private bgColors: Map; private mode: ColorMode; constructor( fgColors: Record, bgColors: Record, mode: ColorMode, options: { name?: string; sourcePath?: string; sourceInfo?: SourceInfo } = {}, ) { this.name = options.name; this.sourcePath = options.sourcePath; this.sourceInfo = options.sourceInfo; this.mode = mode; this.fgColors = new Map(); for (const [key, value] of Object.entries(fgColors) as [ThemeColor, string | number][]) { this.fgColors.set(key, colorAnsi(value, mode, "fg")); } this.bgColors = new Map(); for (const [key, value] of Object.entries(bgColors) as [ThemeBg, string | number][]) { this.bgColors.set(key, colorAnsi(value, mode, "bg")); } } fg(color: ThemeColor, text: string): string { const ansi = getThemeAnsi(this.fgColors, color, "color"); return `${ansi}${text}\x1b[39m`; // Reset only foreground color } bg(color: ThemeBg, text: string): string { const ansi = getThemeAnsi(this.bgColors, color, "background color"); return `${ansi}${text}\x1b[49m`; // Reset only background color } bold(text: string): string { return chalk.bold(text); } italic(text: string): string { return chalk.italic(text); } underline(text: string): string { return chalk.underline(text); } inverse(text: string): string { return chalk.inverse(text); } strikethrough(text: string): string { return chalk.strikethrough(text); } getFgAnsi(color: ThemeColor): string { return getThemeAnsi(this.fgColors, color, "color"); } getBgAnsi(color: ThemeBg): string { return getThemeAnsi(this.bgColors, color, "background color"); } getColorMode(): ColorMode { return this.mode; } getThinkingBorderColor( level: "off" | "minimal" | "low" | "medium" | "high" | "xhigh", ): (str: string) => string { // Map thinking levels to dedicated theme colors switch (level) { case "off": return (str: string) => this.fg("thinkingOff", str); case "minimal": return (str: string) => this.fg("thinkingMinimal", str); case "low": return (str: string) => this.fg("thinkingLow", str); case "medium": return (str: string) => this.fg("thinkingMedium", str); case "high": return (str: string) => this.fg("thinkingHigh", str); case "xhigh": return (str: string) => this.fg("thinkingXhigh", str); default: return (str: string) => this.fg("thinkingOff", str); } } getBashModeBorderColor(): (str: string) => string { return (str: string) => this.fg("bashMode", str); } } // ============================================================================ // Theme Loading // ============================================================================ function parseThemeJson(label: string, json: unknown): ThemeJson { if (!validateThemeJson.Check(json)) { const errors = Array.from(validateThemeJson.Errors(json)); const missingColors = new Set(); const otherErrors: string[] = []; for (const error of errors) { if (error.keyword === "required" && error.instancePath === "/colors") { const requiredProperties = (error.params as { requiredProperties?: string[] }) .requiredProperties; for (const requiredProperty of requiredProperties ?? []) { missingColors.add(requiredProperty); } continue; } const pathLocal = error.instancePath || "/"; otherErrors.push(` - ${pathLocal}: ${error.message}`); } let errorMessage = `Invalid theme "${label}":\n`; if (missingColors.size > 0) { errorMessage += "\nMissing required color tokens:\n"; errorMessage += Array.from(missingColors) .toSorted() .map((color) => ` - ${color}`) .join("\n"); errorMessage += '\n\nPlease add these colors to your theme\'s "colors" object.'; errorMessage += "\nSee the built-in themes (dark.json, light.json) for reference values."; } if (otherErrors.length > 0) { errorMessage += `\n\nOther errors:\n${otherErrors.join("\n")}`; } throw new Error(errorMessage); } return json; } function parseThemeJsonContent(label: string, content: string): ThemeJson { let json: unknown; try { json = JSON.parse(content); } catch (error) { const message = error instanceof Error ? error.message : String(error); throw new Error(`Failed to parse theme ${label}: ${message}`, { cause: error }); } return parseThemeJson(label, json); } function createTheme(themeJson: ThemeJson, mode?: ColorMode, sourcePath?: string): Theme { const colorMode = mode ?? (getCapabilities().trueColor ? "truecolor" : "256color"); const resolvedColors = resolveThemeColors(themeJson.colors, themeJson.vars); const fgColors: Record = {} as Record; const bgColors: Record = {} as Record; const bgColorKeys: Set = new Set([ "selectedBg", "userMessageBg", "customMessageBg", "toolPendingBg", "toolSuccessBg", "toolErrorBg", ]); for (const [key, value] of Object.entries(resolvedColors)) { if (bgColorKeys.has(key)) { bgColors[key as ThemeBg] = value; } else { fgColors[key as ThemeColor] = value; } } return new Theme(fgColors, bgColors, colorMode, { name: themeJson.name, sourcePath, }); } export function loadThemeFromPath(themePath: string, mode?: ColorMode): Theme { const content = fs.readFileSync(themePath, "utf-8"); const themeJson = parseThemeJsonContent(themePath, content); return createTheme(themeJson, mode, themePath); } // ============================================================================ // Global Theme Instance // ============================================================================ // Use globalThis to share theme across module loaders (tsx + jiti in dev mode) const THEME_KEY = Symbol.for("openclaw:agent-theme"); // Export theme as a getter that reads from globalThis // This ensures all module instances (tsx, jiti) see the same theme export const interactiveAgentTheme: Theme = new Proxy({} as Theme, { get(_target, prop) { const t = (globalThis as Record)[THEME_KEY]; if (!t) { throw new Error("Theme not initialized. Call setTheme() first."); } return (t as unknown as Record)[prop]; }, }); // ============================================================================ // HTML Export Helpers // ============================================================================ // ============================================================================ // TUI Helpers // ============================================================================ type CliHighlightTheme = Record string>; let cachedHighlightThemeFor: Theme | undefined; let cachedCliHighlightTheme: CliHighlightTheme | undefined; function buildCliHighlightTheme(t: Theme): CliHighlightTheme { return { keyword: (s: string) => t.fg("syntaxKeyword", s), built_in: (s: string) => t.fg("syntaxType", s), literal: (s: string) => t.fg("syntaxNumber", s), number: (s: string) => t.fg("syntaxNumber", s), string: (s: string) => t.fg("syntaxString", s), comment: (s: string) => t.fg("syntaxComment", s), function: (s: string) => t.fg("syntaxFunction", s), title: (s: string) => t.fg("syntaxFunction", s), class: (s: string) => t.fg("syntaxType", s), type: (s: string) => t.fg("syntaxType", s), attr: (s: string) => t.fg("syntaxVariable", s), variable: (s: string) => t.fg("syntaxVariable", s), params: (s: string) => t.fg("syntaxVariable", s), operator: (s: string) => t.fg("syntaxOperator", s), punctuation: (s: string) => t.fg("syntaxPunctuation", s), }; } function getCliHighlightTheme(t: Theme): CliHighlightTheme { if (cachedHighlightThemeFor !== t || !cachedCliHighlightTheme) { cachedHighlightThemeFor = t; cachedCliHighlightTheme = buildCliHighlightTheme(t); } return cachedCliHighlightTheme; } /** * Highlight code with syntax coloring based on file extension or language. * Returns array of highlighted lines. */ export function highlightCode(code: string, lang?: string): string[] { // Validate language before highlighting to avoid stderr spam from cli-highlight const validLang = lang && supportsLanguage(lang) ? lang : undefined; // Skip highlighting when no valid language is specified. cli-highlight's // auto-detection is unreliable and can misidentify prose as AppleScript, // LiveCodeServer, etc., coloring random English words as keywords. if (!validLang) { return code.split("\n").map((line) => interactiveAgentTheme.fg("mdCodeBlock", line)); } const opts = { language: validLang, ignoreIllegals: true, theme: getCliHighlightTheme(interactiveAgentTheme), }; try { return highlight(code, opts).split("\n"); } catch { return code.split("\n"); } } /** * Get language identifier from file path extension. */ export function getLanguageFromPath(filePath: string): string | undefined { const ext = filePath.split(".").pop()?.toLowerCase(); if (!ext) { return undefined; } const extToLang: Record = { ts: "typescript", tsx: "typescript", js: "javascript", jsx: "javascript", mjs: "javascript", cjs: "javascript", py: "python", rb: "ruby", rs: "rust", go: "go", java: "java", kt: "kotlin", swift: "swift", c: "c", h: "c", cpp: "cpp", cc: "cpp", cxx: "cpp", hpp: "cpp", cs: "csharp", php: "php", sh: "bash", bash: "bash", zsh: "bash", fish: "fish", ps1: "powershell", sql: "sql", html: "html", htm: "html", css: "css", scss: "scss", sass: "sass", less: "less", json: "json", yaml: "yaml", yml: "yaml", toml: "toml", xml: "xml", md: "markdown", markdown: "markdown", dockerfile: "dockerfile", makefile: "makefile", cmake: "cmake", lua: "lua", perl: "perl", r: "r", scala: "scala", clj: "clojure", ex: "elixir", exs: "elixir", erl: "erlang", hs: "haskell", ml: "ocaml", vim: "vim", graphql: "graphql", proto: "protobuf", tf: "hcl", hcl: "hcl", }; return extToLang[ext]; }