mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 10:05:14 +00:00
568 lines
16 KiB
TypeScript
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(),
|
|
};
|
|
}
|