Files
oh-my-pi/packages/coding-agent/src/modes/theme/theme-class.ts
T
can1357 e0ec404de6 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.
2026-08-08 06:32:01 +02:00

612 lines
19 KiB
TypeScript

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`;
}
}