Files
oh-my-pi/packages/coding-agent/src/modes/theme/loader.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

179 lines
6.0 KiB
TypeScript

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