| import * as fs from "node:fs"; |
| import * as path from "node:path"; |
| import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; |
| import { |
| type EditorTheme, |
| getCapabilities, |
| type MarkdownTheme, |
| type RgbColor, |
| type SelectListTheme, |
| type SettingsListTheme, |
| } from "@earendil-works/pi-tui"; |
| import chalk from "chalk"; |
| import { getCustomThemesDir, getThemesDir } from "../../../config.ts"; |
| import type { SourceInfo } from "../../../core/source-info.ts"; |
| import { closeWatcher, watchWithErrorHandler } from "../../../utils/fs-watch.ts"; |
| import { highlight, supportsLanguage } from "../../../utils/syntax-highlight.ts"; |
| import { stripBom } from "../../../utils/text.ts"; |
|
|
| |
| |
| |
|
|
| |
| import type { ThemeColorValue as ColorValue, ValidatedThemeJson as ThemeJson } from "./theme-json.ts"; |
|
|
| export type { ValidatedThemeJson as ThemeJson } from "./theme-json.ts"; |
|
|
| export type ThemeJsonValidator = (label: string, json: unknown) => ThemeJson; |
|
|
| let themeJsonValidator: ThemeJsonValidator | undefined; |
|
|
| |
| |
| |
| |
| |
| export function setThemeJsonValidator(validator: ThemeJsonValidator): void { |
| themeJsonValidator = validator; |
| } |
|
|
| export type ThemeColor = |
| | "accent" |
| | "border" |
| | "borderAccent" |
| | "borderMuted" |
| | "success" |
| | "error" |
| | "warning" |
| | "muted" |
| | "dim" |
| | "text" |
| | "thinkingText" |
| | "scrollbarTrack" |
| | "scrollbarThumb" |
| | "searchMatchText" |
| | "userMessageText" |
| | "customMessageText" |
| | "customMessageLabel" |
| | "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" |
| | "thinkingXhigh" |
| | "thinkingMax" |
| | "bashMode"; |
|
|
| export type ThemeBg = |
| | "selectedBg" |
| | "searchMatchBg" |
| | "userMessageBg" |
| | "customMessageBg" |
| | "toolPendingBg" |
| | "toolSuccessBg" |
| | "toolErrorBg"; |
|
|
| type OptionalThemeColor = "scrollbarTrack" | "scrollbarThumb" | "thinkingMax" | "searchMatchText"; |
| type OptionalThemeBg = "searchMatchBg"; |
|
|
| type ColorMode = "truecolor" | "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 }; |
| } |
|
|
| |
| const CUBE_VALUES = [0, 95, 135, 175, 215, 255]; |
|
|
| |
| const GRAY_VALUES = Array.from({ length: 24 }, (_, i) => 8 + i * 10); |
|
|
| function findClosestCubeIndex(value: number): number { |
| 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: number): number { |
| 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: number, g1: number, b1: number, r2: number, g2: number, b2: number): number { |
| |
| 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: number, g: number, b: number): number { |
| |
| 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); |
|
|
| |
| 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); |
|
|
| |
| |
| const maxC = Math.max(r, g, b); |
| const minC = Math.min(r, g, b); |
| const spread = maxC - minC; |
|
|
| |
| |
| if (spread < 10 && grayDist < cubeDist) { |
| return grayIndex; |
| } |
|
|
| return cubeIndex; |
| } |
|
|
| 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>; |
| } |
|
|
| function withThemeColorFallbacks(colors: ThemeJson["colors"]): ThemeJson["colors"] & { |
| scrollbarTrack: ColorValue; |
| scrollbarThumb: ColorValue; |
| thinkingMax: ColorValue; |
| searchMatchBg: ColorValue; |
| searchMatchText: ColorValue; |
| } { |
| return { |
| ...colors, |
| scrollbarTrack: colors.scrollbarTrack ?? colors.muted, |
| scrollbarThumb: colors.scrollbarThumb ?? colors.text, |
| thinkingMax: colors.thinkingMax ?? colors.thinkingXhigh, |
| searchMatchBg: colors.searchMatchBg ?? colors.selectedBg, |
| searchMatchText: colors.searchMatchText ?? colors.text, |
| }; |
| } |
|
|
| |
| |
| |
|
|
| export class Theme { |
| readonly name?: string; |
| readonly sourcePath?: string; |
| sourceInfo?: SourceInfo; |
| private fgColors: Map<ThemeColor, string>; |
| private bgColors: Map<ThemeBg, string>; |
| private mode: ColorMode; |
|
|
| constructor( |
| fgColors: Record<Exclude<ThemeColor, OptionalThemeColor>, string | number> & |
| Partial<Record<OptionalThemeColor, string | number>>, |
| bgColors: Record<Exclude<ThemeBg, OptionalThemeBg>, string | number> & |
| Partial<Record<OptionalThemeBg, 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(); |
| const colors = { |
| ...fgColors, |
| scrollbarTrack: fgColors.scrollbarTrack ?? fgColors.muted, |
| scrollbarThumb: fgColors.scrollbarThumb ?? fgColors.text, |
| thinkingMax: fgColors.thinkingMax ?? fgColors.thinkingXhigh, |
| searchMatchText: fgColors.searchMatchText ?? fgColors.text, |
| }; |
| for (const [key, value] of Object.entries(colors) as [ThemeColor, string | number][]) { |
| this.fgColors.set(key, fgAnsi(value, mode)); |
| } |
| this.bgColors = new Map(); |
| const backgrounds = { |
| ...bgColors, |
| searchMatchBg: bgColors.searchMatchBg ?? bgColors.selectedBg, |
| }; |
| for (const [key, value] of Object.entries(backgrounds) 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`; |
| } |
|
|
| 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`; |
| } |
|
|
| 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: ThinkingLevel): (str: string) => string { |
| |
| 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": |
| return (str: string) => this.fg("thinkingMax", str); |
| default: |
| return (str: string) => this.fg("thinkingOff", str); |
| } |
| } |
|
|
| getBashModeBorderColor(): (str: string) => string { |
| return (str: string) => this.fg("bashMode", str); |
| } |
| } |
|
|
| |
| |
| |
|
|
| let BUILTIN_THEMES: Record<string, ThemeJson> | undefined; |
|
|
| function getBuiltinThemes(): Record<string, ThemeJson> { |
| 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(stripBom(fs.readFileSync(darkPath, "utf-8"))) as ThemeJson, |
| light: JSON.parse(stripBom(fs.readFileSync(lightPath, "utf-8"))) as ThemeJson, |
| }; |
| } |
| return BUILTIN_THEMES; |
| } |
|
|
| export function getAvailableThemes(): string[] { |
| return getAvailableThemesWithPaths().map(({ name }) => name); |
| } |
|
|
| export interface ThemeInfo { |
| name: string; |
| path: string | undefined; |
| } |
|
|
| export function getAvailableThemesWithPaths(): ThemeInfo[] { |
| const themesDir = getThemesDir(); |
| const result: ThemeInfo[] = []; |
| const seen = new Set<string>(); |
| const addTheme = (themeInfo: ThemeInfo) => { |
| if (seen.has(themeInfo.name)) { |
| return; |
| } |
| seen.add(themeInfo.name); |
| result.push(themeInfo); |
| }; |
|
|
| |
| for (const name of Object.keys(getBuiltinThemes())) { |
| addTheme({ name, path: path.join(themesDir, `${name}.json`) }); |
| } |
|
|
| |
| 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(): ThemeInfo[] { |
| const customThemesDir = getCustomThemesDir(); |
| const result: ThemeInfo[] = []; |
| 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 { |
| |
| |
| } |
| } |
| return result; |
| } |
|
|
| function assertThemeNameIsValid(name: string): void { |
| 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: string, json: unknown): ThemeJson { |
| if (themeJsonValidator) return themeJsonValidator(label, json); |
| if (typeof json !== "object" || json === null || !("colors" in json)) { |
| throw new Error(`Invalid theme "${label}": expected an object with a "colors" map.`); |
| } |
| return json as ThemeJson; |
| } |
|
|
| function parseThemeJsonContent(label: string, content: string): ThemeJson { |
| let json: unknown; |
| try { |
| json = JSON.parse(stripBom(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 ?? (getCapabilities().trueColor ? "truecolor" : "256color"); |
| const resolvedColors = resolveThemeColors(withThemeColorFallbacks(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([ |
| "selectedBg", |
| "searchMatchBg", |
| "userMessageBg", |
| "customMessageBg", |
| "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, { |
| 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; |
| } |
| } |
|
|
| export type TerminalTheme = "dark" | "light"; |
|
|
| export function parseAutoThemeSetting( |
| themeSetting: string | undefined, |
| ): { lightTheme: string; darkTheme: string } | undefined { |
| 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: string | undefined, |
| terminalTheme: TerminalTheme, |
| ): string | undefined { |
| 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; |
| } |
|
|
| export interface TerminalThemeDetection { |
| theme: TerminalTheme; |
| source: "terminal background" | "COLORFGBG" | "fallback"; |
| detail: string; |
| confidence: "high" | "low"; |
| } |
|
|
| export interface TerminalThemeDetectionOptions { |
| env?: NodeJS.ProcessEnv; |
| } |
|
|
| export interface TerminalBackgroundThemeDetector { |
| queryTerminalBackgroundColor({ timeoutMs }: { timeoutMs: number }): Promise<RgbColor | undefined>; |
| } |
|
|
| export interface TerminalAutoThemeDetector extends TerminalBackgroundThemeDetector { |
| queryTerminalColorScheme?({ timeoutMs }: { timeoutMs: number }): Promise<TerminalTheme | undefined>; |
| } |
|
|
| export interface TerminalBackgroundThemeDetectionOptions extends TerminalThemeDetectionOptions { |
| ui: TerminalBackgroundThemeDetector; |
| timeoutMs: number; |
| } |
|
|
| export interface TerminalAutoThemeDetectionOptions extends TerminalThemeDetectionOptions { |
| ui: TerminalAutoThemeDetector; |
| timeoutMs: number; |
| } |
|
|
| function getColorFgBgBackgroundIndex(colorfgbg: string): number | undefined { |
| 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 }: RgbColor): number { |
| const toLinear = (channel: number) => { |
| 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: number): number { |
| return getRgbColorLuminance(hexToRgb(ansi256ToHex(index))); |
| } |
|
|
| export function getThemeForRgbColor(rgb: RgbColor): TerminalTheme { |
| return getRgbColorLuminance(rgb) >= 0.5 ? "light" : "dark"; |
| } |
|
|
| export function detectTerminalBackgroundFromEnv(options: TerminalThemeDetectionOptions = {}): TerminalThemeDetection { |
| 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, |
| }: TerminalBackgroundThemeDetectionOptions): Promise<TerminalThemeDetection> { |
| 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 { |
| |
| } |
|
|
| return detectTerminalBackgroundFromEnv({ env }); |
| } |
|
|
| export async function detectTerminalThemeForAuto({ |
| ui, |
| timeoutMs, |
| env, |
| }: TerminalAutoThemeDetectionOptions): Promise<TerminalTheme> { |
| let colorSchemePromise: Promise<TerminalTheme | undefined> | undefined; |
| try { |
| colorSchemePromise = ui.queryTerminalColorScheme?.({ timeoutMs }); |
| } catch { |
| |
| } |
| const backgroundThemePromise = detectTerminalBackgroundTheme({ ui, timeoutMs, env }); |
|
|
| try { |
| const colorScheme = await colorSchemePromise; |
| if (colorScheme) return colorScheme; |
| } catch { |
| |
| } |
| return (await backgroundThemePromise).theme; |
| } |
|
|
| export function getDefaultTheme(): string { |
| return detectTerminalBackgroundFromEnv().theme; |
| } |
|
|
| |
| |
| |
|
|
| |
| const THEME_KEY = Symbol.for("@earendil-works/pi-coding-agent:theme"); |
| const THEME_KEY_OLD = Symbol.for("@mariozechner/pi-coding-agent:theme"); |
|
|
| |
| |
| export 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; |
| (globalThis as Record<symbol, Theme>)[THEME_KEY_OLD] = t; |
| } |
|
|
| let currentThemeName: string | undefined; |
| let themeWatcher: fs.FSWatcher | undefined; |
| let themeReloadTimer: NodeJS.Timeout | undefined; |
| let onThemeChangeCallback: (() => void) | undefined; |
| const registeredThemes = new Map<string, Theme>(); |
|
|
| export function setRegisteredThemes(themes: Theme[]): void { |
| registeredThemes.clear(); |
| for (const theme of themes) { |
| if (theme.name) { |
| assertThemeNameIsValid(theme.name); |
| registeredThemes.set(theme.name, theme); |
| } |
| } |
| } |
|
|
| export function initTheme(themeName?: string, enableWatcher: boolean = false): void { |
| const name = themeName ?? getDefaultTheme(); |
| currentThemeName = name; |
| try { |
| setGlobalTheme(loadTheme(name)); |
| if (enableWatcher) { |
| startThemeWatcher(); |
| } |
| } catch (_error) { |
| |
| currentThemeName = "dark"; |
| setGlobalTheme(loadTheme("dark")); |
| |
| } |
| } |
|
|
| export function setTheme(name: string, enableWatcher: boolean = false): { success: boolean; error?: string } { |
| currentThemeName = name; |
| try { |
| setGlobalTheme(loadTheme(name)); |
| if (enableWatcher) { |
| startThemeWatcher(); |
| } |
| if (onThemeChangeCallback) { |
| onThemeChangeCallback(); |
| } |
| return { success: true }; |
| } catch (error) { |
| |
| currentThemeName = "dark"; |
| setGlobalTheme(loadTheme("dark")); |
| |
| return { |
| success: false, |
| error: error instanceof Error ? error.message : String(error), |
| }; |
| } |
| } |
|
|
| export function setThemeInstance(themeInstance: Theme): void { |
| setGlobalTheme(themeInstance); |
| currentThemeName = "<in-memory>"; |
| stopThemeWatcher(); |
| if (onThemeChangeCallback) { |
| onThemeChangeCallback(); |
| } |
| } |
|
|
| export function onThemeChange(callback: () => void): void { |
| onThemeChangeCallback = callback; |
| } |
|
|
| function startThemeWatcher(): void { |
| stopThemeWatcher(); |
|
|
| |
| if (!currentThemeName || currentThemeName === "dark" || currentThemeName === "light") { |
| return; |
| } |
|
|
| const customThemesDir = getCustomThemesDir(); |
| const watchedThemeName = currentThemeName; |
| const watchedFileName = `${watchedThemeName}.json`; |
| const themeFile = path.join(customThemesDir, watchedFileName); |
|
|
| |
| if (!fs.existsSync(themeFile)) { |
| return; |
| } |
|
|
| const scheduleReload = () => { |
| if (themeReloadTimer) { |
| clearTimeout(themeReloadTimer); |
| } |
| themeReloadTimer = setTimeout(() => { |
| themeReloadTimer = undefined; |
|
|
| |
| if (currentThemeName !== watchedThemeName) { |
| return; |
| } |
|
|
| |
| if (!fs.existsSync(themeFile)) { |
| return; |
| } |
|
|
| try { |
| |
| const reloadedTheme = loadThemeFromPath(themeFile); |
| registeredThemes.set(watchedThemeName, reloadedTheme); |
| setGlobalTheme(reloadedTheme); |
| |
| if (onThemeChangeCallback) { |
| onThemeChangeCallback(); |
| } |
| } catch (_error) { |
| |
| } |
| }, 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; |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| function ansi256ToHex(index: number): string { |
| |
| const basicColors = [ |
| "#000000", |
| "#800000", |
| "#008000", |
| "#808000", |
| "#000080", |
| "#800080", |
| "#008080", |
| "#c0c0c0", |
| "#808080", |
| "#ff0000", |
| "#00ff00", |
| "#ffff00", |
| "#0000ff", |
| "#ff00ff", |
| "#00ffff", |
| "#ffffff", |
| ]; |
| if (index < 16) { |
| return basicColors[index]; |
| } |
|
|
| |
| 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)}`; |
| } |
|
|
| |
| const gray = 8 + (index - 232) * 10; |
| const grayHex = gray.toString(16).padStart(2, "0"); |
| return `#${grayHex}${grayHex}${grayHex}`; |
| } |
|
|
| |
| |
| |
| |
| export function getResolvedThemeColors(themeName?: string): Record<string, string> { |
| const name = themeName ?? currentThemeName ?? getDefaultTheme(); |
| const isLight = name === "light"; |
| const themeJson = loadThemeJson(name); |
| const resolved = resolveThemeColors(withThemeColorFallbacks(themeJson.colors), themeJson.vars); |
|
|
| |
| 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 === "") { |
| |
| cssColors[key] = defaultText; |
| } else { |
| cssColors[key] = value; |
| } |
| } |
| return cssColors; |
| } |
|
|
| |
| |
| |
| export function isLightTheme(themeName?: string): boolean { |
| |
| return themeName === "light"; |
| } |
|
|
| |
| |
| |
| |
| 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 {}; |
| } |
| } |
|
|
| |
| |
| |
|
|
| 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), |
| regexp: (s: string) => t.fg("syntaxString", s), |
| string: (s: string) => t.fg("syntaxString", s), |
| comment: (s: string) => t.fg("syntaxComment", s), |
| doctag: (s: string) => t.fg("syntaxComment", s), |
| meta: (s: string) => t.fg("muted", 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), |
| tag: (s: string) => t.fg("syntaxPunctuation", s), |
| name: (s: string) => t.fg("syntaxKeyword", 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), |
| emphasis: (s: string) => t.italic(s), |
| strong: (s: string) => t.bold(s), |
| link: (s: string) => t.underline(s), |
| addition: (s: string) => t.fg("toolDiffAdded", s), |
| deletion: (s: string) => t.fg("toolDiffRemoved", s), |
| }; |
| } |
|
|
| function getCliHighlightTheme(t: Theme): CliHighlightTheme { |
| if (cachedHighlightThemeFor !== t || !cachedCliHighlightTheme) { |
| cachedHighlightThemeFor = t; |
| cachedCliHighlightTheme = buildCliHighlightTheme(t); |
| } |
| return cachedCliHighlightTheme; |
| } |
|
|
| |
| |
| |
| |
| export function highlightCode(code: string, lang?: string): string[] { |
| |
| const validLang = lang && supportsLanguage(lang) ? lang : undefined; |
| |
| |
| |
| 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"); |
| } |
| } |
|
|
| |
| |
| |
| 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), |
| highlightCode: (code: string, lang?: string): string[] => { |
| |
| const validLang = lang && supportsLanguage(lang) ? lang : undefined; |
| |
| |
| |
| 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(): 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(), |
| }; |
| } |
|
|
| export function getSettingsListTheme(): 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), |
| }; |
| } |
|
|