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

746 lines
24 KiB
TypeScript

import * as fs from "node:fs";
import * as path from "node:path";
import { detectMacOSAppearance, MacAppearanceObserver } from "@oh-my-pi/pi-natives";
import type { Terminal, TerminalAppearance } from "@oh-my-pi/pi-tui";
import { colorLuma, getCustomThemesDir, logger } from "@oh-my-pi/pi-utils";
import { ansi256ToHex, resolveThemeColors, resolveVarRefs } from "./color";
import { type CreateThemeOptions, getBuiltinThemes, loadTheme, loadThemeJson } from "./loader";
import type { ThemeColor, ThemeJson } from "./schema";
import type { SymbolPreset } from "./symbols";
import type { Theme } from "./theme-class";
export { getLanguageFromPath, isMarkdownPath } from "../../utils/lang-from-path";
export { getAvailableThemes, getAvailableThemesWithPaths, getThemeByName, type ThemeInfo } from "./loader";
export { isValidThemeColor, type ThemeBg, type ThemeColor } from "./schema";
export {
getAvailableSymbolPresets,
isValidSymbolPreset,
type SpinnerType,
type SymbolKey,
type SymbolPreset,
} from "./symbols";
export { Theme } from "./theme-class";
export {
getEditorTheme,
getMarkdownTheme,
getSelectListTheme,
getSettingsListTheme,
getSymbolTheme,
highlightCode,
setMarkdownMermaidRendering,
} from "./tui-adapters";
/** Appearance detected via OSC 11 background color query, or undefined if not yet available. */
var terminalReportedAppearance: "dark" | "light" | undefined;
/** Appearance reported by the macOS fallback observer, or undefined if not yet available. */
var macOSReportedAppearance: "dark" | "light" | undefined;
function shouldUseMacOSAppearanceFallback(): boolean {
// Zellij currently breaks OSC 11 passthrough on macOS, so terminal-derived
// appearance cannot be trusted there. Fall back to host macOS appearance
// without letting it override valid terminal signals elsewhere.
return process.platform === "darwin" && !!Bun.env.ZELLIJ;
}
function detectTerminalBackground(): "dark" | "light" {
// Tier 1: terminal-reported appearance from OSC 11 luminance.
if (!shouldUseMacOSAppearanceFallback() && terminalReportedAppearance) {
return terminalReportedAppearance;
}
// Tier 2: COLORFGBG env var (static at process start, but still terminal-derived).
const colorfgbg = Bun.env.COLORFGBG || "";
if (colorfgbg) {
const parts = colorfgbg.split(";");
if (parts.length >= 2) {
const bg = parseInt(parts[1], 10);
if (!Number.isNaN(bg)) return bg < 8 ? "dark" : "light";
}
}
// Tier 3: host macOS appearance for known-broken terminal paths only.
if (shouldUseMacOSAppearanceFallback()) {
const macAppearance = macOSReportedAppearance ?? detectMacOSAppearance();
if (macAppearance) return macAppearance;
}
return "dark";
}
function getDefaultTheme(): string {
const bg = detectTerminalBackground();
return bg === "light" ? autoLightTheme : autoDarkTheme;
}
// ============================================================================
// Global Theme Instance
// ============================================================================
export var theme: Theme;
var currentThemeName: string | undefined;
/** Get the name of the currently active theme. */
export function getCurrentThemeName(): string | undefined {
return currentThemeName;
}
/** Returns unstyled `text` before `initTheme()` assigns the global theme; use only for early-render paths. */
export function fgOrPlain(color: ThemeColor, text: string, styledText: string = text): string {
return typeof theme === "undefined" ? text : theme.fg(color, styledText);
}
export interface ThemeChangeEvent {
/** Preview/presentation-only changes should repaint live UI without replacing native scrollback. */
ephemeral?: boolean;
}
var currentSymbolPresetOverride: SymbolPreset | undefined;
var currentColorBlindMode: boolean = false;
var themeWatcher: fs.FSWatcher | undefined;
var themeReloadTimer: NodeJS.Timeout | undefined;
var sigwinchHandler: (() => void) | undefined;
var autoDetectedTheme: boolean = false;
var autoDarkTheme: string = "dark";
var autoLightTheme: string = "light";
var onThemeChangeCallback: ((event: ThemeChangeEvent) => void) | undefined;
var themeLoadRequestId: number = 0;
let themeEpoch = 0;
function getCurrentThemeOptions(): CreateThemeOptions {
return {
symbolPresetOverride: currentSymbolPresetOverride,
colorBlindMode: currentColorBlindMode,
};
}
export async function initTheme(
enableWatcher: boolean = false,
symbolPreset?: SymbolPreset,
colorBlindMode?: boolean,
darkTheme?: string,
lightTheme?: string,
): Promise<void> {
autoDetectedTheme = true;
autoDarkTheme = darkTheme ?? "dark";
autoLightTheme = lightTheme ?? "light";
const name = getDefaultTheme();
currentThemeName = name;
currentSymbolPresetOverride = symbolPreset;
currentColorBlindMode = colorBlindMode ?? false;
try {
theme = await loadTheme(name, getCurrentThemeOptions());
if (enableWatcher) {
await startThemeWatcher();
startSigwinchListener();
}
} catch (err) {
logger.debug("Theme loading failed, falling back to dark theme", { error: String(err) });
currentThemeName = "dark";
theme = await loadTheme("dark", getCurrentThemeOptions());
// Don't start watcher for fallback theme
}
}
export async function setTheme(
name: string,
enableWatcher: boolean = false,
): Promise<{ success: boolean; error?: string }> {
autoDetectedTheme = false;
currentThemeName = name;
const requestId = ++themeLoadRequestId;
try {
const loadedTheme = await loadTheme(name, getCurrentThemeOptions());
if (requestId !== themeLoadRequestId) {
return { success: false, error: "Theme change superseded by a newer request" };
}
theme = loadedTheme;
if (enableWatcher) {
await startThemeWatcher();
}
notifyThemeChange();
return { success: true };
} catch (error) {
if (requestId !== themeLoadRequestId) {
return { success: false, error: "Theme change superseded by a newer request" };
}
// Theme is invalid - fall back to dark theme
currentThemeName = "dark";
theme = await loadTheme("dark", getCurrentThemeOptions());
// The active theme just changed to the fallback — bump the epoch so memoized
// renderers (e.g. ToolExecutionComponent) re-shape with the fallback colors
// instead of holding the failed theme's stale styling.
notifyThemeChange();
// Don't start watcher for fallback theme
return {
success: false,
error: error instanceof Error ? error.message : String(error),
};
}
}
export async function previewTheme(
name: string,
event: ThemeChangeEvent = { ephemeral: true },
): Promise<{ success: boolean; error?: string }> {
const requestId = ++themeLoadRequestId;
try {
const loadedTheme = await loadTheme(name, getCurrentThemeOptions());
if (requestId !== themeLoadRequestId) {
return { success: false, error: "Theme preview superseded by a newer request" };
}
theme = loadedTheme;
notifyThemeChange(event);
return { success: true };
} catch (error) {
if (requestId !== themeLoadRequestId) {
return { success: false, error: "Theme preview superseded by a newer request" };
}
return {
success: false,
error: error instanceof Error ? error.message : String(error),
};
}
}
/**
* Enable auto-detection mode, switching to the appropriate dark/light theme.
*/
export function enableAutoTheme(event: ThemeChangeEvent = {}): void {
autoDetectedTheme = true;
reevaluateAutoTheme("enableAutoTheme", event);
}
/**
* Update the theme mappings for auto-detection mode.
* When a dark/light mapping changes and auto-detection is active, re-evaluate the theme.
*/
export function setAutoThemeMapping(mode: "dark" | "light", themeName: string): void {
if (mode === "dark") autoDarkTheme = themeName;
else autoLightTheme = themeName;
reevaluateAutoTheme("setAutoThemeMapping");
}
/**
* Called when the terminal detects a dark/light appearance change.
* The terminal layer queries OSC 11 (background color) and computes luminance;
* Mode 2031 notifications trigger re-queries rather than providing the value directly.
*/
export function onTerminalAppearanceChange(
mode: "dark" | "light",
event: ThemeChangeEvent = { ephemeral: true },
): void {
if (terminalReportedAppearance === mode) return;
terminalReportedAppearance = mode;
reevaluateAutoTheme("terminal appearance", event);
}
export function setThemeInstance(themeInstance: Theme): void {
autoDetectedTheme = false;
theme = themeInstance;
currentThemeName = "<in-memory>";
stopThemeWatcher();
notifyThemeChange({ ephemeral: true });
}
/**
* Set the symbol preset override, recreating the theme with the new preset.
*/
export async function setSymbolPreset(preset: SymbolPreset): Promise<void> {
currentSymbolPresetOverride = preset;
if (!currentThemeName) return;
const requestId = ++themeLoadRequestId;
try {
const loadedTheme = await loadTheme(currentThemeName, getCurrentThemeOptions());
if (requestId !== themeLoadRequestId) return;
theme = loadedTheme;
} catch {
if (requestId !== themeLoadRequestId) return;
// Fall back to dark theme with new preset
theme = await loadTheme("dark", getCurrentThemeOptions());
if (requestId !== themeLoadRequestId) return;
}
notifyThemeChange({ ephemeral: true });
}
/**
* Get the current symbol preset override.
*/
export function getSymbolPresetOverride(): SymbolPreset | undefined {
return currentSymbolPresetOverride;
}
/**
* Set color blind mode, recreating the theme with the new setting.
* When enabled, uses blue instead of green for diff additions.
*/
export async function setColorBlindMode(enabled: boolean): Promise<void> {
currentColorBlindMode = enabled;
if (!currentThemeName) return;
const requestId = ++themeLoadRequestId;
try {
const loadedTheme = await loadTheme(currentThemeName, getCurrentThemeOptions());
if (requestId !== themeLoadRequestId) return;
theme = loadedTheme;
} catch {
if (requestId !== themeLoadRequestId) return;
// Fall back to dark theme
theme = await loadTheme("dark", getCurrentThemeOptions());
if (requestId !== themeLoadRequestId) return;
}
notifyThemeChange({ ephemeral: true });
}
/**
* Get the current color blind mode setting.
*/
export function getColorBlindMode(): boolean {
return currentColorBlindMode;
}
export function onThemeChange(callback: (event: ThemeChangeEvent) => void): () => void {
onThemeChangeCallback = callback;
return () => {
if (onThemeChangeCallback === callback) {
onThemeChangeCallback = undefined;
}
};
}
/**
* Monotonic counter bumped on any theme-affecting change that should invalidate
* cached renders: theme swaps and reloads (including the invalid-theme dark
* fallback), theme previews, symbol-preset changes, and color-blind-mode
* changes — everything that routes through {@link notifyThemeChange}. Consumers
* key cached renders on it so the next render re-shapes their output.
*/
export function getThemeEpoch(): number {
return themeEpoch;
}
/** Bump the theme epoch and notify the registered theme-change listener. */
function notifyThemeChange(event: ThemeChangeEvent = {}): void {
themeEpoch++;
onThemeChangeCallback?.(event);
}
async function startThemeWatcher(): Promise<void> {
stopThemeWatcher();
// Only watch if it's a custom theme (not built-in)
if (!currentThemeName || currentThemeName === "dark" || currentThemeName === "light") {
return;
}
const customThemesDir = getCustomThemesDir();
const watchedThemeName = currentThemeName;
const watchedFileName = `${watchedThemeName}.json`;
const themeFile = path.join(customThemesDir, watchedFileName);
// Only watch if the file exists
if (!fs.existsSync(themeFile)) {
return;
}
const scheduleReload = () => {
if (themeReloadTimer) {
clearTimeout(themeReloadTimer);
}
themeReloadTimer = setTimeout(() => {
themeReloadTimer = undefined;
// Ignore stale timers after switching themes or stopping the watcher
if (currentThemeName !== watchedThemeName) {
return;
}
// Keep the last successfully loaded theme active if the file is temporarily missing
if (!fs.existsSync(themeFile)) {
return;
}
loadTheme(watchedThemeName, getCurrentThemeOptions())
.then(loadedTheme => {
theme = loadedTheme;
notifyThemeChange({ ephemeral: true });
})
.catch(() => {
// Ignore errors (file might be in invalid state while being edited)
});
}, 100);
};
try {
themeWatcher = fs.watch(customThemesDir, (_eventType, filename) => {
if (currentThemeName !== watchedThemeName) {
return;
}
if (!filename) {
scheduleReload();
return;
}
const changedFile = String(filename);
if (changedFile !== watchedFileName) {
return;
}
scheduleReload();
});
} catch {
// Ignore errors starting watcher
}
}
/**
* Load and apply an already-resolved auto-theme name.
*/
function applyResolvedAutoTheme(resolved: string, debugLabel: string, event: ThemeChangeEvent): void {
if (resolved === currentThemeName) return;
currentThemeName = resolved;
const requestId = ++themeLoadRequestId;
loadTheme(resolved, getCurrentThemeOptions())
.then(loadedTheme => {
if (requestId !== themeLoadRequestId) return;
theme = loadedTheme;
notifyThemeChange(event);
})
.catch(err => {
if (requestId !== themeLoadRequestId) return;
logger.debug(`Theme switch on ${debugLabel} failed`, { error: String(err) });
});
}
/**
* Shared logic for re-evaluating the auto-detected theme.
* An explicit appearance is provisional input and does not alter terminal-reported state.
*/
function reevaluateAutoTheme(debugLabel: string, event: ThemeChangeEvent = {}, appearance?: "dark" | "light"): void {
if (!autoDetectedTheme) return;
const resolved =
appearance === undefined ? getDefaultTheme() : appearance === "dark" ? autoDarkTheme : autoLightTheme;
applyResolvedAutoTheme(resolved, debugLabel, event);
}
function reevaluateAutoThemeForAppearance(debugLabel: string, appearance?: "dark" | "light"): void {
reevaluateAutoTheme(debugLabel, { ephemeral: true }, appearance);
}
// ============================================================================
// macOS Appearance Fallback Observer
// ============================================================================
type MacOSAppearanceReprobeTerminal = Pick<
Terminal,
"appearance" | "onAppearanceChange" | "onAppearanceReport" | "onPrivateModeReport" | "refreshAppearance"
>;
const MACOS_APPEARANCE_REPROBE_DELAYS_MS = [25, 50, 100, 250, 500, 1000] as const;
const MACOS_APPEARANCE_RECONCILE_DELAY_MS = 1100;
/**
* Fall back to native macOS appearance notifications when the terminal
* explicitly confirms that Mode 2031 notifications are unsupported.
*
* Native notifications provisionally repaint from the host appearance and
* synchronously trigger an OSC 11 probe, followed by a bounded burst of six
* retries. A changed terminal classification cancels the sequence; otherwise
* a confirmed terminal classification is restored at the validation deadline.
*/
export function startMacOSAppearanceReprobeFallback(terminal: MacOSAppearanceReprobeTerminal): () => void {
let disposed = false;
let observerStartAttempted = false;
let observer: MacAppearanceObserver | undefined;
let probeGeneration = 0;
let probeSequenceActive = false;
let probeBaseline: TerminalAppearance | undefined;
let probeResponseConfirmed = false;
const probeTimers = new Set<Timer>();
let reconciliationTimer: Timer | undefined;
const cancelProbeSequence = (): void => {
probeGeneration++;
probeSequenceActive = false;
probeBaseline = undefined;
probeResponseConfirmed = false;
if (reconciliationTimer) {
clearTimeout(reconciliationTimer);
reconciliationTimer = undefined;
}
for (const timer of probeTimers) {
clearTimeout(timer);
}
probeTimers.clear();
};
const scheduleProbeSequence = (): void => {
cancelProbeSequence();
if (disposed || !autoDetectedTheme) return;
probeSequenceActive = true;
probeBaseline = terminal.appearance;
probeResponseConfirmed = false;
const generation = probeGeneration;
terminal.refreshAppearance?.();
if (disposed || generation !== probeGeneration || !autoDetectedTheme) return;
for (const delay of MACOS_APPEARANCE_REPROBE_DELAYS_MS) {
const timer = setTimeout(() => {
probeTimers.delete(timer);
if (disposed || generation !== probeGeneration) return;
if (!autoDetectedTheme) {
cancelProbeSequence();
return;
}
terminal.refreshAppearance?.();
}, delay);
timer.unref?.();
probeTimers.add(timer);
}
reconciliationTimer = setTimeout(() => {
reconciliationTimer = undefined;
if (disposed || generation !== probeGeneration) return;
const appearance = probeResponseConfirmed ? terminal.appearance : undefined;
cancelProbeSequence();
if (!autoDetectedTheme || !appearance) return;
reevaluateAutoThemeForAppearance("macOS appearance reconciliation", appearance);
}, MACOS_APPEARANCE_RECONCILE_DELAY_MS);
reconciliationTimer.unref?.();
};
const unsubscribeAppearanceReport = terminal.onAppearanceReport?.(() => {
if (disposed || !probeSequenceActive) return;
probeResponseConfirmed = true;
});
terminal.onAppearanceChange(appearance => {
if (disposed || !probeSequenceActive || appearance === probeBaseline) return;
cancelProbeSequence();
});
terminal.onPrivateModeReport?.((mode, supported, confirmed) => {
if (disposed || observerStartAttempted || mode !== 2031 || supported || confirmed !== true) {
return;
}
observerStartAttempted = true;
try {
observer = MacAppearanceObserver.start((err, appearance) => {
if (disposed) return;
if (err) {
cancelProbeSequence();
return;
}
if (appearance === "dark" || appearance === "light") {
reevaluateAutoThemeForAppearance("macOS provisional appearance", appearance);
}
scheduleProbeSequence();
});
} catch (err) {
logger.warn("Failed to start macOS appearance reprobe observer", { err });
}
});
return () => {
if (disposed) return;
disposed = true;
cancelProbeSequence();
if (unsubscribeAppearanceReport) unsubscribeAppearanceReport();
const activeObserver = observer;
observer = undefined;
if (!activeObserver) return;
try {
activeObserver.stop();
} catch (err) {
logger.debug("Failed to stop macOS appearance reprobe observer", { err });
}
};
}
var macObserver: { stop(): void } | undefined;
function startMacAppearanceObserver(): void {
stopMacAppearanceObserver();
if (!shouldUseMacOSAppearanceFallback()) return;
try {
macOSReportedAppearance = detectMacOSAppearance() ?? undefined;
macObserver = MacAppearanceObserver.start((err, appearance) => {
if (!err && (appearance === "dark" || appearance === "light")) {
macOSReportedAppearance = appearance;
reevaluateAutoThemeForAppearance("macOS fallback");
}
});
} catch (err) {
logger.warn("Failed to start macOS appearance observer", { err });
}
}
function stopMacAppearanceObserver(): void {
if (macObserver) {
macObserver.stop();
macObserver = undefined;
}
macOSReportedAppearance = undefined;
}
// ============================================================================
// SIGWINCH Listener
// ============================================================================
/** Re-check appearance on SIGWINCH and switch dark/light when using auto-detected theme. */
function startSigwinchListener(): void {
stopSigwinchListener();
sigwinchHandler = () => {
reevaluateAutoThemeForAppearance("SIGWINCH");
};
process.on("SIGWINCH", sigwinchHandler);
startMacAppearanceObserver();
}
function stopSigwinchListener(): void {
if (sigwinchHandler) {
process.removeListener("SIGWINCH", sigwinchHandler);
sigwinchHandler = undefined;
}
stopMacAppearanceObserver();
}
export function stopThemeWatcher(): void {
if (themeReloadTimer) {
clearTimeout(themeReloadTimer);
themeReloadTimer = undefined;
}
if (themeWatcher) {
themeWatcher.close();
themeWatcher = undefined;
}
stopSigwinchListener();
terminalReportedAppearance = undefined;
}
// ============================================================================
// HTML Export Helpers
// ============================================================================
/**
* Classify a parsed theme JSON as light/dark by the perceived luminance of its
* status-line background. Mirrors {@link Theme.isLight} so the synchronous
* helpers below stay in lockstep with the runtime classifier — see the comment
* on `Theme.statusLineLuminance` for why `statusLineBg` is the source of truth
* (themes like `porcelain` style a dark chat bubble on an otherwise-light
* theme, so `userMessageBg` is unreliable).
*/
function isLightThemeJson(themeJson: ThemeJson): boolean {
try {
const resolved = resolveVarRefs(themeJson.colors.statusLineBg, themeJson.vars ?? {});
const luminance = colorLuma(resolved);
return luminance !== undefined && luminance > 0.5;
} catch {
return false;
}
}
function getHtmlDefaultTextForSurface(surface: string | number | undefined): string {
const luminance = surface === undefined ? undefined : colorLuma(surface);
return luminance !== undefined && luminance > 0.5 ? "#000000" : "#e5e5e7";
}
function resolveThemeExportColors(themeJson: ThemeJson): {
pageBg?: string;
cardBg?: string;
infoBg?: string;
} {
const exportSection = themeJson.export;
if (!exportSection) return {};
const vars = themeJson.vars ?? {};
const resolve = (value: string | number | undefined): string | undefined => {
if (value === undefined) return undefined;
if (typeof value === "number") return ansi256ToHex(value);
if (value === "" || value.startsWith("#")) return value;
const varName = value.startsWith("$") ? value.slice(1) : value;
if (varName in vars) {
const resolved = resolveVarRefs(varName, vars);
return typeof resolved === "number" ? ansi256ToHex(resolved) : resolved;
}
return value;
};
return {
pageBg: resolve(exportSection.pageBg),
cardBg: resolve(exportSection.cardBg),
infoBg: resolve(exportSection.infoBg),
};
}
/**
* Get resolved theme colors as CSS-compatible hex strings.
* Used by HTML export to generate CSS custom properties.
*/
export async function getResolvedThemeColors(themeName?: string): Promise<Record<string, string>> {
const name = themeName ?? getDefaultTheme();
const themeJson = await loadThemeJson(name);
const exportColors = resolveThemeExportColors(themeJson);
const resolved = resolveThemeColors(themeJson.colors, themeJson.vars);
// Empty foreground tokens use the terminal default color. In HTML export,
// that default must contrast the export surface, not the TUI status line:
// custom light themes can still export dark transcript cards when they omit
// `export`, because generateThemeVars derives those cards from userMessageBg.
const defaultText = getHtmlDefaultTextForSurface(
exportColors.cardBg ?? exportColors.pageBg ?? resolved.userMessageBg,
);
const cssColors: Record<string, string> = {};
for (const [key, value] of Object.entries(resolved)) {
if (typeof value === "number") {
cssColors[key] = ansi256ToHex(value);
} else if (value === "") {
// Empty means default terminal color - use sensible fallback for HTML
cssColors[key] = defaultText;
} else {
cssColors[key] = value;
}
}
return cssColors;
}
/**
* Check if a theme is a "light" theme by analyzing its status-line background
* luminance. Loads theme JSON synchronously (built-in or custom file on disk)
* for callers in synchronous flows (settings migration, setup wizard).
*/
export function isLightTheme(themeName?: string): boolean {
const name = themeName ?? "dark";
const builtinThemes = getBuiltinThemes();
let themeJson: ThemeJson | undefined;
if (name in builtinThemes) {
themeJson = builtinThemes[name];
} else {
try {
const customPath = path.join(getCustomThemesDir(), `${name}.json`);
const content = fs.readFileSync(customPath, "utf-8");
themeJson = JSON.parse(content) as ThemeJson;
} catch {
return false;
}
}
return isLightThemeJson(themeJson);
}
/**
* Get explicit export colors from theme JSON, if specified.
* Returns undefined for each color that isn't explicitly set.
*/
export async function getThemeExportColors(themeName?: string): Promise<{
pageBg?: string;
cardBg?: string;
infoBg?: string;
}> {
const name = themeName ?? getDefaultTheme();
try {
const themeJson = await loadThemeJson(name);
return resolveThemeExportColors(themeJson);
} catch {
return {};
}
}