1063 lines
36 KiB
JavaScript
1063 lines
36 KiB
JavaScript
import * as fs from "node:fs";
|
|
import * as path from "node:path";
|
|
import { getCapabilities, } from "@earendil-works/pi-tui";
|
|
import chalk from "chalk";
|
|
import { Type } from "typebox";
|
|
import { Compile } from "typebox/compile";
|
|
import { getCustomThemesDir, getThemesDir } from "../../../config.js";
|
|
import { closeWatcher, watchWithErrorHandler } from "../../../utils/fs-watch.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
|
|
]);
|
|
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,
|
|
thinkingMax: Type.Optional(ColorValueSchema),
|
|
// Bash Mode (1 color)
|
|
bashMode: ColorValueSchema,
|
|
}),
|
|
export: Type.Optional(Type.Object({
|
|
pageBg: Type.Optional(ColorValueSchema),
|
|
cardBg: Type.Optional(ColorValueSchema),
|
|
infoBg: Type.Optional(ColorValueSchema),
|
|
})),
|
|
});
|
|
const validateThemeJson = Compile(ThemeJsonSchema);
|
|
// ============================================================================
|
|
// Color Utilities
|
|
// ============================================================================
|
|
function hexToRgb(hex) {
|
|
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 };
|
|
}
|
|
// 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 findClosestCubeIndex(value) {
|
|
let minDist = Infinity;
|
|
let minIdx = 0;
|
|
for (let i = 0; i < CUBE_VALUES.length; i++) {
|
|
const dist = Math.abs(value - CUBE_VALUES[i]);
|
|
if (dist < minDist) {
|
|
minDist = dist;
|
|
minIdx = i;
|
|
}
|
|
}
|
|
return minIdx;
|
|
}
|
|
function findClosestGrayIndex(gray) {
|
|
let minDist = Infinity;
|
|
let minIdx = 0;
|
|
for (let i = 0; i < GRAY_VALUES.length; i++) {
|
|
const dist = Math.abs(gray - GRAY_VALUES[i]);
|
|
if (dist < minDist) {
|
|
minDist = dist;
|
|
minIdx = i;
|
|
}
|
|
}
|
|
return minIdx;
|
|
}
|
|
function colorDistance(r1, g1, b1, r2, g2, b2) {
|
|
// 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, g, b) {
|
|
// Find closest color in the 6x6x6 cube
|
|
const rIdx = findClosestCubeIndex(r);
|
|
const gIdx = findClosestCubeIndex(g);
|
|
const bIdx = findClosestCubeIndex(b);
|
|
const cubeR = CUBE_VALUES[rIdx];
|
|
const cubeG = CUBE_VALUES[gIdx];
|
|
const cubeB = CUBE_VALUES[bIdx];
|
|
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 = findClosestGrayIndex(gray);
|
|
const grayValue = GRAY_VALUES[grayIdx];
|
|
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) {
|
|
const { r, g, b } = hexToRgb(hex);
|
|
return rgbTo256(r, g, b);
|
|
}
|
|
function fgAnsi(color, mode) {
|
|
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, mode) {
|
|
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, vars, visited = new Set()) {
|
|
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, vars = {}) {
|
|
const resolved = {};
|
|
for (const [key, value] of Object.entries(colors)) {
|
|
resolved[key] = resolveVarRefs(value, vars);
|
|
}
|
|
return resolved;
|
|
}
|
|
function withThemeColorFallbacks(colors) {
|
|
return { ...colors, thinkingMax: colors.thinkingMax ?? colors.thinkingXhigh };
|
|
}
|
|
// ============================================================================
|
|
// Theme Class
|
|
// ============================================================================
|
|
export class Theme {
|
|
name;
|
|
sourcePath;
|
|
sourceInfo;
|
|
fgColors;
|
|
bgColors;
|
|
mode;
|
|
constructor(fgColors, bgColors, mode, options = {}) {
|
|
this.name = options.name;
|
|
this.sourcePath = options.sourcePath;
|
|
this.sourceInfo = options.sourceInfo;
|
|
this.mode = mode;
|
|
this.fgColors = new Map();
|
|
const colors = { ...fgColors, thinkingMax: fgColors.thinkingMax ?? fgColors.thinkingXhigh };
|
|
for (const [key, value] of Object.entries(colors)) {
|
|
this.fgColors.set(key, fgAnsi(value, mode));
|
|
}
|
|
this.bgColors = new Map();
|
|
for (const [key, value] of Object.entries(bgColors)) {
|
|
this.bgColors.set(key, bgAnsi(value, mode));
|
|
}
|
|
}
|
|
fg(color, text) {
|
|
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, text) {
|
|
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) {
|
|
return chalk.bold(text);
|
|
}
|
|
italic(text) {
|
|
return chalk.italic(text);
|
|
}
|
|
underline(text) {
|
|
return chalk.underline(text);
|
|
}
|
|
inverse(text) {
|
|
return chalk.inverse(text);
|
|
}
|
|
strikethrough(text) {
|
|
return chalk.strikethrough(text);
|
|
}
|
|
getFgAnsi(color) {
|
|
const ansi = this.fgColors.get(color);
|
|
if (!ansi)
|
|
throw new Error(`Unknown theme color: ${color}`);
|
|
return ansi;
|
|
}
|
|
getBgAnsi(color) {
|
|
const ansi = this.bgColors.get(color);
|
|
if (!ansi)
|
|
throw new Error(`Unknown theme background color: ${color}`);
|
|
return ansi;
|
|
}
|
|
getColorMode() {
|
|
return this.mode;
|
|
}
|
|
getThinkingBorderColor(level) {
|
|
// Map thinking levels to dedicated theme colors
|
|
switch (level) {
|
|
case "off":
|
|
return (str) => this.fg("thinkingOff", str);
|
|
case "minimal":
|
|
return (str) => this.fg("thinkingMinimal", str);
|
|
case "low":
|
|
return (str) => this.fg("thinkingLow", str);
|
|
case "medium":
|
|
return (str) => this.fg("thinkingMedium", str);
|
|
case "high":
|
|
return (str) => this.fg("thinkingHigh", str);
|
|
case "xhigh":
|
|
return (str) => this.fg("thinkingXhigh", str);
|
|
case "max":
|
|
return (str) => this.fg("thinkingMax", str);
|
|
default:
|
|
return (str) => this.fg("thinkingOff", str);
|
|
}
|
|
}
|
|
getBashModeBorderColor() {
|
|
return (str) => this.fg("bashMode", str);
|
|
}
|
|
}
|
|
// ============================================================================
|
|
// Theme Loading
|
|
// ============================================================================
|
|
let BUILTIN_THEMES;
|
|
function getBuiltinThemes() {
|
|
if (!BUILTIN_THEMES) {
|
|
const themesDir = getThemesDir();
|
|
const darkPath = path.join(themesDir, "dark.json");
|
|
const lightPath = path.join(themesDir, "light.json");
|
|
BUILTIN_THEMES = {
|
|
dark: JSON.parse(fs.readFileSync(darkPath, "utf-8")),
|
|
light: JSON.parse(fs.readFileSync(lightPath, "utf-8")),
|
|
};
|
|
}
|
|
return BUILTIN_THEMES;
|
|
}
|
|
export function getAvailableThemes() {
|
|
return getAvailableThemesWithPaths().map(({ name }) => name);
|
|
}
|
|
export function getAvailableThemesWithPaths() {
|
|
const themesDir = getThemesDir();
|
|
const result = [];
|
|
const seen = new Set();
|
|
const addTheme = (themeInfo) => {
|
|
if (seen.has(themeInfo.name)) {
|
|
return;
|
|
}
|
|
seen.add(themeInfo.name);
|
|
result.push(themeInfo);
|
|
};
|
|
// Built-in themes
|
|
for (const name of Object.keys(getBuiltinThemes())) {
|
|
addTheme({ name, path: path.join(themesDir, `${name}.json`) });
|
|
}
|
|
// Custom themes
|
|
for (const themeInfo of getCustomThemeInfos()) {
|
|
addTheme(themeInfo);
|
|
}
|
|
for (const [name, theme] of registeredThemes.entries()) {
|
|
addTheme({ name, path: theme.sourcePath });
|
|
}
|
|
return result.sort((a, b) => a.name.localeCompare(b.name));
|
|
}
|
|
function getCustomThemeInfos() {
|
|
const customThemesDir = getCustomThemesDir();
|
|
const result = [];
|
|
if (!fs.existsSync(customThemesDir)) {
|
|
return result;
|
|
}
|
|
for (const file of fs.readdirSync(customThemesDir)) {
|
|
if (!file.endsWith(".json")) {
|
|
continue;
|
|
}
|
|
const themePath = path.join(customThemesDir, file);
|
|
try {
|
|
const customTheme = loadThemeFromPath(themePath);
|
|
if (customTheme.name) {
|
|
result.push({ name: customTheme.name, path: themePath });
|
|
}
|
|
}
|
|
catch {
|
|
// Invalid themes are ignored here; the resource loader reports them
|
|
// during normal startup/reload.
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
function assertThemeNameIsValid(name) {
|
|
if (name.includes("/")) {
|
|
throw new Error(`Invalid theme name "${name}": theme names cannot contain "/" because it is reserved for automatic light/dark theme settings.`);
|
|
}
|
|
}
|
|
function parseThemeJson(label, json) {
|
|
if (!validateThemeJson.Check(json)) {
|
|
const errors = Array.from(validateThemeJson.Errors(json));
|
|
const missingColors = new Set();
|
|
const otherErrors = [];
|
|
for (const error of errors) {
|
|
if (error.keyword === "required" && error.instancePath === "/colors") {
|
|
const requiredProperties = error.params.requiredProperties;
|
|
for (const requiredProperty of requiredProperties ?? []) {
|
|
missingColors.add(requiredProperty);
|
|
}
|
|
continue;
|
|
}
|
|
const path = error.instancePath || "/";
|
|
otherErrors.push(` - ${path}: ${error.message}`);
|
|
}
|
|
let errorMessage = `Invalid theme "${label}":\n`;
|
|
if (missingColors.size > 0) {
|
|
errorMessage += "\nMissing required color tokens:\n";
|
|
errorMessage += Array.from(missingColors)
|
|
.sort()
|
|
.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);
|
|
}
|
|
const themeJson = json;
|
|
assertThemeNameIsValid(themeJson.name);
|
|
return themeJson;
|
|
}
|
|
function parseThemeJsonContent(label, content) {
|
|
let json;
|
|
try {
|
|
json = JSON.parse(content);
|
|
}
|
|
catch (error) {
|
|
throw new Error(`Failed to parse theme ${label}: ${error}`);
|
|
}
|
|
return parseThemeJson(label, json);
|
|
}
|
|
function loadThemeJson(name) {
|
|
const builtinThemes = getBuiltinThemes();
|
|
if (name in builtinThemes) {
|
|
return builtinThemes[name];
|
|
}
|
|
const registeredTheme = registeredThemes.get(name);
|
|
if (registeredTheme?.sourcePath) {
|
|
const content = fs.readFileSync(registeredTheme.sourcePath, "utf-8");
|
|
return parseThemeJsonContent(registeredTheme.sourcePath, content);
|
|
}
|
|
if (registeredTheme) {
|
|
throw new Error(`Theme "${name}" does not have a source path for export`);
|
|
}
|
|
const customThemesDir = getCustomThemesDir();
|
|
const themePath = path.join(customThemesDir, `${name}.json`);
|
|
if (!fs.existsSync(themePath)) {
|
|
throw new Error(`Theme not found: ${name}`);
|
|
}
|
|
const content = fs.readFileSync(themePath, "utf-8");
|
|
return parseThemeJsonContent(name, content);
|
|
}
|
|
function createTheme(themeJson, mode, sourcePath) {
|
|
const colorMode = mode ?? (getCapabilities().trueColor ? "truecolor" : "256color");
|
|
const resolvedColors = resolveThemeColors(withThemeColorFallbacks(themeJson.colors), themeJson.vars);
|
|
const fgColors = {};
|
|
const bgColors = {};
|
|
const bgColorKeys = new Set([
|
|
"selectedBg",
|
|
"userMessageBg",
|
|
"customMessageBg",
|
|
"toolPendingBg",
|
|
"toolSuccessBg",
|
|
"toolErrorBg",
|
|
]);
|
|
for (const [key, value] of Object.entries(resolvedColors)) {
|
|
if (bgColorKeys.has(key)) {
|
|
bgColors[key] = value;
|
|
}
|
|
else {
|
|
fgColors[key] = value;
|
|
}
|
|
}
|
|
return new Theme(fgColors, bgColors, colorMode, {
|
|
name: themeJson.name,
|
|
sourcePath,
|
|
});
|
|
}
|
|
export function loadThemeFromPath(themePath, mode) {
|
|
const content = fs.readFileSync(themePath, "utf-8");
|
|
const themeJson = parseThemeJsonContent(themePath, content);
|
|
return createTheme(themeJson, mode, themePath);
|
|
}
|
|
function loadTheme(name, mode) {
|
|
const registeredTheme = registeredThemes.get(name);
|
|
if (registeredTheme) {
|
|
return registeredTheme;
|
|
}
|
|
const themeJson = loadThemeJson(name);
|
|
return createTheme(themeJson, mode);
|
|
}
|
|
export function getThemeByName(name) {
|
|
try {
|
|
return loadTheme(name);
|
|
}
|
|
catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
export function parseAutoThemeSetting(themeSetting) {
|
|
if (!themeSetting)
|
|
return undefined;
|
|
const slashIndex = themeSetting.indexOf("/");
|
|
if (slashIndex === -1 || themeSetting.indexOf("/", slashIndex + 1) !== -1) {
|
|
return undefined;
|
|
}
|
|
const lightTheme = themeSetting.slice(0, slashIndex).trim();
|
|
const darkTheme = themeSetting.slice(slashIndex + 1).trim();
|
|
if (!lightTheme || !darkTheme) {
|
|
return undefined;
|
|
}
|
|
return { lightTheme, darkTheme };
|
|
}
|
|
export function resolveThemeSetting(themeSetting, terminalTheme) {
|
|
const autoTheme = parseAutoThemeSetting(themeSetting);
|
|
if (autoTheme) {
|
|
return terminalTheme === "light" ? autoTheme.lightTheme : autoTheme.darkTheme;
|
|
}
|
|
if (themeSetting?.includes("/"))
|
|
return undefined;
|
|
if (typeof themeSetting === "string")
|
|
return themeSetting;
|
|
return undefined;
|
|
}
|
|
function getColorFgBgBackgroundIndex(colorfgbg) {
|
|
const parts = colorfgbg.split(";");
|
|
for (let i = parts.length - 1; i >= 0; i--) {
|
|
const bg = parseInt(parts[i].trim(), 10);
|
|
if (Number.isInteger(bg) && bg >= 0 && bg <= 255) {
|
|
return bg;
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
function getRgbColorLuminance({ r, g, b }) {
|
|
const toLinear = (channel) => {
|
|
const value = channel / 255;
|
|
return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
|
|
};
|
|
return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);
|
|
}
|
|
function getAnsiColorLuminance(index) {
|
|
return getRgbColorLuminance(hexToRgb(ansi256ToHex(index)));
|
|
}
|
|
export function getThemeForRgbColor(rgb) {
|
|
return getRgbColorLuminance(rgb) >= 0.5 ? "light" : "dark";
|
|
}
|
|
export function detectTerminalBackgroundFromEnv(options = {}) {
|
|
const env = options.env ?? process.env;
|
|
const colorfgbg = env.COLORFGBG || "";
|
|
const bg = getColorFgBgBackgroundIndex(colorfgbg);
|
|
if (bg !== undefined) {
|
|
return {
|
|
theme: getAnsiColorLuminance(bg) >= 0.5 ? "light" : "dark",
|
|
source: "COLORFGBG",
|
|
detail: `background color index ${bg}`,
|
|
confidence: "high",
|
|
};
|
|
}
|
|
return {
|
|
theme: "dark",
|
|
source: "fallback",
|
|
detail: "no terminal background hint found",
|
|
confidence: "low",
|
|
};
|
|
}
|
|
export async function detectTerminalBackgroundTheme({ ui, timeoutMs, env, }) {
|
|
try {
|
|
const rgb = await ui.queryTerminalBackgroundColor({ timeoutMs });
|
|
if (rgb) {
|
|
return {
|
|
theme: getThemeForRgbColor(rgb),
|
|
source: "terminal background",
|
|
detail: `OSC 11 background rgb(${rgb.r}, ${rgb.g}, ${rgb.b})`,
|
|
confidence: "high",
|
|
};
|
|
}
|
|
}
|
|
catch {
|
|
// Fall back to environment-based detection when the terminal query fails.
|
|
}
|
|
return detectTerminalBackgroundFromEnv({ env });
|
|
}
|
|
export async function detectTerminalThemeForAuto({ ui, timeoutMs, env, }) {
|
|
try {
|
|
const colorScheme = await ui.queryTerminalColorScheme?.({ timeoutMs });
|
|
if (colorScheme)
|
|
return colorScheme;
|
|
}
|
|
catch {
|
|
// Fall back to OSC 11 / COLORFGBG detection when color-scheme DSR is unsupported.
|
|
}
|
|
return (await detectTerminalBackgroundTheme({ ui, timeoutMs, env })).theme;
|
|
}
|
|
export function getDefaultTheme() {
|
|
return detectTerminalBackgroundFromEnv().theme;
|
|
}
|
|
// ============================================================================
|
|
// Global Theme Instance
|
|
// ============================================================================
|
|
// Use globalThis to share theme across module loaders (tsx + jiti in dev mode)
|
|
const THEME_KEY = Symbol.for("@earendil-works/pi-coding-agent:theme");
|
|
const THEME_KEY_OLD = Symbol.for("@mariozechner/pi-coding-agent:theme");
|
|
// Export theme as a getter that reads from globalThis
|
|
// This ensures all module instances (tsx, jiti) see the same theme
|
|
export const theme = new Proxy({}, {
|
|
get(_target, prop) {
|
|
const t = globalThis[THEME_KEY];
|
|
if (!t)
|
|
throw new Error("Theme not initialized. Call initTheme() first.");
|
|
return t[prop];
|
|
},
|
|
});
|
|
function setGlobalTheme(t) {
|
|
globalThis[THEME_KEY] = t;
|
|
globalThis[THEME_KEY_OLD] = t;
|
|
}
|
|
let currentThemeName;
|
|
let themeWatcher;
|
|
let themeReloadTimer;
|
|
let onThemeChangeCallback;
|
|
const registeredThemes = new Map();
|
|
export function setRegisteredThemes(themes) {
|
|
registeredThemes.clear();
|
|
for (const theme of themes) {
|
|
if (theme.name) {
|
|
assertThemeNameIsValid(theme.name);
|
|
registeredThemes.set(theme.name, theme);
|
|
}
|
|
}
|
|
}
|
|
export function initTheme(themeName, enableWatcher = false) {
|
|
const name = themeName ?? getDefaultTheme();
|
|
currentThemeName = name;
|
|
try {
|
|
setGlobalTheme(loadTheme(name));
|
|
if (enableWatcher) {
|
|
startThemeWatcher();
|
|
}
|
|
}
|
|
catch (_error) {
|
|
// Theme is invalid - fall back to dark theme silently
|
|
currentThemeName = "dark";
|
|
setGlobalTheme(loadTheme("dark"));
|
|
// Don't start watcher for fallback theme
|
|
}
|
|
}
|
|
export function setTheme(name, enableWatcher = false) {
|
|
currentThemeName = name;
|
|
try {
|
|
setGlobalTheme(loadTheme(name));
|
|
if (enableWatcher) {
|
|
startThemeWatcher();
|
|
}
|
|
if (onThemeChangeCallback) {
|
|
onThemeChangeCallback();
|
|
}
|
|
return { success: true };
|
|
}
|
|
catch (error) {
|
|
// Theme is invalid - fall back to dark theme
|
|
currentThemeName = "dark";
|
|
setGlobalTheme(loadTheme("dark"));
|
|
// Don't start watcher for fallback theme
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
};
|
|
}
|
|
}
|
|
export function setThemeInstance(themeInstance) {
|
|
setGlobalTheme(themeInstance);
|
|
currentThemeName = "<in-memory>";
|
|
stopThemeWatcher(); // Can't watch a direct instance
|
|
if (onThemeChangeCallback) {
|
|
onThemeChangeCallback();
|
|
}
|
|
}
|
|
export function onThemeChange(callback) {
|
|
onThemeChangeCallback = callback;
|
|
}
|
|
function startThemeWatcher() {
|
|
stopThemeWatcher();
|
|
// Only watch if it's a custom theme (not built-in)
|
|
if (!currentThemeName || currentThemeName === "dark" || currentThemeName === "light") {
|
|
return;
|
|
}
|
|
const customThemesDir = getCustomThemesDir();
|
|
const watchedThemeName = currentThemeName;
|
|
const watchedFileName = `${watchedThemeName}.json`;
|
|
const themeFile = path.join(customThemesDir, watchedFileName);
|
|
// Only watch if the file exists
|
|
if (!fs.existsSync(themeFile)) {
|
|
return;
|
|
}
|
|
const scheduleReload = () => {
|
|
if (themeReloadTimer) {
|
|
clearTimeout(themeReloadTimer);
|
|
}
|
|
themeReloadTimer = setTimeout(() => {
|
|
themeReloadTimer = undefined;
|
|
// Ignore stale timers after switching themes or stopping the watcher
|
|
if (currentThemeName !== watchedThemeName) {
|
|
return;
|
|
}
|
|
// Keep the last successfully loaded theme active if the file is temporarily missing
|
|
if (!fs.existsSync(themeFile)) {
|
|
return;
|
|
}
|
|
try {
|
|
// Reload the theme from disk and refresh the registry cache
|
|
const reloadedTheme = loadThemeFromPath(themeFile);
|
|
registeredThemes.set(watchedThemeName, reloadedTheme);
|
|
setGlobalTheme(reloadedTheme);
|
|
// Notify callback (to invalidate UI)
|
|
if (onThemeChangeCallback) {
|
|
onThemeChangeCallback();
|
|
}
|
|
}
|
|
catch (_error) {
|
|
// Ignore errors (file might be in invalid state while being edited)
|
|
}
|
|
}, 100);
|
|
};
|
|
themeWatcher =
|
|
watchWithErrorHandler(customThemesDir, (_eventType, filename) => {
|
|
if (currentThemeName !== watchedThemeName) {
|
|
return;
|
|
}
|
|
if (!filename) {
|
|
scheduleReload();
|
|
return;
|
|
}
|
|
if (filename !== watchedFileName) {
|
|
return;
|
|
}
|
|
scheduleReload();
|
|
}, () => {
|
|
closeWatcher(themeWatcher);
|
|
themeWatcher = undefined;
|
|
}) ?? undefined;
|
|
}
|
|
export function stopThemeWatcher() {
|
|
if (themeReloadTimer) {
|
|
clearTimeout(themeReloadTimer);
|
|
themeReloadTimer = undefined;
|
|
}
|
|
closeWatcher(themeWatcher);
|
|
themeWatcher = undefined;
|
|
}
|
|
// ============================================================================
|
|
// HTML Export Helpers
|
|
// ============================================================================
|
|
/**
|
|
* Convert a 256-color index to hex string.
|
|
* Indices 0-15: basic colors (approximate)
|
|
* Indices 16-231: 6x6x6 color cube
|
|
* Indices 232-255: grayscale ramp
|
|
*/
|
|
function ansi256ToHex(index) {
|
|
// Basic colors (0-15) - approximate common terminal values
|
|
const basicColors = [
|
|
"#000000",
|
|
"#800000",
|
|
"#008000",
|
|
"#808000",
|
|
"#000080",
|
|
"#800080",
|
|
"#008080",
|
|
"#c0c0c0",
|
|
"#808080",
|
|
"#ff0000",
|
|
"#00ff00",
|
|
"#ffff00",
|
|
"#0000ff",
|
|
"#ff00ff",
|
|
"#00ffff",
|
|
"#ffffff",
|
|
];
|
|
if (index < 16) {
|
|
return basicColors[index];
|
|
}
|
|
// Color cube (16-231): 6x6x6 = 216 colors
|
|
if (index < 232) {
|
|
const cubeIndex = index - 16;
|
|
const r = Math.floor(cubeIndex / 36);
|
|
const g = Math.floor((cubeIndex % 36) / 6);
|
|
const b = cubeIndex % 6;
|
|
const toHex = (n) => (n === 0 ? 0 : 55 + n * 40).toString(16).padStart(2, "0");
|
|
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
|
|
}
|
|
// Grayscale (232-255): 24 shades
|
|
const gray = 8 + (index - 232) * 10;
|
|
const grayHex = gray.toString(16).padStart(2, "0");
|
|
return `#${grayHex}${grayHex}${grayHex}`;
|
|
}
|
|
/**
|
|
* Get resolved theme colors as CSS-compatible hex strings.
|
|
* Used by HTML export to generate CSS custom properties.
|
|
*/
|
|
export function getResolvedThemeColors(themeName) {
|
|
const name = themeName ?? currentThemeName ?? getDefaultTheme();
|
|
const isLight = name === "light";
|
|
const themeJson = loadThemeJson(name);
|
|
const resolved = resolveThemeColors(withThemeColorFallbacks(themeJson.colors), themeJson.vars);
|
|
// Default text color for empty values (terminal uses default fg color)
|
|
const defaultText = isLight ? "#000000" : "#e5e5e7";
|
|
const cssColors = {};
|
|
for (const [key, value] of Object.entries(resolved)) {
|
|
if (typeof value === "number") {
|
|
cssColors[key] = ansi256ToHex(value);
|
|
}
|
|
else if (value === "") {
|
|
// Empty means default terminal color - use sensible fallback for HTML
|
|
cssColors[key] = defaultText;
|
|
}
|
|
else {
|
|
cssColors[key] = value;
|
|
}
|
|
}
|
|
return cssColors;
|
|
}
|
|
/**
|
|
* Check if a theme is a "light" theme (for CSS that needs light/dark variants).
|
|
*/
|
|
export function isLightTheme(themeName) {
|
|
// Currently just check the name - could be extended to analyze colors
|
|
return themeName === "light";
|
|
}
|
|
/**
|
|
* Get explicit export colors from theme JSON, if specified.
|
|
* Returns undefined for each color that isn't explicitly set.
|
|
*/
|
|
export function getThemeExportColors(themeName) {
|
|
const name = themeName ?? currentThemeName ?? getDefaultTheme();
|
|
try {
|
|
const themeJson = loadThemeJson(name);
|
|
const exportSection = themeJson.export;
|
|
if (!exportSection)
|
|
return {};
|
|
const vars = themeJson.vars ?? {};
|
|
const resolve = (value) => {
|
|
if (value === undefined)
|
|
return undefined;
|
|
const resolved = resolveVarRefs(value, vars);
|
|
if (typeof resolved === "number")
|
|
return ansi256ToHex(resolved);
|
|
if (resolved === "")
|
|
return undefined;
|
|
return resolved;
|
|
};
|
|
return {
|
|
pageBg: resolve(exportSection.pageBg),
|
|
cardBg: resolve(exportSection.cardBg),
|
|
infoBg: resolve(exportSection.infoBg),
|
|
};
|
|
}
|
|
catch {
|
|
return {};
|
|
}
|
|
}
|
|
let cachedHighlightThemeFor;
|
|
let cachedCliHighlightTheme;
|
|
function buildCliHighlightTheme(t) {
|
|
return {
|
|
keyword: (s) => t.fg("syntaxKeyword", s),
|
|
built_in: (s) => t.fg("syntaxType", s),
|
|
literal: (s) => t.fg("syntaxNumber", s),
|
|
number: (s) => t.fg("syntaxNumber", s),
|
|
regexp: (s) => t.fg("syntaxString", s),
|
|
string: (s) => t.fg("syntaxString", s),
|
|
comment: (s) => t.fg("syntaxComment", s),
|
|
doctag: (s) => t.fg("syntaxComment", s),
|
|
meta: (s) => t.fg("muted", s),
|
|
function: (s) => t.fg("syntaxFunction", s),
|
|
title: (s) => t.fg("syntaxFunction", s),
|
|
class: (s) => t.fg("syntaxType", s),
|
|
type: (s) => t.fg("syntaxType", s),
|
|
tag: (s) => t.fg("syntaxPunctuation", s),
|
|
name: (s) => t.fg("syntaxKeyword", s),
|
|
attr: (s) => t.fg("syntaxVariable", s),
|
|
variable: (s) => t.fg("syntaxVariable", s),
|
|
params: (s) => t.fg("syntaxVariable", s),
|
|
operator: (s) => t.fg("syntaxOperator", s),
|
|
punctuation: (s) => t.fg("syntaxPunctuation", s),
|
|
emphasis: (s) => t.italic(s),
|
|
strong: (s) => t.bold(s),
|
|
link: (s) => t.underline(s),
|
|
addition: (s) => t.fg("toolDiffAdded", s),
|
|
deletion: (s) => t.fg("toolDiffRemoved", s),
|
|
};
|
|
}
|
|
function getCliHighlightTheme(t) {
|
|
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, lang) {
|
|
// 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) => theme.fg("mdCodeBlock", line));
|
|
}
|
|
const opts = {
|
|
language: validLang,
|
|
ignoreIllegals: true,
|
|
theme: getCliHighlightTheme(theme),
|
|
};
|
|
try {
|
|
return highlight(code, opts).split("\n");
|
|
}
|
|
catch {
|
|
return code.split("\n");
|
|
}
|
|
}
|
|
/**
|
|
* Get language identifier from file path extension.
|
|
*/
|
|
export function getLanguageFromPath(filePath) {
|
|
const ext = filePath.split(".").pop()?.toLowerCase();
|
|
if (!ext)
|
|
return undefined;
|
|
const extToLang = {
|
|
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];
|
|
}
|
|
export function getMarkdownTheme() {
|
|
return {
|
|
heading: (text) => theme.fg("mdHeading", text),
|
|
link: (text) => theme.fg("mdLink", text),
|
|
linkUrl: (text) => theme.fg("mdLinkUrl", text),
|
|
code: (text) => theme.fg("mdCode", text),
|
|
codeBlock: (text) => theme.fg("mdCodeBlock", text),
|
|
codeBlockBorder: (text) => theme.fg("mdCodeBlockBorder", text),
|
|
quote: (text) => theme.fg("mdQuote", text),
|
|
quoteBorder: (text) => theme.fg("mdQuoteBorder", text),
|
|
hr: (text) => theme.fg("mdHr", text),
|
|
listBullet: (text) => theme.fg("mdListBullet", text),
|
|
bold: (text) => theme.bold(text),
|
|
italic: (text) => theme.italic(text),
|
|
underline: (text) => theme.underline(text),
|
|
strikethrough: (text) => chalk.strikethrough(text),
|
|
highlightCode: (code, lang) => {
|
|
// 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) => theme.fg("mdCodeBlock", line));
|
|
}
|
|
const opts = {
|
|
language: validLang,
|
|
ignoreIllegals: true,
|
|
theme: getCliHighlightTheme(theme),
|
|
};
|
|
try {
|
|
return highlight(code, opts).split("\n");
|
|
}
|
|
catch {
|
|
return code.split("\n").map((line) => theme.fg("mdCodeBlock", line));
|
|
}
|
|
},
|
|
};
|
|
}
|
|
export function getSelectListTheme() {
|
|
return {
|
|
selectedPrefix: (text) => theme.fg("accent", text),
|
|
selectedText: (text) => theme.fg("accent", text),
|
|
description: (text) => theme.fg("muted", text),
|
|
scrollInfo: (text) => theme.fg("muted", text),
|
|
noMatch: (text) => theme.fg("muted", text),
|
|
};
|
|
}
|
|
export function getEditorTheme() {
|
|
return {
|
|
borderColor: (text) => theme.fg("borderMuted", text),
|
|
selectList: getSelectListTheme(),
|
|
};
|
|
}
|
|
export function getSettingsListTheme() {
|
|
return {
|
|
label: (text, selected) => (selected ? theme.fg("accent", text) : text),
|
|
value: (text, selected) => (selected ? theme.fg("accent", text) : theme.fg("muted", text)),
|
|
description: (text) => theme.fg("dim", text),
|
|
cursor: theme.fg("accent", "→ "),
|
|
hint: (text) => theme.fg("dim", text),
|
|
};
|
|
}
|
|
//# sourceMappingURL=theme.js.map
|