refactor(coding-agent): split theme module by responsibility

- theme.ts mixed symbol presets, JSON schema, color math, the Theme class,
  loading, global state, appearance handling and TUI adapters in 3171 lines.
- Symbols, schema, color, theme-class, loader and tui-adapters are now
  siblings; theme.ts keeps global state, the watcher, appearance handling and
  HTML export at 745 lines, with all 44 exports intact.
- Left appearance and export-colors in place: both read private mutable
  auto-theme state, so extracting them would have required new exported
  internals or DI rather than a straight move.
This commit is contained in:
can1357
2026-08-08 06:32:01 +02:00
parent 0697e7f688
commit e0ec404de6
7 changed files with 2491 additions and 2453 deletions
@@ -0,0 +1,133 @@
import type { ColorMode, ColorValue } from "./schema";
// ============================================================================
// Color Utilities
// ============================================================================
export function detectColorMode(): ColorMode {
const colorterm = Bun.env.COLORTERM;
if (colorterm === "truecolor" || colorterm === "24bit") {
return "truecolor";
}
// Windows Terminal supports truecolor
if (Bun.env.WT_SESSION) {
return "truecolor";
}
const term = Bun.env.TERM || "";
// Only fall back to 256color for truly limited terminals
if (term === "dumb" || term === "" || term === "linux") {
return "256color";
}
// Assume truecolor for everything else - virtually all modern terminals support it
return "truecolor";
}
export function colorToAnsi(color: string, mode: ColorMode): string {
const format = mode === "truecolor" ? "ansi-16m" : "ansi-256";
const ansi = Bun.color(color, format);
if (ansi === null) {
throw new Error(`Invalid color value: ${color}`);
}
return ansi;
}
export function fgAnsi(color: string | number, mode: ColorMode): string {
if (color === "") return "\x1b[39m";
if (typeof color === "number") return `\x1b[38;5;${color}m`;
if (typeof color === "string") {
return colorToAnsi(color, mode);
}
throw new Error(`Invalid color value: ${color}`);
}
export function bgAnsi(color: string | number, mode: ColorMode): string {
if (color === "") return "\x1b[49m";
if (typeof color === "number") return `\x1b[48;5;${color}m`;
const ansi = colorToAnsi(color, mode);
return ansi.replace("\x1b[38;", "\x1b[48;");
}
export 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);
}
export 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>;
}
/**
* Resolve a theme color value (hex string or 256-color index) to a CSS hex string.
* Empty string represents the default terminal color.
*/
export function resolveToHex(value: string | number, isLight: boolean): string {
if (typeof value === "number") return ansi256ToHex(value);
if (value === "") return isLight ? "#000000" : "#e5e5e7";
return value;
}
/**
* 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
*/
export 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}`;
}
@@ -0,0 +1,178 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { type } from "@oh-my-pi/omptype";
import { adjustHsv, getCustomThemesDir, isEnoent } from "@oh-my-pi/pi-utils";
import { detectColorMode, resolveThemeColors } from "./color";
import darkThemeJson from "./dark.json" with { type: "json" };
import { defaultThemes } from "./defaults";
import lightThemeJson from "./light.json" with { type: "json" };
import { type ColorMode, type ThemeBg, type ThemeColor, type ThemeJson, themeJsonSchema } from "./schema";
import { normalizeSpinnerFramesOverride, type SymbolPreset } from "./symbols";
import { Theme } from "./theme-class";
// ============================================================================
// Theme Loading
// ============================================================================
const BUILTIN_THEMES: Record<string, ThemeJson> = {
dark: darkThemeJson as ThemeJson,
light: lightThemeJson as ThemeJson,
...(defaultThemes as Record<string, ThemeJson>),
};
export function getBuiltinThemes(): Record<string, ThemeJson> {
return BUILTIN_THEMES;
}
export async function getAvailableThemes(): Promise<string[]> {
const themes = new Set<string>(Object.keys(getBuiltinThemes()));
const customThemesDir = getCustomThemesDir();
try {
const files = await fs.promises.readdir(customThemesDir);
for (const file of files) {
if (file.endsWith(".json")) {
themes.add(file.slice(0, -5));
}
}
} catch {
// Directory doesn't exist or isn't readable
}
return Array.from(themes).sort();
}
export interface ThemeInfo {
name: string;
path: string | undefined;
}
export async function getAvailableThemesWithPaths(): Promise<ThemeInfo[]> {
const result: ThemeInfo[] = [];
// Built-in themes (embedded, no file path)
for (const name of Object.keys(getBuiltinThemes())) {
result.push({ name, path: undefined });
}
// Custom themes
const customThemesDir = getCustomThemesDir();
try {
const files = await fs.promises.readdir(customThemesDir);
for (const file of files) {
if (file.endsWith(".json")) {
const name = file.slice(0, -5);
if (!result.some(themeInfo => themeInfo.name === name)) {
result.push({ name, path: path.join(customThemesDir, file) });
}
}
}
} catch {
// Directory doesn't exist or isn't readable
}
return result.sort((a, b) => a.name.localeCompare(b.name));
}
export async function loadThemeJson(name: string): Promise<ThemeJson> {
const builtinThemes = getBuiltinThemes();
if (name in builtinThemes) {
return builtinThemes[name];
}
const customThemesDir = getCustomThemesDir();
const themePath = path.join(customThemesDir, `${name}.json`);
let content: string;
try {
content = await Bun.file(themePath).text();
} catch (err) {
if (isEnoent(err)) throw new Error(`Theme not found: ${name}`);
throw err;
}
let json: unknown;
try {
json = JSON.parse(content);
} catch (error) {
throw new Error(`Failed to parse theme ${name}: ${error}`);
}
let parsed: ThemeJson;
try {
parsed = themeJsonSchema(json) as ThemeJson;
if (parsed instanceof type.errors) {
throw new Error(parsed.summary);
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
// Extract color key information if available
const missingColorMatch = errorMessage.match(/missing keys: (.+)/i);
const missingColors: string[] = missingColorMatch ? missingColorMatch[1].split(",").map(s => s.trim()) : [];
let fullErrorMessage = `Invalid theme "${name}":\n`;
if (missingColors.length > 0) {
fullErrorMessage += `\nMissing required color tokens:\n`;
fullErrorMessage += missingColors.map(c => ` - ${c}`).join("\n");
fullErrorMessage += `\n\nPlease add these colors to your theme's "colors" object.`;
fullErrorMessage += `\nSee the built-in themes (dark.json, light.json) for reference values.`;
}
fullErrorMessage += `\n\nValidation error:\n - ${errorMessage}`;
throw new Error(fullErrorMessage);
}
return parsed;
}
export interface CreateThemeOptions {
mode?: ColorMode;
symbolPresetOverride?: SymbolPreset;
colorBlindMode?: boolean;
}
/** HSV adjustment to shift green toward blue for colorblind mode (red-green colorblindness) */
const COLORBLIND_ADJUSTMENT = { h: 60, s: 0.71 };
export function createTheme(themeJson: ThemeJson, options: CreateThemeOptions = {}): Theme {
const { mode, symbolPresetOverride, colorBlindMode } = options;
const colorMode = mode ?? detectColorMode();
const resolvedColors = resolveThemeColors(themeJson.colors, themeJson.vars);
if (colorBlindMode) {
const added = resolvedColors.toolDiffAdded;
if (typeof added === "string" && added.startsWith("#")) {
resolvedColors.toolDiffAdded = adjustHsv(added, COLORBLIND_ADJUSTMENT);
}
}
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",
"statusLineBg",
]);
for (const [key, value] of Object.entries(resolvedColors)) {
if (bgColorKeys.has(key)) {
bgColors[key as ThemeBg] = value;
} else {
fgColors[key as ThemeColor] = value;
}
}
// Extract symbol configuration - settings override takes precedence over theme
const symbolPreset: SymbolPreset = symbolPresetOverride ?? themeJson.symbols?.preset ?? "unicode";
const symbolOverrides = themeJson.symbols?.overrides ?? {};
const spinnerFramesOverrides = normalizeSpinnerFramesOverride(themeJson.symbols?.spinnerFrames);
return new Theme(fgColors, bgColors, colorMode, symbolPreset, symbolOverrides, spinnerFramesOverrides);
}
export async function loadTheme(name: string, options: CreateThemeOptions = {}): Promise<Theme> {
const themeJson = await loadThemeJson(name);
return createTheme(themeJson, options);
}
export async function getThemeByName(name: string): Promise<Theme | undefined> {
try {
return await loadTheme(name);
} catch {
return undefined;
}
}
@@ -0,0 +1,263 @@
import { type } from "@oh-my-pi/omptype";
import type { SpinnerFramesOverride } from "./symbols";
// ============================================================================
// Types & Schema
// ============================================================================
export type ColorValue = string | number;
const themeColorsSchema = type({
accent: "string | number",
border: "string | number",
borderAccent: "string | number",
borderMuted: "string | number",
success: "string | number",
error: "string | number",
warning: "string | number",
muted: "string | number",
dim: "string | number",
text: "string | number",
thinkingText: "string | number",
selectedBg: "string | number",
userMessageBg: "string | number",
userMessageText: "string | number",
customMessageBg: "string | number",
customMessageText: "string | number",
customMessageLabel: "string | number",
toolPendingBg: "string | number",
toolSuccessBg: "string | number",
toolErrorBg: "string | number",
toolTitle: "string | number",
toolOutput: "string | number",
mdHeading: "string | number",
mdLink: "string | number",
mdLinkUrl: "string | number",
mdCode: "string | number",
mdCodeBlock: "string | number",
mdCodeBlockBorder: "string | number",
mdQuote: "string | number",
mdQuoteBorder: "string | number",
mdHr: "string | number",
mdListBullet: "string | number",
toolDiffAdded: "string | number",
toolDiffRemoved: "string | number",
toolDiffContext: "string | number",
syntaxComment: "string | number",
syntaxKeyword: "string | number",
syntaxFunction: "string | number",
syntaxVariable: "string | number",
syntaxString: "string | number",
syntaxNumber: "string | number",
syntaxType: "string | number",
syntaxOperator: "string | number",
syntaxPunctuation: "string | number",
thinkingOff: "string | number",
thinkingMinimal: "string | number",
thinkingLow: "string | number",
thinkingMedium: "string | number",
thinkingHigh: "string | number",
thinkingXhigh: "string | number",
"thinkingMax?": "string | number",
bashMode: "string | number",
pythonMode: "string | number",
statusLineBg: "string | number",
statusLineSep: "string | number",
statusLineModel: "string | number",
statusLinePath: "string | number",
statusLineGitClean: "string | number",
statusLineGitDirty: "string | number",
statusLineContext: "string | number",
statusLineSpend: "string | number",
statusLineStaged: "string | number",
statusLineDirty: "string | number",
statusLineUntracked: "string | number",
statusLineOutput: "string | number",
statusLineCost: "string | number",
statusLineSubagents: "string | number",
});
const spinnerFramesSchema = type("unknown").narrow((value): value is SpinnerFramesOverride => {
if (Array.isArray(value)) {
return value.length >= 1 && value.every(item => typeof item === "string");
}
if (value && typeof value === "object") {
const obj = value as Record<string, unknown>;
const status = obj.status;
const activity = obj.activity;
if (status === undefined && activity === undefined) return false;
if (status !== undefined) {
if (!Array.isArray(status) || status.length < 1 || !status.every(item => typeof item === "string")) {
return false;
}
}
if (activity !== undefined) {
if (!Array.isArray(activity) || activity.length < 1 || !activity.every(item => typeof item === "string")) {
return false;
}
}
return true;
}
return false;
});
export const themeJsonSchema = type({
"$schema?": "string",
name: "string",
"vars?": { "[string]": "string | number" },
colors: themeColorsSchema,
"export?": {
"pageBg?": "string | number",
"cardBg?": "string | number",
"infoBg?": "string | number",
},
"symbols?": {
"preset?": "'unicode' | 'nerd' | 'ascii'",
"overrides?": { "[string]": "string" },
"spinnerFrames?": spinnerFramesSchema,
},
});
export type ThemeJson = typeof themeJsonSchema.infer;
export type ThemeColor =
| "accent"
| "border"
| "borderAccent"
| "borderMuted"
| "success"
| "error"
| "warning"
| "muted"
| "dim"
| "text"
| "thinkingText"
| "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"
| "pythonMode"
| "statusLineSep"
| "statusLineModel"
| "statusLinePath"
| "statusLineGitClean"
| "statusLineGitDirty"
| "statusLineContext"
| "statusLineSpend"
| "statusLineStaged"
| "statusLineDirty"
| "statusLineUntracked"
| "statusLineOutput"
| "statusLineCost"
| "statusLineSubagents";
/** Set of all valid ThemeColor string values for runtime validation */
const THEME_COLOR_RECORD = {
accent: true,
border: true,
borderAccent: true,
borderMuted: true,
success: true,
error: true,
warning: true,
muted: true,
dim: true,
text: true,
thinkingText: true,
userMessageText: true,
customMessageText: true,
customMessageLabel: true,
toolTitle: true,
toolOutput: true,
mdHeading: true,
mdLink: true,
mdLinkUrl: true,
mdCode: true,
mdCodeBlock: true,
mdCodeBlockBorder: true,
mdQuote: true,
mdQuoteBorder: true,
mdHr: true,
mdListBullet: true,
toolDiffAdded: true,
toolDiffRemoved: true,
toolDiffContext: true,
syntaxComment: true,
syntaxKeyword: true,
syntaxFunction: true,
syntaxVariable: true,
syntaxString: true,
syntaxNumber: true,
syntaxType: true,
syntaxOperator: true,
syntaxPunctuation: true,
thinkingOff: true,
thinkingMinimal: true,
thinkingLow: true,
thinkingMedium: true,
thinkingHigh: true,
thinkingXhigh: true,
thinkingMax: true,
bashMode: true,
pythonMode: true,
statusLineSep: true,
statusLineModel: true,
statusLinePath: true,
statusLineGitClean: true,
statusLineGitDirty: true,
statusLineContext: true,
statusLineSpend: true,
statusLineStaged: true,
statusLineDirty: true,
statusLineUntracked: true,
statusLineOutput: true,
statusLineCost: true,
statusLineSubagents: true,
} satisfies Record<ThemeColor, true>;
const VALID_THEME_COLORS: ReadonlySet<string> = new Set(Object.keys(THEME_COLOR_RECORD));
/** Check if a string is a valid ThemeColor value */
export function isValidThemeColor(color: string): color is ThemeColor {
return VALID_THEME_COLORS.has(color);
}
export type ThemeBg =
| "selectedBg"
| "userMessageBg"
| "customMessageBg"
| "toolPendingBg"
| "toolSuccessBg"
| "toolErrorBg"
| "statusLineBg";
export type ColorMode = "truecolor" | "256color";
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,611 @@
import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
import type { Effort } from "@oh-my-pi/pi-ai";
import { colorLuma, logger, relativeLuminance } from "@oh-my-pi/pi-utils";
import chalk from "@oh-my-pi/pi-utils/chalk";
import { bgAnsi, colorToAnsi, fgAnsi, resolveToHex } from "./color";
import type { ColorMode, ThemeBg, ThemeColor } from "./schema";
import {
SPINNER_FRAMES,
type SpinnerType,
SYMBOL_PRESETS,
type SymbolKey,
type SymbolMap,
type SymbolPreset,
} from "./symbols";
// ============================================================================
// Theme Class
// ============================================================================
const langMap: Record<string, SymbolKey> = {
typescript: "lang.typescript",
ts: "lang.typescript",
tsx: "lang.typescript",
javascript: "lang.javascript",
js: "lang.javascript",
jsx: "lang.javascript",
mjs: "lang.javascript",
cjs: "lang.javascript",
python: "lang.python",
py: "lang.python",
rust: "lang.rust",
rs: "lang.rust",
go: "lang.go",
java: "lang.java",
c: "lang.c",
cpp: "lang.cpp",
"c++": "lang.cpp",
cc: "lang.cpp",
cxx: "lang.cpp",
csharp: "lang.csharp",
cs: "lang.csharp",
ruby: "lang.ruby",
rb: "lang.ruby",
julia: "lang.julia",
jl: "lang.julia",
php: "lang.php",
swift: "lang.swift",
kotlin: "lang.kotlin",
kt: "lang.kotlin",
bash: "lang.shell",
sh: "lang.shell",
zsh: "lang.shell",
fish: "lang.shell",
powershell: "lang.shell",
just: "lang.shell",
shell: "lang.shell",
html: "lang.html",
htm: "lang.html",
astro: "lang.html",
vue: "lang.html",
svelte: "lang.html",
css: "lang.css",
scss: "lang.css",
sass: "lang.css",
less: "lang.css",
json: "lang.json",
yaml: "lang.yaml",
yml: "lang.yaml",
markdown: "lang.markdown",
md: "lang.markdown",
sql: "lang.sql",
dockerfile: "lang.docker",
docker: "lang.docker",
lua: "lang.lua",
text: "lang.text",
txt: "lang.text",
plain: "lang.text",
log: "lang.log",
env: "lang.env",
dotenv: "lang.env",
toml: "lang.toml",
xml: "lang.xml",
ini: "lang.ini",
conf: "lang.conf",
cfg: "lang.conf",
config: "lang.conf",
properties: "lang.conf",
csv: "lang.csv",
tsv: "lang.tsv",
image: "lang.image",
img: "lang.image",
png: "lang.image",
jpg: "lang.image",
jpeg: "lang.image",
gif: "lang.image",
webp: "lang.image",
svg: "lang.image",
ico: "lang.image",
bmp: "lang.image",
tiff: "lang.image",
pdf: "lang.pdf",
zip: "lang.archive",
tar: "lang.archive",
gz: "lang.archive",
tgz: "lang.archive",
bz2: "lang.archive",
xz: "lang.archive",
"7z": "lang.archive",
exe: "lang.binary",
dll: "lang.binary",
so: "lang.binary",
dylib: "lang.binary",
wasm: "lang.binary",
bin: "lang.binary",
};
/**
* Brand colors for language icons, keyed by the resolved `lang.*` SymbolKey.
* Used by {@link Theme.getLangIconStyled} so eval-kernel cell headers tint each
* language with its recognizable hue (JS yellow, Ruby red, Julia purple, Python
* blue) instead of a flat muted gray. Applied as truecolor/256 per the active
* color mode; languages without an entry fall back to the muted theme color.
*/
const LANG_BRAND_COLORS: Partial<Record<SymbolKey, string>> = {
"lang.javascript": "#f7df1e",
"lang.python": "#3776ab",
"lang.ruby": "#cc342d",
"lang.julia": "#9558b2",
};
export class Theme {
#fgColors: Record<ThemeColor, string>;
#bgColors: Record<ThemeBg, string>;
/** Resolved hex strings for foreground colors — populated at construction. */
readonly #hexFgColors: Record<ThemeColor, string>;
/** Resolved hex strings for background colors — populated at construction. */
readonly #hexBgColors: Record<ThemeBg, string>;
#symbols: SymbolMap;
#spinnerFramesOverrides: Partial<Record<SpinnerType, string[]>>;
/**
* Perceptual luma (0..1) of the status-line background — used to classify the
* theme light/dark. Undefined when it can't be resolved. Classified against the
* status line (the surface session accents render on) rather than the chat bubble
* (`userMessageBg`), which some themes (e.g. `porcelain`) style dark on an
* otherwise-light theme.
*/
readonly statusLineLuminance: number | undefined;
/** WCAG relative luminance of the status-line background — basis for accent contrast. */
readonly #statusLineContrastLuminance: number | undefined;
constructor(
fgColors: Record<ThemeColor, string | number>,
bgColors: Record<ThemeBg, string | number>,
private readonly mode: ColorMode,
private readonly symbolPreset: SymbolPreset,
symbolOverrides: Partial<Record<SymbolKey, string>>,
spinnerFramesOverrides: Partial<Record<SpinnerType, string[]>> = {},
) {
this.statusLineLuminance = colorLuma(bgColors.statusLineBg);
this.#statusLineContrastLuminance = relativeLuminance(bgColors.statusLineBg);
const slIsLight = this.statusLineLuminance !== undefined && this.statusLineLuminance > 0.5;
this.#fgColors = {} as Record<ThemeColor, string>;
this.#hexFgColors = {} as Record<ThemeColor, string>;
for (const [key, value] of Object.entries(fgColors) as [ThemeColor, string | number][]) {
this.#fgColors[key] = fgAnsi(value, mode);
this.#hexFgColors[key] = resolveToHex(value, slIsLight);
}
this.#bgColors = {} as Record<ThemeBg, string>;
this.#hexBgColors = {} as Record<ThemeBg, string>;
for (const [key, value] of Object.entries(bgColors) as [ThemeBg, string | number][]) {
this.#bgColors[key] = bgAnsi(value, mode);
this.#hexBgColors[key] = resolveToHex(value, slIsLight);
}
// Build symbol map from preset + overrides
const baseSymbols = SYMBOL_PRESETS[symbolPreset];
this.#symbols = { ...baseSymbols };
for (const [key, value] of Object.entries(symbolOverrides)) {
if (key in this.#symbols) {
this.#symbols[key as SymbolKey] = value;
} else {
logger.debug("Invalid symbol key in override", { key, availableKeys: Object.keys(this.#symbols) });
}
}
this.#spinnerFramesOverrides = spinnerFramesOverrides;
}
/** True when the active theme has a light status-line background. */
get isLight(): boolean {
return this.statusLineLuminance !== undefined && this.statusLineLuminance > 0.5;
}
/**
* Surface luminance to size session accents against on light themes; undefined on
* dark themes so accents stay vivid. Pass straight to `getSessionAccentHex`.
*/
get accentSurfaceLuminance(): number | undefined {
return this.isLight ? this.#statusLineContrastLuminance : undefined;
}
/**
* Get the resolved CSS hex string for a foreground theme color.
*/
getColorHex(color: ThemeColor): string {
const hex = this.#hexFgColors[color];
if (hex === undefined) throw new Error(`Unknown theme color: ${color}`);
return hex || (this.isLight ? "#000000" : "#e5e5e7");
}
/**
* Get all foreground and background theme colors as CSS hex strings.
* Skips colors resolved to the default terminal color (unstyled).
*/
getAllThemeColorHexes(): string[] {
const hexes: string[] = [];
for (const hex of Object.values(this.#hexFgColors)) {
if (hex) hexes.push(hex);
}
for (const hex of Object.values(this.#hexBgColors)) {
if (hex) hexes.push(hex);
}
return hexes;
}
/**
* Get the most visually dominant theme colors as CSS hex strings — accent,
* border, success, error, warning, heading, link, diff markers, etc.
* These are the colors the session accent could visually clash with.
* Skips colors resolved to the default terminal color (unstyled).
*/
getMajorThemeColorHexes(): string[] {
const majors: ThemeColor[] = [
"accent",
"border",
"borderAccent",
"borderMuted",
"success",
"error",
"warning",
"mdHeading",
"mdLink",
"mdCode",
"mdCodeBlock",
"mdQuoteBorder",
"mdListBullet",
"toolDiffAdded",
"toolDiffRemoved",
"customMessageLabel",
"thinkingText",
];
const hexes: string[] = [];
for (const key of majors) {
const hex = this.#hexFgColors[key];
if (hex) hexes.push(hex);
}
return hexes;
}
/**
* Get the resolved CSS hex string for the theme's accent color.
*/
getAccentColorHex(): string {
return this.getColorHex("accent");
}
fg(color: ThemeColor, text: string): string {
const ansi = this.#fgColors[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[color];
if (!ansi) throw new Error(`Unknown theme background color: ${color}`);
return `${ansi}${text}\x1b[49m`; // Reset only background color
}
bold(text: string): string {
return chalk.bold(text);
}
italic(text: string): string {
return chalk.italic(text);
}
underline(text: string): string {
return chalk.underline(text);
}
strikethrough(text: string): string {
return chalk.strikethrough(text);
}
inverse(text: string): string {
return chalk.inverse(text);
}
getFgAnsi(color: ThemeColor): string {
const ansi = this.#fgColors[color];
if (!ansi) throw new Error(`Unknown theme color: ${color}`);
return ansi;
}
getBgAnsi(color: ThemeBg): string {
const ansi = this.#bgColors[color];
if (!ansi) throw new Error(`Unknown theme background color: ${color}`);
return ansi;
}
/**
* Foreground ANSI for text drawn **on top of** `fillColor` used as a solid
* background (e.g. a powerline chip). Picks near-black or near-white by the
* fill's perceived luminance (Rec. 601 luma) so the label stays legible on
* both bright and dark fills, across light and dark themes.
*
* Reads the RGB out of the already-resolved truecolor escape; when the fill
* is encoded as a 256-palette index (limited terminals) the RGB is
* unavailable, so it falls back to the theme `text` color.
*/
getContrastFgAnsi(fillColor: ThemeColor): string {
const ansi = this.#fgColors[fillColor];
const match = ansi ? /38;2;(\d+);(\d+);(\d+)/.exec(ansi) : null;
if (!match) return this.#fgColors.text;
const luma = 0.299 * Number(match[1]) + 0.587 * Number(match[2]) + 0.114 * Number(match[3]);
return luma > 140 ? "\x1b[38;2;0;0;0m" : "\x1b[38;2;255;255;255m";
}
getColorMode(): ColorMode {
return this.mode;
}
getThinkingBorderColor(level: ThinkingLevel | Effort): (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":
// thinkingMax is optional; themes without it resolve to the xhigh color.
return (str: string) => this.fg(this.#fgColors.thinkingMax ? "thinkingMax" : "thinkingXhigh", str);
default:
return (str: string) => this.fg("thinkingOff", str);
}
}
getBashModeBorderColor(): (str: string) => string {
return (str: string) => this.fg("bashMode", str);
}
getPythonModeBorderColor(): (str: string) => string {
return (str: string) => this.fg("pythonMode", str);
}
// ============================================================================
// Symbol Methods
// ============================================================================
/**
* Get a symbol by key.
*/
symbol(key: SymbolKey): string {
return this.#symbols[key];
}
/**
* Get a symbol styled with a color.
*/
styledSymbol(key: SymbolKey, color: ThemeColor): string {
return this.fg(color, this.#symbols[key]);
}
/**
* Get the current symbol preset.
*/
getSymbolPreset(): SymbolPreset {
return this.symbolPreset;
}
// ============================================================================
// Symbol Category Accessors
// ============================================================================
get status() {
return {
success: this.#symbols["status.success"],
error: this.#symbols["status.error"],
warning: this.#symbols["status.warning"],
info: this.#symbols["status.info"],
pending: this.#symbols["status.pending"],
disabled: this.#symbols["status.disabled"],
enabled: this.#symbols["status.enabled"],
running: this.#symbols["status.running"],
shadowed: this.#symbols["status.shadowed"],
aborted: this.#symbols["status.aborted"],
done: this.#symbols["status.done"],
};
}
get nav() {
return {
cursor: this.#symbols["nav.cursor"],
selected: this.#symbols["nav.selected"],
expand: this.#symbols["nav.expand"],
collapse: this.#symbols["nav.collapse"],
back: this.#symbols["nav.back"],
};
}
get tree() {
return {
branch: this.#symbols["tree.branch"],
last: this.#symbols["tree.last"],
vertical: this.#symbols["tree.vertical"],
horizontal: this.#symbols["tree.horizontal"],
hook: this.#symbols["tree.hook"],
};
}
get boxRound() {
return {
topLeft: this.#symbols["boxRound.topLeft"],
topRight: this.#symbols["boxRound.topRight"],
bottomLeft: this.#symbols["boxRound.bottomLeft"],
bottomRight: this.#symbols["boxRound.bottomRight"],
horizontal: this.#symbols["boxRound.horizontal"],
vertical: this.#symbols["boxRound.vertical"],
// Junctions have no rounded Unicode variant, so a rounded box reuses the
// sharp tee/cross glyphs. Sourcing them from the boxSharp.* tokens keeps a
// theme's `boxSharp.tee*` overrides effective for rounded-box dividers.
cross: this.#symbols["boxSharp.cross"],
teeDown: this.#symbols["boxSharp.teeDown"],
teeUp: this.#symbols["boxSharp.teeUp"],
teeRight: this.#symbols["boxSharp.teeRight"],
teeLeft: this.#symbols["boxSharp.teeLeft"],
};
}
get boxSharp() {
return {
topLeft: this.#symbols["boxSharp.topLeft"],
topRight: this.#symbols["boxSharp.topRight"],
bottomLeft: this.#symbols["boxSharp.bottomLeft"],
bottomRight: this.#symbols["boxSharp.bottomRight"],
horizontal: this.#symbols["boxSharp.horizontal"],
vertical: this.#symbols["boxSharp.vertical"],
cross: this.#symbols["boxSharp.cross"],
teeDown: this.#symbols["boxSharp.teeDown"],
teeUp: this.#symbols["boxSharp.teeUp"],
teeRight: this.#symbols["boxSharp.teeRight"],
teeLeft: this.#symbols["boxSharp.teeLeft"],
};
}
get sep() {
return {
powerline: this.#symbols["sep.powerline"],
powerlineThin: this.#symbols["sep.powerlineThin"],
powerlineLeft: this.#symbols["sep.powerlineLeft"],
powerlineRight: this.#symbols["sep.powerlineRight"],
powerlineThinLeft: this.#symbols["sep.powerlineThinLeft"],
powerlineThinRight: this.#symbols["sep.powerlineThinRight"],
block: this.#symbols["sep.block"],
space: this.#symbols["sep.space"],
asciiLeft: this.#symbols["sep.asciiLeft"],
asciiRight: this.#symbols["sep.asciiRight"],
dot: this.#symbols["sep.dot"],
slash: this.#symbols["sep.slash"],
pipe: this.#symbols["sep.pipe"],
};
}
get icon() {
return {
model: this.#symbols["icon.model"],
plan: this.#symbols["icon.plan"],
prewalk: this.#symbols["icon.prewalk"],
goal: this.#symbols["icon.goal"],
pause: this.#symbols["icon.pause"],
loop: this.#symbols["icon.loop"],
folder: this.#symbols["icon.folder"],
worktree: this.#symbols["icon.worktree"],
scratchFolder: this.#symbols["icon.scratchFolder"],
file: this.#symbols["icon.file"],
git: this.#symbols["icon.git"],
branch: this.#symbols["icon.branch"],
pr: this.#symbols["icon.pr"],
tokens: this.#symbols["icon.tokens"],
context: this.#symbols["icon.context"],
cost: this.#symbols["icon.cost"],
time: this.#symbols["icon.time"],
pi: this.#symbols["icon.pi"],
ghost: this.#symbols["icon.ghost"],
agents: this.#symbols["icon.agents"],
job: this.#symbols["icon.job"],
cache: this.#symbols["icon.cache"],
cacheMiss: this.#symbols["icon.cacheMiss"],
input: this.#symbols["icon.input"],
output: this.#symbols["icon.output"],
throughput: this.#symbols["icon.throughput"],
host: this.#symbols["icon.host"],
session: this.#symbols["icon.session"],
package: this.#symbols["icon.package"],
warning: this.#symbols["icon.warning"],
rewind: this.#symbols["icon.rewind"],
auto: this.#symbols["icon.auto"],
fast: this.#symbols["icon.fast"],
extensionSkill: this.#symbols["icon.extensionSkill"],
extensionTool: this.#symbols["icon.extensionTool"],
extensionSlashCommand: this.#symbols["icon.extensionSlashCommand"],
extensionMcp: this.#symbols["icon.extensionMcp"],
extensionRule: this.#symbols["icon.extensionRule"],
extensionHook: this.#symbols["icon.extensionHook"],
extensionPrompt: this.#symbols["icon.extensionPrompt"],
extensionContextFile: this.#symbols["icon.extensionContextFile"],
extensionInstruction: this.#symbols["icon.extensionInstruction"],
mic: this.#symbols["icon.mic"],
camera: this.#symbols["icon.camera"],
};
}
get thinking() {
return {
minimal: this.#symbols["thinking.minimal"],
low: this.#symbols["thinking.low"],
medium: this.#symbols["thinking.medium"],
high: this.#symbols["thinking.high"],
xhigh: this.#symbols["thinking.xhigh"],
max: this.#symbols["thinking.max"],
autoPending: this.#symbols["thinking.autoPending"],
};
}
get checkbox() {
return {
checked: this.#symbols["checkbox.checked"],
unchecked: this.#symbols["checkbox.unchecked"],
};
}
get radio() {
return {
selected: this.#symbols["radio.selected"],
unselected: this.#symbols["radio.unselected"],
};
}
get format() {
return {
bullet: this.#symbols["format.bullet"],
dash: this.#symbols["format.dash"],
bracketLeft: this.#symbols["format.bracketLeft"],
bracketRight: this.#symbols["format.bracketRight"],
};
}
get md() {
return {
quoteBorder: this.#symbols["md.quoteBorder"],
hrChar: this.#symbols["md.hrChar"],
bullet: this.#symbols["md.bullet"],
colorSwatch: this.#symbols["md.colorSwatch"],
};
}
/**
* Default spinner frames (status spinner).
*/
get spinnerFrames(): string[] {
return this.getSpinnerFrames();
}
/**
* Get spinner frames by type.
*/
getSpinnerFrames(type: SpinnerType = "status"): string[] {
return this.#spinnerFramesOverrides[type] ?? SPINNER_FRAMES[this.symbolPreset][type];
}
/**
* Get language icon for a language name.
* Maps common language names to their corresponding symbol keys.
*/
getLangIcon(lang: string | undefined): string {
if (!lang) return this.#symbols["lang.default"];
const normalized = lang.toLowerCase();
const key = langMap[normalized];
return key ? this.#symbols[key] : this.#symbols["lang.default"];
}
/**
* Language icon tinted with the language's brand color (see
* {@link LANG_BRAND_COLORS}). Falls back to the muted theme color for
* languages without a brand entry, and returns the bare (possibly empty)
* icon when the active symbol preset has none.
*/
getLangIconStyled(lang: string | undefined): string {
const icon = this.getLangIcon(lang);
if (!icon) return icon;
const key = lang ? langMap[lang.toLowerCase()] : undefined;
const hex = key ? LANG_BRAND_COLORS[key] : undefined;
if (!hex) return this.fg("muted", icon);
return `${colorToAnsi(hex, this.mode)}${icon}\x1b[39m`;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,279 @@
import {
type HighlightColors as NativeHighlightColors,
highlightCode as nativeHighlightCode,
supportsLanguage as nativeSupportsLanguage,
} from "@oh-my-pi/pi-natives";
import type { EditorTheme, MarkdownTheme, SelectListTheme, SettingsListTheme, SymbolTheme } from "@oh-my-pi/pi-tui";
import chalk from "@oh-my-pi/pi-utils/chalk";
import { LRUCache } from "@oh-my-pi/pi-utils/lru";
import { resolveMermaidAscii } from "./mermaid-cache";
import { theme } from "./theme";
import type { Theme } from "./theme-class";
// ============================================================================
// TUI Helpers
// ============================================================================
let cachedHighlightColorsFor: Theme | undefined;
let cachedHighlightColors: NativeHighlightColors | undefined;
function getHighlightColors(t: Theme): NativeHighlightColors {
if (cachedHighlightColorsFor !== t || !cachedHighlightColors) {
cachedHighlightColorsFor = t;
cachedHighlightColors = {
comment: t.getFgAnsi("syntaxComment"),
keyword: t.getFgAnsi("syntaxKeyword"),
function: t.getFgAnsi("syntaxFunction"),
variable: t.getFgAnsi("syntaxVariable"),
string: t.getFgAnsi("syntaxString"),
number: t.getFgAnsi("syntaxNumber"),
type: t.getFgAnsi("syntaxType"),
operator: t.getFgAnsi("syntaxOperator"),
punctuation: t.getFgAnsi("syntaxPunctuation"),
inserted: t.getFgAnsi("toolDiffAdded"),
deleted: t.getFgAnsi("toolDiffRemoved"),
};
}
return cachedHighlightColors;
}
/**
* Memoized native syntax highlight. Returns the joined ANSI string, or `null`
* when the native tokenizer throws so callers can apply their own fallback.
*
* Keyed on `(lang, code)` and reset whenever the active `theme` instance
* changes — the ANSI colors are baked into the highlighted output, so a theme
* switch (which always reassigns `theme`) must invalidate every entry.
*
* Why this exists: animated tool blocks (eval/bash) repaint their box on every
* ~33ms border-shimmer frame, and markdown re-lexes on every streamed delta.
* Without memoization each frame can re-tokenize an unchanged code body through
* the Rust FFI — ~26ms for 100 lines, ~40ms for 150 — consuming or overrunning
* the 33ms frame budget and starving the spinner/render timers (the "TUI freeze").
*/
const HIGHLIGHT_CACHE_MAX = 256;
const highlightCache = new LRUCache<string, string>({ max: HIGHLIGHT_CACHE_MAX });
let highlightCacheTheme: Theme | undefined;
function highlightCached(code: string, validLang: string | undefined, highlightTheme: Theme): string | null {
if (highlightCacheTheme !== highlightTheme) {
highlightCache.clear();
highlightCacheTheme = highlightTheme;
}
const key = `${validLang ?? ""}\x00${code}`;
const hit = highlightCache.get(key);
if (hit !== undefined) {
return hit;
}
let highlighted: string;
try {
highlighted = nativeHighlightCode(code, validLang, getHighlightColors(highlightTheme));
} catch {
return null;
}
highlightCache.set(key, highlighted);
return highlighted;
}
/**
* Highlight code with syntax coloring based on file extension or language.
* Returns array of highlighted lines.
*/
export function highlightCode(code: string, lang?: string, highlightTheme: Theme = theme): string[] {
const validLang = lang && nativeSupportsLanguage(lang) ? lang : undefined;
const highlighted = highlightCached(code, validLang, highlightTheme);
// Always return a fresh array: callers (e.g. renderCodeCell) push extra lines
// onto the result, which would corrupt the cached string otherwise.
return (highlighted ?? code).split("\n");
}
export function getSymbolTheme(): SymbolTheme {
// Guard against `theme` being undefined (pre-init or cross-module-instance
// plugin calls). Fall back to the ASCII preset so the returned symbols are
// usable instead of crashing. See #2998.
if (typeof theme === "undefined") {
const box = {
topLeft: "+",
topRight: "+",
bottomLeft: "+",
bottomRight: "+",
horizontal: "-",
vertical: "|",
cross: "+",
teeDown: "+",
teeUp: "+",
teeLeft: "+",
teeRight: "+",
};
return {
cursor: ">",
inputCursor: "|",
boxRound: box,
boxSharp: box,
table: box,
quoteBorder: "|",
hrChar: "-",
colorSwatch: "[]",
spinnerFrames: ["-", "\\", "|", "/"],
};
}
const preset = theme.getSymbolPreset();
return {
cursor: theme.nav.cursor,
inputCursor: preset === "ascii" ? "|" : "▏",
boxRound: theme.boxRound,
boxSharp: theme.boxSharp,
table: theme.boxSharp,
quoteBorder: theme.md.quoteBorder,
hrChar: theme.md.hrChar,
colorSwatch: theme.md.colorSwatch,
spinnerFrames: theme.getSpinnerFrames("activity"),
};
}
let cachedMarkdownTheme: MarkdownTheme | undefined;
let cachedMarkdownThemeRef: Theme | undefined;
let markdownMermaidRendering = true;
export function setMarkdownMermaidRendering(enabled: boolean): void {
if (markdownMermaidRendering === enabled) return;
markdownMermaidRendering = enabled;
cachedMarkdownTheme = undefined;
}
export function getMarkdownTheme(): MarkdownTheme {
if (cachedMarkdownTheme !== undefined && cachedMarkdownThemeRef === theme) {
return cachedMarkdownTheme;
}
const mermaid = markdownMermaidRendering
? (() => {
// Mermaid ASCII diagrams render with the active palette so they read as
// content rather than raw monochrome. Roles mirror the SVG renderer's
// mapping; `text`/`muted`/`border`/`borderMuted`/`accent` exist in every theme.
const mermaidColorMode =
theme.getColorMode() === "truecolor" ? ("truecolor" as const) : ("ansi256" as const);
const mermaidTheme = {
fg: theme.getColorHex("text"),
border: theme.getColorHex("border"),
line: theme.getColorHex("muted"),
arrow: theme.getColorHex("accent"),
corner: theme.getColorHex("muted"),
junction: theme.getColorHex("borderMuted"),
};
return { mermaidColorMode, mermaidTheme };
})()
: undefined;
const markdownTheme: MarkdownTheme = {
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),
symbols: getSymbolTheme(),
resolveMermaidAscii: mermaid
? (source, maxWidth) =>
resolveMermaidAscii(source, {
maxWidth,
theme: mermaid.mermaidTheme,
colorMode: mermaid.mermaidColorMode,
})
: undefined,
highlightCode: (code: string, lang?: string): string[] => {
const validLang = lang && nativeSupportsLanguage(lang) ? lang : undefined;
const highlighted = highlightCached(code, validLang, theme);
if (highlighted !== null) return highlighted.split("\n");
return code.split("\n").map(line => theme.fg("mdCodeBlock", line));
},
};
cachedMarkdownTheme = markdownTheme;
cachedMarkdownThemeRef = theme;
return markdownTheme;
}
export function getSelectListTheme(): SelectListTheme {
// Guard against `theme` being undefined (pre-init or cross-module-instance
// plugin calls). See #2998.
if (typeof theme === "undefined") {
return {
selectedPrefix: (text: string) => text,
selectedText: (text: string) => text,
description: (text: string) => text,
scrollInfo: (text: string) => text,
noMatch: (text: string) => text,
symbols: getSymbolTheme(),
hovered: (text: string) => text,
};
}
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),
symbols: getSymbolTheme(),
hovered: (text: string) => theme.bg("selectedBg", text),
};
}
export function getEditorTheme(): EditorTheme {
// Guard against `theme` being undefined (pre-init or cross-module-instance
// plugin calls). See #2998.
if (typeof theme === "undefined") {
return {
borderColor: (text: string) => text,
selectList: getSelectListTheme(),
symbols: getSymbolTheme(),
hintStyle: (text: string) => text,
};
}
return {
borderColor: (text: string) => theme.fg("borderMuted", text),
selectList: getSelectListTheme(),
symbols: getSymbolTheme(),
hintStyle: (text: string) => theme.fg("dim", text),
};
}
export function getSettingsListTheme(): SettingsListTheme {
// Plugins (e.g. pi-rtk-optimizer) may call this before `initTheme()` assigns
// the global `theme`, or from a separate module instance under npm-global
// installs where the live binding was never initialized. Fall back to plain
// text so the call returns a usable (unstyled) theme instead of crashing with
// "undefined is not an object (evaluating 'theme.fg')". See #2998.
if (typeof theme === "undefined") {
return {
label: (text: string) => text,
value: (text: string) => text,
description: (text: string) => text,
cursor: "> ",
hint: (text: string) => text,
heading: (text: string) => text,
section: (text: string) => text,
hovered: (text: string) => text,
};
}
return {
label: (text: string, selected: boolean, changed: boolean) =>
changed ? theme.fg("statusLineGitDirty", text) : selected ? theme.fg("accent", text) : text,
value: (text: string, selected: boolean, changed: boolean) =>
changed ? theme.fg("statusLineGitDirty", text) : selected ? theme.fg("accent", text) : theme.fg("muted", text),
description: (text: string) => theme.fg("dim", text),
cursor: theme.fg("accent", `${theme.nav.cursor} `),
hint: (text: string) => theme.fg("dim", text),
heading: (text: string, dimmed: boolean) =>
dimmed ? theme.fg("dim", theme.underline(text)) : theme.fg("muted", theme.bold(theme.underline(text))),
section: (text: string, active: boolean) =>
active ? theme.fg("accent", theme.bold(text)) : theme.fg("muted", text),
hovered: (text: string) => theme.bg("selectedBg", text),
};
}