co-mono/packages/coding-agent/src/theme/theme.ts

568 lines
16 KiB
TypeScript

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<typeof ColorValueSchema>;
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<typeof ThemeJsonSchema>;
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<string, ColorValue>,
visited = new Set<string>(),
): 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<T extends Record<string, ColorValue>>(
colors: T,
vars: Record<string, ColorValue> = {},
): Record<keyof T, string | number> {
const resolved: Record<string, string | number> = {};
for (const [key, value] of Object.entries(colors)) {
resolved[key] = resolveVarRefs(value, vars);
}
return resolved as Record<keyof T, string | number>;
}
// ============================================================================
// Theme Class
// ============================================================================
export class Theme {
private fgColors: Map<ThemeColor, string>;
private bgColors: Map<ThemeBg, string>;
private mode: ColorMode;
constructor(
fgColors: Record<ThemeColor, string | number>,
bgColors: Record<ThemeBg, string | number>,
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<string, ThemeJson> | undefined;
function getBuiltinThemes(): Record<string, ThemeJson> {
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<string>(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<ThemeColor, string | number> = {} as Record<ThemeColor, string | number>;
const bgColors: Record<ThemeBg, string | number> = {} as Record<ThemeBg, string | number>;
const bgColorKeys: Set<string> = 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(),
};
}