import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import type { EditorTheme, MarkdownTheme, SelectListTheme } from "@mariozechner/pi-tui"; import { type Static, Type } from "@sinclair/typebox"; import { TypeCompiler } from "@sinclair/typebox/compiler"; import chalk from "chalk"; import { getThemeDir } from "../paths.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, // Backgrounds & Content Text (7 colors) userMessageBg: ColorValueSchema, userMessageText: 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 (5 colors) thinkingOff: ColorValueSchema, thinkingMinimal: ColorValueSchema, thinkingLow: ColorValueSchema, thinkingMedium: ColorValueSchema, thinkingHigh: ColorValueSchema, }), }); type ThemeJson = Static; const validateThemeJson = TypeCompiler.Compile(ThemeJsonSchema); export type ThemeColor = | "accent" | "border" | "borderAccent" | "borderMuted" | "success" | "error" | "warning" | "muted" | "dim" | "text" | "userMessageText" | "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"; 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"; } // Windows Terminal supports truecolor if (process.env.WT_SESSION) { 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 // ============================================================================ 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}\x1b[39m`; // Reset only foreground color } 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}\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); } 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; } getThinkingBorderColor(level: "off" | "minimal" | "low" | "medium" | "high"): (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); default: return (str: string) => this.fg("thinkingOff", str); } } } // ============================================================================ // Theme Loading // ============================================================================ let BUILTIN_THEMES: Record | undefined; function getBuiltinThemes(): Record { if (!BUILTIN_THEMES) { const themeDir = getThemeDir(); const darkPath = path.join(themeDir, "dark.json"); const lightPath = path.join(themeDir, "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)) { const result = bg < 8 ? "dark" : "light"; return result; } } } return "dark"; } function getDefaultTheme(): string { return detectTerminalBackground(); } // ============================================================================ // Global Theme Instance // ============================================================================ export let theme: Theme; let currentThemeName: string | undefined; let themeWatcher: fs.FSWatcher | undefined; let onThemeChangeCallback: (() => void) | undefined; export function initTheme(themeName?: string): void { const name = themeName ?? getDefaultTheme(); currentThemeName = name; try { theme = loadTheme(name); startThemeWatcher(); } catch (error) { // Theme is invalid - fall back to dark theme silently currentThemeName = "dark"; theme = loadTheme("dark"); // Don't start watcher for fallback theme } } export function setTheme(name: string): { success: boolean; error?: string } { currentThemeName = name; try { theme = loadTheme(name); startThemeWatcher(); return { success: true }; } catch (error) { // Theme is invalid - fall back to dark theme currentThemeName = "dark"; theme = loadTheme("dark"); // Don't start watcher for fallback theme return { success: false, error: error instanceof Error ? error.message : String(error), }; } } export function onThemeChange(callback: () => void): void { onThemeChangeCallback = callback; } function startThemeWatcher(): void { // Stop existing watcher if any if (themeWatcher) { themeWatcher.close(); themeWatcher = undefined; } // Only watch if it's a custom theme (not built-in) if (!currentThemeName || currentThemeName === "dark" || currentThemeName === "light") { return; } const themesDir = getThemesDir(); const themeFile = path.join(themesDir, `${currentThemeName}.json`); // Only watch if the file exists if (!fs.existsSync(themeFile)) { return; } try { themeWatcher = fs.watch(themeFile, (eventType) => { if (eventType === "change") { // Debounce rapid changes setTimeout(() => { try { // Reload the theme theme = loadTheme(currentThemeName!); // Notify callback (to invalidate UI) if (onThemeChangeCallback) { onThemeChangeCallback(); } } catch (error) { // Ignore errors (file might be in invalid state while being edited) } }, 100); } else if (eventType === "rename") { // File was deleted or renamed - fall back to default theme setTimeout(() => { if (!fs.existsSync(themeFile)) { currentThemeName = "dark"; theme = loadTheme("dark"); if (themeWatcher) { themeWatcher.close(); themeWatcher = undefined; } if (onThemeChangeCallback) { onThemeChangeCallback(); } } }, 100); } }); } catch (error) { // Ignore errors starting watcher } } export function stopThemeWatcher(): void { if (themeWatcher) { themeWatcher.close(); themeWatcher = undefined; } } // ============================================================================ // TUI Helpers // ============================================================================ export function getMarkdownTheme(): MarkdownTheme { return { heading: (text: string) => theme.fg("mdHeading", text), link: (text: string) => theme.fg("mdLink", text), linkUrl: (text: string) => theme.fg("mdLinkUrl", 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), bold: (text: string) => theme.bold(text), italic: (text: string) => theme.italic(text), underline: (text: string) => theme.underline(text), strikethrough: (text: string) => chalk.strikethrough(text), }; } export function getSelectListTheme(): SelectListTheme { return { selectedPrefix: (text: string) => theme.fg("accent", text), selectedText: (text: string) => theme.fg("accent", text), description: (text: string) => theme.fg("muted", text), scrollInfo: (text: string) => theme.fg("muted", text), noMatch: (text: string) => theme.fg("muted", text), }; } export function getEditorTheme(): EditorTheme { return { borderColor: (text: string) => theme.fg("borderMuted", text), selectList: getSelectListTheme(), }; }