import fs from "node:fs"; import fsp from "node:fs/promises"; import path from "node:path"; import type { ExtensionAPI, Theme as PiTheme, } from "@mariozechner/pi-coding-agent"; import { Theme } from "@mariozechner/pi-coding-agent"; const THEME_NAME = "matugen"; const THEME_PATH = path.join( process.env.HOME ?? "", ".pi/agent/themes/matugen.json", ); const POLL_INTERVAL_MS = 500; type ColorValue = string | number; type ThemeJson = { name: string; vars?: Record; colors: Record; }; const BG_KEYS = new Set([ "selectedBg", "userMessageBg", "customMessageBg", "toolPendingBg", "toolSuccessBg", "toolErrorBg", ]); function detectColorMode(): "truecolor" | "256color" { const colorterm = process.env.COLORTERM; if (colorterm === "truecolor" || colorterm === "24bit") return "truecolor"; if (process.env.WT_SESSION) return "truecolor"; const term = process.env.TERM || ""; if (term === "dumb" || term === "" || term === "linux") return "256color"; if (process.env.TERM_PROGRAM === "Apple_Terminal") return "256color"; return "truecolor"; } function resolveVarRefs( value: ColorValue, vars: Record, visited = new Set(), ): ColorValue { if ( typeof value === "number" || value === "" || (typeof value === "string" && value.startsWith("#")) ) { return value; } if (typeof value !== "string") { throw new Error(`Invalid color value: ${String(value)}`); } if (visited.has(value)) { throw new Error(`Circular variable reference: ${value}`); } if (!(value in vars)) { throw new Error(`Variable reference not found: ${value}`); } visited.add(value); return resolveVarRefs(vars[value], vars, visited); } function resolveThemeColors( colors: Record, vars: Record = {}, ): Record { const resolved: Record = {}; for (const [key, value] of Object.entries(colors)) { resolved[key] = resolveVarRefs(value, vars); } return resolved; } async function loadThemeFromPath(filePath: string): Promise { const content = await fsp.readFile(filePath, "utf-8"); const parsed = JSON.parse(content) as ThemeJson; const resolved = resolveThemeColors(parsed.colors, parsed.vars ?? {}); const fgColors: Record = {}; const bgColors: Record = {}; for (const [key, value] of Object.entries(resolved)) { if (BG_KEYS.has(key)) { bgColors[key] = value; } else { fgColors[key] = value; } } return new Theme(fgColors as never, bgColors as never, detectColorMode(), { name: parsed.name ?? THEME_NAME, sourcePath: filePath, }); } export default function matugenThemeWatch(pi: ExtensionAPI) { let watcher: fs.StatWatcher | null = null; let debounce: NodeJS.Timeout | null = null; pi.on("session_start", (_event, ctx) => { try { watcher = fs.watchFile( THEME_PATH, { interval: POLL_INTERVAL_MS }, (curr, prev) => { if (curr.mtimeMs === prev.mtimeMs) return; if (debounce) { clearTimeout(debounce); } debounce = setTimeout(async () => { // i wish this fucking worked // pi.sendUserMessage("/reload", { deliverAs: "followUp" }); const theme = await loadThemeFromPath(THEME_PATH); const res = ctx.ui.setTheme(theme); if (res.success) { ctx.ui.notify("Background changed. Colors: ", "info"); } else { ctx.ui.notify("Theme update failed, fuck", "error"); } }, 150); }, ); } catch (error) { ctx.ui.notify(`Theme watch failed: ${String(error)}`, "error"); } }); pi.on("session_shutdown", () => { if (watcher) { // eslint-disable-next-line @typescript-eslint/no-explicit-any -- weird types fs.unwatchFile(THEME_PATH, watcher as any); watcher = null; } if (debounce) { clearTimeout(debounce); debounce = null; } }); }