Something went wrong. Try again.
fork of prime agent with changes i want but are not upstreamable probqbly (and some fixes)
Something went wrong. Try again.
46 kB · 1436 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437import * as fs from "node:fs";import * as path from "node:path";import { bestAnsiColor, blendColor, type EditorTheme, getDefaultTerminalColors, getTerminalBackgroundKind, isLightColor, type MarkdownTheme, onDefaultTerminalColorsChange, type Rgb, rgbTo256, type SelectListTheme,} from "@earendil-works/pi-tui";import chalk from "chalk";import { type Static, type TProperties, Type } from "typebox";import type { Validator } from "typebox/compile";import { getCustomThemesDir, getThemesDir } from "../../../config.js";import type { SourceInfo } from "../../../core/source-info.js";import { closeWatcher, watchWithErrorHandler } from "../../../utils/fs-watch.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, thinkingText: ColorValueSchema, // Backgrounds & Content Text (12 colors) selectedBg: ColorValueSchema, userMessageBg: ColorValueSchema, userMessageText: ColorValueSchema, customMessageBg: ColorValueSchema, customMessageText: ColorValueSchema, customMessageLabel: ColorValueSchema, refinementHeader: Type.Optional(ColorValueSchema), refinementSummary: Type.Optional(ColorValueSchema), toolPendingBg: ColorValueSchema, toolSuccessBg: ColorValueSchema, toolErrorBg: ColorValueSchema, toolDiffAddedBg: ColorValueSchema, toolDiffRemovedBg: ColorValueSchema, toolPanelBg: ColorValueSchema, toolTitle: ColorValueSchema, toolOutput: ColorValueSchema, // Markdown (11 colors) mdBody: Type.Optional(ColorValueSchema), 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, toolDiffText: 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, // Bash Mode (1 color) bashMode: ColorValueSchema, }), export: Type.Optional( Type.Object({ pageBg: Type.Optional(ColorValueSchema), cardBg: Type.Optional(ColorValueSchema), infoBg: Type.Optional(ColorValueSchema), }), ),});
type ThemeJson = Static<typeof ThemeJsonSchema>;
// typebox/compile costs ~300ms to import, so the theme validator loads lazily.// Built-in themes never validate; custom themes get a minimal structural check// until the validator is ready (preloaded from initTheme), after which full// schema validation applies (e.g. on watcher reloads and setTheme).let validateThemeJson: Validator<TProperties, typeof ThemeJsonSchema> | undefined;let themeValidatorPromise: Promise<void> | undefined;
export function preloadThemeValidator(): Promise<void> { themeValidatorPromise ??= import("typebox/compile").then(({ Compile }) => { validateThemeJson = Compile(ThemeJsonSchema); }); return themeValidatorPromise;}
export type ThemeColor = | "accent" | "border" | "borderAccent" | "borderMuted" | "success" | "error" | "warning" | "muted" | "dim" | "text" | "thinkingText" | "userMessageText" | "customMessageText" | "customMessageLabel" | "refinementHeader" | "refinementSummary" | "toolTitle" | "toolOutput" | "mdBody" | "mdHeading" | "mdLink" | "mdLinkUrl" | "mdCode" | "mdCodeBlock" | "mdCodeBlockBorder" | "mdQuote" | "mdQuoteBorder" | "mdHr" | "mdListBullet" | "toolDiffAdded" | "toolDiffRemoved" | "toolDiffText" | "toolDiffContext" | "syntaxComment" | "syntaxKeyword" | "syntaxFunction" | "syntaxVariable" | "syntaxString" | "syntaxNumber" | "syntaxType" | "syntaxOperator" | "syntaxPunctuation" | "thinkingOff" | "thinkingMinimal" | "thinkingLow" | "thinkingMedium" | "thinkingHigh" | "thinkingXhigh" | "bashMode";
export type ThemeBg = | "selectedBg" | "userMessageBg" | "customMessageBg" | "toolPendingBg" | "toolSuccessBg" | "toolErrorBg" | "toolDiffAddedBg" | "toolDiffRemovedBg" | "toolPanelBg";
type ColorMode = "truecolor" | "256color";type RefinementColor = "refinementHeader" | "refinementSummary";
function refinementColors(light: boolean): Record<RefinementColor, string> { return light ? { refinementHeader: "#7146ab", refinementSummary: "#8a70ad" } : { refinementHeader: "#9575cd", refinementSummary: "#b7a1d6" };}
const ADAPTIVE_LIGHT_BG_ACCENT: Rgb = { r: 0, g: 95, b: 135 };const SOFT_SELECTION_ALPHA = 0.5;const EFFORT_SQUARE_DARK_COLOR = "#a78bfa";const EFFORT_SQUARE_LIGHT_COLOR = "#8b5cf6";const SURFACE_MIN_LUMINANCE_DELTA = 12;const SURFACE_CONTRAST_ALPHA = 0.08;// Selection rows must stand out clearly, much more than passive surfaces.const SELECTION_MIN_LUMINANCE_DELTA = 28;const SELECTION_MAX_BLEND_ALPHA = 0.5;const SELECTION_BLEND_STEP = 0.05;const BLACK: Rgb = { r: 0, g: 0, b: 0 };const WHITE: Rgb = { r: 255, g: 255, b: 255 };const CUBE_VALUES = [0, 95, 135, 175, 215, 255] as const;
// ============================================================================// 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 || ""; // Fall back to 256color for truly limited terminals if (term === "dumb" || term === "" || term === "linux") { return "256color"; } // Terminal.app also doesn't support truecolor if (process.env.TERM_PROGRAM === "Apple_Terminal") { return "256color"; } // tmux reports TERM=screen* but forwards 24-bit color, so treat it as // truecolor-capable; only genuine GNU screen (no $TMUX) falls back. const inTmux = process.env.TMUX !== undefined || term.startsWith("tmux"); if (!inTmux && (term === "screen" || term.startsWith("screen-") || term.startsWith("screen."))) { return "256color"; } // Assume truecolor for everything else - virtually all modern terminals support it return "truecolor";}
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 ansi256ToRgb(index: number): Rgb | undefined { if (index < 0 || index > 255) { return undefined; } if (index < 16) { const basicColors: Rgb[] = [ { r: 0, g: 0, b: 0 }, { r: 128, g: 0, b: 0 }, { r: 0, g: 128, b: 0 }, { r: 128, g: 128, b: 0 }, { r: 0, g: 0, b: 128 }, { r: 128, g: 0, b: 128 }, { r: 0, g: 128, b: 128 }, { r: 192, g: 192, b: 192 }, { r: 128, g: 128, b: 128 }, { r: 255, g: 0, b: 0 }, { r: 0, g: 255, b: 0 }, { r: 255, g: 255, b: 0 }, { r: 0, g: 0, b: 255 }, { r: 255, g: 0, b: 255 }, { r: 0, g: 255, b: 255 }, { r: 255, g: 255, b: 255 }, ]; return basicColors[index]; } if (index >= 232) { const value = 8 + (index - 232) * 10; return { r: value, g: value, b: value }; } const cubeIndex = index - 16; return { r: CUBE_VALUES[Math.floor(cubeIndex / 36)]!, g: CUBE_VALUES[Math.floor((cubeIndex % 36) / 6)]!, b: CUBE_VALUES[cubeIndex % 6]!, };}
function colorValueToRgb(value: string | number | undefined): Rgb | undefined { if (typeof value === "number") { return ansi256ToRgb(value); } if (!value || !value.startsWith("#")) { return undefined; } try { return hexToRgb(value); } catch { // Malformed theme colors (e.g. 3-character hex shorthand) should not crash rendering. return undefined; }}
function luminance(rgb: Rgb): number { return 0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b;}
function hexTo256(hex: string): number { return rgbTo256(hexToRgb(hex));}
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 { readonly name?: string; readonly sourcePath?: string; sourceInfo?: SourceInfo; private fgColors: Map<ThemeColor, string>; private bgColors: Map<ThemeBg, string>; private bgColorValues: Map<ThemeBg, string | number>; private mode: ColorMode;
constructor( fgColors: Record<Exclude<ThemeColor, RefinementColor>, string | number> & Partial<Record<RefinementColor, string | number>>, bgColors: Record<ThemeBg, string | number>, mode: ColorMode, options: { name?: string; sourcePath?: string; sourceInfo?: SourceInfo } = {}, ) { this.name = options.name; this.sourcePath = options.sourcePath; this.sourceInfo = options.sourceInfo; this.mode = mode; this.fgColors = new Map(); for (const [key, value] of Object.entries(refinementColors(options.name === "light"))) { this.fgColors.set(key as RefinementColor, fgAnsi(value, mode)); } 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)); } this.bgColorValues = new Map(Object.entries(bgColors) as [ThemeBg, string | number][]); }
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 }
/** Active color depth (truecolor vs 256color). */ get colorMode(): ColorMode { return this.mode; }
getEditorBackgroundColor(): ((str: string) => string) | undefined { return this.surfaceBackgroundColor("userMessageBg"); }
getUserMessageBackgroundColor(): (str: string) => string { return this.surfaceBackgroundColor("userMessageBg"); }
getPopupBackgroundColor(): (str: string) => string { return this.surfaceBackgroundColor("toolPanelBg"); }
getSelectionBackgroundColor(): (str: string) => string { const terminalBg = getDefaultTerminalColors()?.background; const selectedBgValue = this.bgColorValues.get("selectedBg"); // Basic ANSI colors (0-15) are terminal-defined; their rendered color is // unknown, so no reliable contrast computation (or blend base) exists. if (!terminalBg || (typeof selectedBgValue === "number" && selectedBgValue < 16)) { return (str: string) => this.bg("selectedBg", str); } // An empty selectedBg renders as the terminal background itself; blend // from there so the selection still gets an explicit contrasting color. const selectionRgb = colorValueToRgb(selectedBgValue) ?? terminalBg;
// Compare against what actually renders: on 256-color terminals the // palette quantization can erase the contrast of the configured value, // and gives the blends a quantized baseline to beat. const terminalLuminance = luminance(terminalBg); const renderedSelection = colorValueToRgb(bestAnsiColor(selectionRgb, this.mode)) ?? selectionRgb; const selectionLuminance = luminance(renderedSelection); const delta = Math.abs(selectionLuminance - terminalLuminance); if (delta >= SELECTION_MIN_LUMINANCE_DELTA) { return (str: string) => this.bg("selectedBg", str); }
// Blend away from the terminal background toward the endpoint on the // selection's side of it. When that direction cannot reach the minimum // delta (e.g. the selection already sits at the endpoint), fall back to // the opposite endpoint, which requires crossing the background. const endpoints = selectionLuminance >= terminalLuminance ? [WHITE, BLACK] : [BLACK, WHITE]; let bestColor: string | number | undefined; let bestDelta = delta; for (const top of endpoints) { const spread = luminance(top) - selectionLuminance; if (spread === 0) { continue; } const targetLuminance = terminalLuminance + Math.sign(spread) * SELECTION_MIN_LUMINANCE_DELTA; const baseAlpha = Math.min(SELECTION_MAX_BLEND_ALPHA, (targetLuminance - selectionLuminance) / spread); // Quantize before evaluating: on 256-color terminals the palette // rounding can otherwise erase the delta the blend was chosen for. // If the direct hit undershoots, keep stepping toward the cap — // a stronger blend may quantize to a palette color that passes. const alphas: number[] = []; for (let alpha = baseAlpha; alpha < SELECTION_MAX_BLEND_ALPHA; alpha += SELECTION_BLEND_STEP) { alphas.push(alpha); } alphas.push(SELECTION_MAX_BLEND_ALPHA); for (const alpha of alphas) { const adjustedColor = bestAnsiColor(blendColor(top, selectionRgb, alpha), this.mode); const adjustedRgb = colorValueToRgb(adjustedColor); if (adjustedColor === "" || !adjustedRgb) { continue; } const resultDelta = Math.abs(luminance(adjustedRgb) - terminalLuminance); if (resultDelta >= SELECTION_MIN_LUMINANCE_DELTA - 1) { bestColor = adjustedColor; bestDelta = resultDelta; break; } if (resultDelta > bestDelta) { bestColor = adjustedColor; bestDelta = resultDelta; } } if (bestColor !== undefined && bestDelta >= SELECTION_MIN_LUMINANCE_DELTA - 1) { break; } } if (bestColor === undefined) { return (str: string) => this.bg("selectedBg", str); } const ansi = bgAnsi(bestColor, this.mode); return (str: string) => `${ansi}${str}\x1b[49m`; }
/** * Row-selection highlight for menu rows: the selection color blended halfway * toward the editor surface, a softer band than the full selection block. */ getSoftSelectionBackgroundColor(): (str: string) => string { const terminalBg = getDefaultTerminalColors()?.background; const selectedBgValue = this.bgColorValues.get("selectedBg"); // Basic ANSI colors (0-15) are terminal-defined; their rendered color is // unknown, so no reliable blend base exists. if (!terminalBg || (typeof selectedBgValue === "number" && selectedBgValue < 16)) { return (str: string) => this.bg("selectedBg", str); } const surfaceRgb = colorValueToRgb(this.bgColorValues.get("userMessageBg")); const selectionRgb = colorValueToRgb(selectedBgValue); if (!surfaceRgb || !selectionRgb) { return (str: string) => this.bg("selectedBg", str); } const surfaceAnsi = bestAnsiColor(surfaceRgb, this.mode); // Half contrast by default; strengthen the blend only when quantization // would collapse the highlight into the editor surface. for (const alpha of [SOFT_SELECTION_ALPHA, 0.75, 1]) { const adjusted = bestAnsiColor(blendColor(selectionRgb, surfaceRgb, alpha), this.mode); if (adjusted !== "" && adjusted !== surfaceAnsi) { const ansi = bgAnsi(adjusted, this.mode); return (str: string) => `${ansi}${str}[49m`; } } return this.getSelectionBackgroundColor(); }
private surfaceBackgroundColor(color: ThemeBg): (str: string) => string { const terminalBg = getDefaultTerminalColors()?.background; const surfaceRgb = colorValueToRgb(this.bgColorValues.get(color)); if (!terminalBg || !surfaceRgb) { return (str: string) => this.bg(color, str); }
const delta = Math.abs(luminance(surfaceRgb) - luminance(terminalBg)); if (delta >= SURFACE_MIN_LUMINANCE_DELTA) { return (str: string) => this.bg(color, str); }
const top = isLightColor(terminalBg) ? BLACK : WHITE; const adjustedColor = bestAnsiColor(blendColor(top, surfaceRgb, SURFACE_CONTRAST_ALPHA), this.mode); if (adjustedColor === "") { return (str: string) => this.bg(color, str); } const ansi = bgAnsi(adjustedColor, this.mode); return (str: string) => `${ansi}${str}\x1b[49m`; }
/** Filled effort squares: a pastel purple that reads softer than the theme accent. */ getEffortSquareColor(): (str: string) => string { const hex = getTerminalBackgroundKind() === "light" ? EFFORT_SQUARE_LIGHT_COLOR : EFFORT_SQUARE_DARK_COLOR; const color = bestAnsiColor(hexToRgb(hex), this.mode); if (color === "") { return (str: string) => this.fg("accent", str); } const ansi = fgAnsi(color, this.mode); return (str: string) => `${ansi}${str}[39m`; }
getAdaptiveAccentColor(): (str: string) => string { const ansi = getTerminalBackgroundKind() === "light" ? fgAnsi(bestAnsiColor(ADAPTIVE_LIGHT_BG_ACCENT, this.mode), this.mode) : "\x1b[36m"; return (str: string) => `${ansi}\x1b[1m${str}\x1b[22m\x1b[39m`; }
bold(text: string): string { return chalk.bold(text); }
italic(text: string): string { return chalk.italic(text); }
underline(text: string): string { return chalk.underline(text); }
inverse(text: string): string { return chalk.inverse(text); }
strikethrough(text: string): string { return chalk.strikethrough(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" | "xhigh" | "max", ): (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); case "xhigh": return (str: string) => this.fg("thinkingXhigh", str); case "max": // Reuse the xhigh color: a dedicated max color would touch every theme preset. return (str: string) => this.fg("thinkingXhigh", str); default: return (str: string) => this.fg("thinkingOff", str); } }
getBashModeBorderColor(): (str: string) => string { return (str: string) => this.fg("bashMode", str); }}
// ============================================================================// Theme Loading// ============================================================================
let BUILTIN_THEMES: Record<string, ThemeJson> | undefined;
function getBuiltinThemes(): Record<string, ThemeJson> { if (!BUILTIN_THEMES) { const themesDir = getThemesDir(); const primePath = path.join(themesDir, "prime.json"); const darkPath = path.join(themesDir, "dark.json"); const lightPath = path.join(themesDir, "light.json"); BUILTIN_THEMES = { prime: JSON.parse(fs.readFileSync(primePath, "utf-8")) as ThemeJson, dark: JSON.parse(fs.readFileSync(darkPath, "utf-8")) as ThemeJson, light: JSON.parse(fs.readFileSync(lightPath, "utf-8")) as ThemeJson, }; } return BUILTIN_THEMES;}
export function getAvailableThemes(): string[] { const themes = new Set<string>(Object.keys(getBuiltinThemes())); const customThemesDir = getCustomThemesDir(); if (fs.existsSync(customThemesDir)) { const files = fs.readdirSync(customThemesDir); for (const file of files) { if (file.endsWith(".json")) { themes.add(file.slice(0, -5)); } } } for (const name of registeredThemes.keys()) { themes.add(name); } return Array.from(themes).sort();}
export interface ThemeInfo { name: string; path: string | undefined;}
export function getAvailableThemesWithPaths(): ThemeInfo[] { const themesDir = getThemesDir(); const customThemesDir = getCustomThemesDir(); const result: ThemeInfo[] = [];
// Built-in themes for (const name of Object.keys(getBuiltinThemes())) { result.push({ name, path: path.join(themesDir, `${name}.json`) }); }
// Custom themes if (fs.existsSync(customThemesDir)) { for (const file of fs.readdirSync(customThemesDir)) { if (file.endsWith(".json")) { const name = file.slice(0, -5); if (!result.some((t) => t.name === name)) { result.push({ name, path: path.join(customThemesDir, file) }); } } } }
for (const [name, theme] of registeredThemes.entries()) { if (!result.some((t) => t.name === name)) { result.push({ name, path: theme.sourcePath }); } }
return result.sort((a, b) => a.name.localeCompare(b.name));}
function parseThemeJson(label: string, json: unknown): ThemeJson { if (!validateThemeJson) { // Validator not loaded yet (first custom-theme parse during startup): // apply a minimal structural check now and report full schema errors // asynchronously once the validator is ready. const colors = (json as Partial<ThemeJson> | null)?.colors; if (!json || typeof json !== "object" || !colors || typeof colors !== "object") { throw new Error(`Invalid theme "${label}": expected a JSON object with a "colors" object`); } void preloadThemeValidator().then(() => { try { parseThemeJson(label, json); } catch (error) { console.error(error instanceof Error ? error.message : String(error)); } }); return json as ThemeJson; } if (!validateThemeJson.Check(json)) { const errors = Array.from(validateThemeJson.Errors(json)); const missingColors = new Set<string>(); const otherErrors: string[] = [];
for (const error of errors) { if (error.keyword === "required" && error.instancePath === "/colors") { const requiredProperties = (error.params as { requiredProperties?: string[] }).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); }
return json as ThemeJson;}
function parseThemeJsonContent(label: string, content: string): ThemeJson { let json: unknown; try { json = JSON.parse(content); } catch (error) { throw new Error(`Failed to parse theme ${label}: ${error}`); } return parseThemeJson(label, json);}
function loadThemeJson(name: string): ThemeJson { 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: ThemeJson, mode?: ColorMode, sourcePath?: string): Theme { const colorMode = mode ?? detectColorMode(); const resolvedColors = resolveThemeColors( { ...refinementColors(themeJson.name === "light"), ...themeJson.colors, mdBody: themeJson.colors.mdBody ?? themeJson.colors.text, }, 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([ "selectedBg", "userMessageBg", "customMessageBg", "toolPendingBg", "toolSuccessBg", "toolErrorBg", "toolDiffAddedBg", "toolDiffRemovedBg", "toolPanelBg", ]); 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, { name: themeJson.name, sourcePath, });}
export function loadThemeFromPath(themePath: string, mode?: ColorMode): Theme { const content = fs.readFileSync(themePath, "utf-8"); const themeJson = parseThemeJsonContent(themePath, content); return createTheme(themeJson, mode, themePath);}
function loadTheme(name: string, mode?: ColorMode): Theme { const registeredTheme = registeredThemes.get(name); if (registeredTheme) { return registeredTheme; } const themeJson = loadThemeJson(name); return createTheme(themeJson, mode);}
export function getThemeByName(name: string): Theme | undefined { try { return loadTheme(name); } catch { return undefined; }}
function detectTerminalBackground(): "dark" | "light" { return getTerminalBackgroundKind() ?? "dark";}
function getDefaultTheme(): string { // Prime brand is dark-first; only fall back to light when the terminal is light. return detectTerminalBackground() === "light" ? "light" : "prime";}
// ============================================================================// 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");
// Export theme as a getter that reads from globalThis// This ensures all module instances (tsx, jiti) see the same themeexport const theme: Theme = new Proxy({} as Theme, { get(_target, prop) { const t = (globalThis as Record<symbol, Theme>)[THEME_KEY]; if (!t) throw new Error("Theme not initialized. Call initTheme() first."); return (t as unknown as Record<string | symbol, unknown>)[prop]; },});
function setGlobalTheme(t: Theme): void { (globalThis as Record<symbol, Theme>)[THEME_KEY] = t;}
let currentThemeName: string | undefined;let currentThemeIsAutomatic = false;let themeWatcher: fs.FSWatcher | undefined;let themeReloadTimer: NodeJS.Timeout | undefined;let onThemeChangeCallback: (() => void) | undefined;const registeredThemes = new Map<string, Theme>();
onDefaultTerminalColorsChange(() => { if (currentThemeIsAutomatic) { const name = getDefaultTheme(); if (name !== currentThemeName) { currentThemeName = name; try { setGlobalTheme(loadTheme(name)); } catch { currentThemeName = "dark"; setGlobalTheme(loadTheme("dark")); } } } if (onThemeChangeCallback) { onThemeChangeCallback(); }});
export function setRegisteredThemes(themes: Theme[]): void { registeredThemes.clear(); for (const theme of themes) { if (theme.name) { registeredThemes.set(theme.name, theme); } }}
type CodeHighlighterModule = typeof import("./code-highlighter.js");let codeHighlighter: CodeHighlighterModule | undefined;let codeHighlighterPromise: Promise<void> | undefined;
/** * Start loading the syntax highlighter (cli-highlight pulls in all of * highlight.js, ~350ms) off the startup-critical import path. highlightCode * falls back to unhighlighted output until the load completes; await this * before the first render to guarantee highlighted code blocks. */export function preloadCodeHighlighter(): Promise<void> { codeHighlighterPromise ??= import("./code-highlighter.js").then((module) => { codeHighlighter = module; }); return codeHighlighterPromise;}
export function initTheme(themeName?: string, enableWatcher: boolean = false): void { void preloadCodeHighlighter(); void preloadThemeValidator(); const name = themeName ?? getDefaultTheme(); currentThemeName = name; currentThemeIsAutomatic = themeName === undefined; 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: string, enableWatcher: boolean = false): { success: boolean; error?: string } { currentThemeName = name; currentThemeIsAutomatic = false; 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: Theme): void { setGlobalTheme(themeInstance); currentThemeName = "<in-memory>"; currentThemeIsAutomatic = false; stopThemeWatcher(); // Can't watch a direct instance if (onThemeChangeCallback) { onThemeChangeCallback(); }}
export function onThemeChange(callback: () => void): void { onThemeChangeCallback = callback; if (getDefaultTerminalColors()) { callback(); }}
function startThemeWatcher(): void { stopThemeWatcher();
// Only watch if it's a custom theme (not built-in) if ( !currentThemeName || currentThemeName === "prime" || 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(): void { 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: number): string { // 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: number) => (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?: string): Record<string, string> { const name = themeName ?? currentThemeName ?? getDefaultTheme(); const isLight = name === "light"; const themeJson = loadThemeJson(name); const resolved = resolveThemeColors( { ...refinementColors(themeJson.name === "light"), ...themeJson.colors }, themeJson.vars, );
// Default text color for empty values (terminal uses default fg color) const defaultText = isLight ? "#000000" : "#e5e5e7";
const cssColors: Record<string, string> = {}; 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?: string): boolean { // 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?: string): { pageBg?: string; cardBg?: string; infoBg?: string;} { const name = themeName ?? currentThemeName ?? getDefaultTheme(); try { const themeJson = loadThemeJson(name); const exportSection = themeJson.export; if (!exportSection) return {};
const vars = themeJson.vars ?? {}; const resolve = (value: ColorValue | undefined): string | undefined => { 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 {}; }}
// ============================================================================// TUI Helpers// ============================================================================
type CliHighlightTheme = Record<string, (s: string) => string>;
let cachedHighlightThemeFor: Theme | undefined;let cachedCliHighlightTheme: CliHighlightTheme | undefined;
function buildCliHighlightTheme(t: Theme): CliHighlightTheme { return { keyword: (s: string) => t.fg("syntaxKeyword", s), built_in: (s: string) => t.fg("syntaxType", s), literal: (s: string) => t.fg("syntaxNumber", s), number: (s: string) => t.fg("syntaxNumber", s), string: (s: string) => t.fg("syntaxString", s), comment: (s: string) => t.fg("syntaxComment", s), function: (s: string) => t.fg("syntaxFunction", s), title: (s: string) => t.fg("syntaxFunction", s), class: (s: string) => t.fg("syntaxType", s), type: (s: string) => t.fg("syntaxType", s), attr: (s: string) => t.fg("syntaxVariable", s), variable: (s: string) => t.fg("syntaxVariable", s), params: (s: string) => t.fg("syntaxVariable", s), operator: (s: string) => t.fg("syntaxOperator", s), punctuation: (s: string) => t.fg("syntaxPunctuation", s), };}
function getCliHighlightTheme(t: Theme): CliHighlightTheme { 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: string, lang?: string): string[] { // The highlighter loads lazily; until then render the block unhighlighted. const highlighter = codeHighlighter; // Validate language before highlighting to avoid stderr spam from cli-highlight const validLang = lang && highlighter?.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 (!highlighter || !validLang) { return code.split("\n").map((line) => theme.fg("mdCodeBlock", line)); } const opts = { language: validLang, ignoreIllegals: true, theme: getCliHighlightTheme(theme), }; try { return highlighter.highlight(code, opts).split("\n"); } catch { return code.split("\n"); }}
/** * Get language identifier from file path extension. */export function getLanguageFromPath(filePath: string): string | undefined { const ext = filePath.split(".").pop()?.toLowerCase(); if (!ext) return undefined;
const extToLang: Record<string, string> = { 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(): 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), math: (text: string) => theme.fg("mdCode", text), mathBlock: (text: string) => theme.fg("mdCodeBlock", text), highlightCode: (code: string, lang?: string): string[] => { // The highlighter loads lazily; until then render the block unhighlighted. const highlighter = codeHighlighter; // Validate language before highlighting to avoid stderr spam from cli-highlight const validLang = lang && highlighter?.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 (!highlighter || !validLang) { return code.split("\n").map((line) => theme.fg("mdCodeBlock", line)); } const opts = { language: validLang, ignoreIllegals: true, theme: getCliHighlightTheme(theme), }; try { return highlighter.highlight(code, opts).split("\n"); } catch { return code.split("\n").map((line) => theme.fg("mdCodeBlock", line)); } }, };}
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), argumentHint: (text: string) => theme.fg("mdCode", text), sourceTag: (text: string) => theme.fg("dim", 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), backgroundColor: theme.getEditorBackgroundColor(), autocompleteBackgroundColor: (text: string) => theme.getPopupBackgroundColor()(text), selectList: getSelectListTheme(), commandColor: (text: string) => theme.fg("accent", text), };}
export function getSettingsListTheme(): import("@earendil-works/pi-tui").SettingsListTheme { return { label: (text: string, selected: boolean) => (selected ? theme.fg("accent", text) : text), value: (text: string, selected: boolean) => (selected ? theme.fg("accent", text) : theme.fg("muted", text)), description: (text: string) => theme.fg("dim", text), cursor: theme.fg("accent", "› "), hint: (text: string) => theme.fg("dim", text), };}