import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; import type { MarkdownTheme } from "@mariozechner/pi-tui"; import { type Static, Type } from "@sinclair/typebox"; import { TypeCompiler } from "@sinclair/typebox/compiler"; import chalk from "chalk"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); // ============================================================================ // 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 (9 colors) accent: ColorValueSchema, border: ColorValueSchema, borderAccent: ColorValueSchema, borderMuted: ColorValueSchema, success: ColorValueSchema, error: ColorValueSchema, warning: ColorValueSchema, muted: ColorValueSchema, text: ColorValueSchema, // Backgrounds & Content Text (6 colors) userMessageBg: ColorValueSchema, userMessageText: ColorValueSchema, toolPendingBg: ColorValueSchema, toolSuccessBg: ColorValueSchema, toolErrorBg: ColorValueSchema, toolText: ColorValueSchema, // Markdown (9 colors) mdHeading: ColorValueSchema, mdLink: 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, }), }); type ThemeJson = Static; const validateThemeJson = TypeCompiler.Compile(ThemeJsonSchema); export type ThemeColor = | "accent" | "border" | "borderAccent" | "borderMuted" | "success" | "error" | "warning" | "muted" | "text" | "userMessageText" | "toolText" | "mdHeading" | "mdLink" | "mdCode" | "mdCodeBlock" | "mdCodeBlockBorder" | "mdQuote" | "mdQuoteBorder" | "mdHr" | "mdListBullet" | "toolDiffAdded" | "toolDiffRemoved" | "toolDiffContext" | "syntaxComment" | "syntaxKeyword" | "syntaxFunction" | "syntaxVariable" | "syntaxString" | "syntaxNumber" | "syntaxType" | "syntaxOperator" | "syntaxPunctuation"; export type ThemeBg = "userMessageBg" | "toolPendingBg" | "toolSuccessBg" | "toolErrorBg"; type ColorMode = "truecolor" | "256color"; // ============================================================================ // Color Utilities // ============================================================================ function detectColorMode(): ColorMode { const colorterm = process.env.COLORTERM; if (colorterm === "truecolor" || colorterm === "24bit") { return "truecolor"; } const term = process.env.TERM || ""; if (term.includes("256color")) { return "256color"; } return "256color"; } 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 = parseInt(cleaned.substring(0, 2), 16); const g = parseInt(cleaned.substring(2, 4), 16); const b = parseInt(cleaned.substring(4, 6), 16); if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) { throw new Error(`Invalid hex color: ${hex}`); } return { r, g, b }; } function rgbTo256(r: number, g: number, b: number): number { const rIndex = Math.round((r / 255) * 5); const gIndex = Math.round((g / 255) * 5); const bIndex = Math.round((b / 255) * 5); return 16 + 36 * rIndex + 6 * gIndex + bIndex; } function hexTo256(hex: string): number { const { r, g, b } = hexToRgb(hex); return rgbTo256(r, g, b); } function fgAnsi(color: string | number, mode: ColorMode): string { if (color === "") return "\x1b[39m"; if (typeof color === "number") return `\x1b[38;5;${color}m`; if (color.startsWith("#")) { if (mode === "truecolor") { const { r, g, b } = hexToRgb(color); return `\x1b[38;2;${r};${g};${b}m`; } else { const index = hexTo256(color); return `\x1b[38;5;${index}m`; } } throw new Error(`Invalid color value: ${color}`); } function bgAnsi(color: string | number, mode: ColorMode): string { if (color === "") return "\x1b[49m"; if (typeof color === "number") return `\x1b[48;5;${color}m`; if (color.startsWith("#")) { if (mode === "truecolor") { const { r, g, b } = hexToRgb(color); return `\x1b[48;2;${r};${g};${b}m`; } else { const index = hexTo256(color); return `\x1b[48;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); return resolveVarRefs(vars[value], 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 // ============================================================================ const RESET = "\x1b[0m"; export class Theme { private fgColors: Map; private bgColors: Map; private mode: ColorMode; constructor( fgColors: Record, bgColors: Record, mode: ColorMode, ) { this.mode = mode; this.fgColors = new Map(); for (const [key, value] of Object.entries(fgColors) as [ThemeColor, string | number][]) { this.fgColors.set(key, fgAnsi(value, mode)); } this.bgColors = new Map(); for (const [key, value] of Object.entries(bgColors) as [ThemeBg, string | number][]) { this.bgColors.set(key, bgAnsi(value, mode)); } } fg(color: ThemeColor, text: string): string { const ansi = this.fgColors.get(color); if (!ansi) throw new Error(`Unknown theme color: ${color}`); return `${ansi}${text}${RESET}`; } bg(color: ThemeBg, text: string): string { const ansi = this.bgColors.get(color); if (!ansi) throw new Error(`Unknown theme background color: ${color}`); return `${ansi}${text}${RESET}`; } bold(text: string): string { return chalk.bold(text); } italic(text: string): string { return chalk.italic(text); } underline(text: string): string { return chalk.underline(text); } getFgAnsi(color: ThemeColor): string { const ansi = this.fgColors.get(color); if (!ansi) throw new Error(`Unknown theme color: ${color}`); return ansi; } getBgAnsi(color: ThemeBg): string { const ansi = this.bgColors.get(color); if (!ansi) throw new Error(`Unknown theme background color: ${color}`); return ansi; } getColorMode(): ColorMode { return this.mode; } } // ============================================================================ // Theme Loading // ============================================================================ let BUILTIN_THEMES: Record | undefined; function getBuiltinThemes(): Record { if (!BUILTIN_THEMES) { const darkPath = path.join(__dirname, "dark.json"); const lightPath = path.join(__dirname, "light.json"); BUILTIN_THEMES = { dark: JSON.parse(fs.readFileSync(darkPath, "utf-8")) as ThemeJson, light: JSON.parse(fs.readFileSync(lightPath, "utf-8")) as ThemeJson, }; } return BUILTIN_THEMES; } function getThemesDir(): string { return path.join(os.homedir(), ".pi", "agent", "themes"); } export function getAvailableThemes(): string[] { const themes = new Set(Object.keys(getBuiltinThemes())); const themesDir = getThemesDir(); if (fs.existsSync(themesDir)) { const files = fs.readdirSync(themesDir); for (const file of files) { if (file.endsWith(".json")) { themes.add(file.slice(0, -5)); } } } return Array.from(themes).sort(); } function loadThemeJson(name: string): ThemeJson { const builtinThemes = getBuiltinThemes(); if (name in builtinThemes) { return builtinThemes[name]; } const themesDir = getThemesDir(); const themePath = path.join(themesDir, `${name}.json`); if (!fs.existsSync(themePath)) { throw new Error(`Theme not found: ${name}`); } const content = fs.readFileSync(themePath, "utf-8"); let json: unknown; try { json = JSON.parse(content); } catch (error) { throw new Error(`Failed to parse theme ${name}: ${error}`); } if (!validateThemeJson.Check(json)) { const errors = Array.from(validateThemeJson.Errors(json)); const errorMessages = errors.map((e) => ` - ${e.path}: ${e.message}`).join("\n"); throw new Error(`Invalid theme ${name}:\n${errorMessages}`); } return json as ThemeJson; } function createTheme(themeJson: ThemeJson, mode?: ColorMode): Theme { const colorMode = mode ?? detectColorMode(); const resolvedColors = resolveThemeColors(themeJson.colors, themeJson.vars); const fgColors: Record = {} as Record; const bgColors: Record = {} as Record; const bgColorKeys: Set = new Set(["userMessageBg", "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); } function loadTheme(name: string, mode?: ColorMode): Theme { const themeJson = loadThemeJson(name); return createTheme(themeJson, mode); } function detectTerminalBackground(): "dark" | "light" { const colorfgbg = process.env.COLORFGBG || ""; if (colorfgbg) { const parts = colorfgbg.split(";"); if (parts.length >= 2) { const bg = parseInt(parts[1], 10); if (!Number.isNaN(bg)) { return bg < 8 ? "dark" : "light"; } } } return "dark"; } function getDefaultTheme(): string { return detectTerminalBackground(); } // ============================================================================ // Global Theme Instance // ============================================================================ export let theme: Theme; export function initTheme(themeName?: string): void { const name = themeName ?? getDefaultTheme(); theme = loadTheme(name); } export function setTheme(name: string): void { theme = loadTheme(name); } // ============================================================================ // TUI Helpers // ============================================================================ export function getMarkdownTheme(): MarkdownTheme { return { heading: (text: string) => theme.fg("mdHeading", text), link: (text: string) => theme.fg("mdLink", text), code: (text: string) => theme.fg("mdCode", text), codeBlock: (text: string) => theme.fg("mdCodeBlock", text), codeBlockBorder: (text: string) => theme.fg("mdCodeBlockBorder", text), quote: (text: string) => theme.fg("mdQuote", text), quoteBorder: (text: string) => theme.fg("mdQuoteBorder", text), hr: (text: string) => theme.fg("mdHr", text), listBullet: (text: string) => theme.fg("mdListBullet", text), }; }