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:
@@ -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),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user