/** * WezTerm Theme Sync Extension * * Syncs pi theme with WezTerm terminal colors on startup. * * How it works: * 1. Finds the WezTerm config directory (via $WEZTERM_CONFIG_DIR or defaults) * 2. Runs the config through luajit to extract effective colors * 3. Maps ANSI palette slots to pi theme colors * 4. Writes a pi theme file and activates it * * Supports: * - Inline `config.colors = { ... }` definitions * - Lua theme modules loaded via require() * - Any config structure as long as `config.colors` is set * * ANSI slots (consistent across themes): * 0: black 8: bright black (gray/muted) * 1: red 9: bright red * 2: green 10: bright green * 3: yellow 11: bright yellow * 4: blue 12: bright blue * 5: magenta 13: bright magenta * 6: cyan 14: bright cyan * 7: white 15: bright white * * Requirements: * - WezTerm installed and running (sets $WEZTERM_CONFIG_DIR) * - luajit or lua available in PATH */ import { execSync } from "node:child_process"; import { createHash } from "node:crypto"; import { existsSync, mkdirSync, readdirSync, unlinkSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { homedir } from "node:os"; import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; interface WeztermColors { background: string; foreground: string; palette: Record; } /** * Find the WezTerm config directory. * Checks $WEZTERM_CONFIG_DIR, then standard locations. */ function findConfigDir(): string | null { if (process.env.WEZTERM_CONFIG_DIR && existsSync(process.env.WEZTERM_CONFIG_DIR)) { return process.env.WEZTERM_CONFIG_DIR; } const candidates = [ join(homedir(), ".config", "wezterm"), join(homedir(), ".wezterm"), ]; for (const dir of candidates) { if (existsSync(dir)) return dir; } return null; } /** * Find which Lua interpreter is available. */ function findLua(): string | null { for (const cmd of ["luajit", "lua5.4", "lua5.3", "lua"]) { try { execSync(`which ${cmd}`, { stdio: "pipe" }); return cmd; } catch { // Try next } } return null; } /** * Extract colors from WezTerm config by evaluating it with a mocked wezterm module. * Writes a temporary Lua helper script, runs it with luajit, then cleans up. */ function getWeztermColors(configDir: string, lua: string): WeztermColors | null { const configFile = join(configDir, "wezterm.lua"); if (!existsSync(configFile)) return null; const tmpScript = join(configDir, ".pi-extract-colors.lua"); const extractScript = ` -- Mock wezterm module with commonly used functions local mock_wezterm = { font = function(name) return name end, font_with_fallback = function(names) return names end, hostname = function() return "mock" end, home_dir = ${JSON.stringify(homedir())}, config_dir = ${JSON.stringify(configDir)}, target_triple = "x86_64-unknown-linux-gnu", version = "mock", log_info = function() end, log_warn = function() end, log_error = function() end, on = function() end, action = setmetatable({}, { __index = function(_, k) return function(...) return { action = k, args = {...} } end end }), action_callback = function(fn) return fn end, color = { parse = function(c) return c end, get_builtin_schemes = function() return {} end, }, gui = { get_appearance = function() return "Dark" end, }, GLOBAL = {}, nerdfonts = setmetatable({}, { __index = function() return "" end }), } mock_wezterm.plugin = { require = function() return {} end } package.loaded["wezterm"] = mock_wezterm -- Add config dir to Lua search path package.path = ${JSON.stringify(configDir)} .. "/?.lua;" .. ${JSON.stringify(configDir)} .. "/?/init.lua;" .. package.path -- Try to load the config local ok, config = pcall(dofile, ${JSON.stringify(configFile)}) if not ok then io.stderr:write("Failed to load config: " .. tostring(config) .. "\\n") os.exit(1) end if type(config) ~= "table" then io.stderr:write("Config did not return a table\\n") os.exit(1) end local colors = config.colors if not colors then if config.color_scheme then io.stderr:write("color_scheme=" .. tostring(config.color_scheme) .. "\\n") end io.stderr:write("No inline colors found in config\\n") os.exit(1) end if type(colors) == "table" then if colors.background then print("background=" .. colors.background) end if colors.foreground then print("foreground=" .. colors.foreground) end if colors.ansi then for i, c in ipairs(colors.ansi) do print("ansi" .. (i-1) .. "=" .. c) end end if colors.brights then for i, c in ipairs(colors.brights) do print("bright" .. (i-1) .. "=" .. c) end end end `; try { writeFileSync(tmpScript, extractScript); const output = execSync(`${lua} ${JSON.stringify(tmpScript)}`, { encoding: "utf-8", timeout: 5000, cwd: configDir, stdio: ["pipe", "pipe", "pipe"], }); return parseWeztermOutput(output); } catch (err: any) { if (err.stderr) { console.error(`[wezterm-theme-sync] ${err.stderr.trim()}`); } return null; } finally { try { unlinkSync(tmpScript); } catch { /* ignore */ } } } function parseWeztermOutput(output: string): WeztermColors { const colors: WeztermColors = { background: "#1e1e1e", foreground: "#d4d4d4", palette: {}, }; for (const line of output.split("\n")) { const match = line.match(/^(\w+)=(.+)$/); if (!match) continue; const [, key, value] = match; const color = normalizeColor(value.trim()); if (key === "background") { colors.background = color; } else if (key === "foreground") { colors.foreground = color; } else { const ansiMatch = key.match(/^ansi(\d+)$/); const brightMatch = key.match(/^bright(\d+)$/); if (ansiMatch) { const idx = parseInt(ansiMatch[1], 10); if (idx >= 0 && idx <= 7) colors.palette[idx] = color; } else if (brightMatch) { const idx = parseInt(brightMatch[1], 10); if (idx >= 0 && idx <= 7) colors.palette[idx + 8] = color; } } } return colors; } function normalizeColor(color: string): string { const trimmed = color.trim(); if (trimmed.startsWith("#")) { if (trimmed.length === 4) { return `#${trimmed[1]}${trimmed[1]}${trimmed[2]}${trimmed[2]}${trimmed[3]}${trimmed[3]}`; } return trimmed.toLowerCase(); } if (/^[0-9a-fA-F]{6}$/.test(trimmed)) { return `#${trimmed}`.toLowerCase(); } return `#${trimmed}`.toLowerCase(); } function hexToRgb(hex: string): { r: number; g: number; b: number } { const h = hex.replace("#", ""); return { r: parseInt(h.substring(0, 2), 16), g: parseInt(h.substring(2, 4), 16), b: parseInt(h.substring(4, 6), 16), }; } function rgbToHex(r: number, g: number, b: number): string { const clamp = (n: number) => Math.round(Math.min(255, Math.max(0, n))); return `#${clamp(r).toString(16).padStart(2, "0")}${clamp(g).toString(16).padStart(2, "0")}${clamp(b).toString(16).padStart(2, "0")}`; } function getLuminance(hex: string): number { const { r, g, b } = hexToRgb(hex); return (0.299 * r + 0.587 * g + 0.114 * b) / 255; } function adjustBrightness(hex: string, amount: number): string { const { r, g, b } = hexToRgb(hex); return rgbToHex(r + amount, g + amount, b + amount); } function mixColors(color1: string, color2: string, weight: number): string { const c1 = hexToRgb(color1); const c2 = hexToRgb(color2); return rgbToHex( c1.r * weight + c2.r * (1 - weight), c1.g * weight + c2.g * (1 - weight), c1.b * weight + c2.b * (1 - weight), ); } function generatePiTheme(colors: WeztermColors, themeName: string): object { const bg = colors.background; const fg = colors.foreground; const isDark = getLuminance(bg) < 0.5; // ANSI color slots - trust the standard for semantic colors const error = colors.palette[1] || "#cc6666"; const success = colors.palette[2] || "#98c379"; const warning = colors.palette[3] || "#e5c07b"; const link = colors.palette[4] || "#61afef"; const accent = colors.palette[5] || "#c678dd"; const accentAlt = colors.palette[6] || "#56b6c2"; // Derive neutrals from bg/fg for consistent readability const muted = mixColors(fg, bg, 0.65); const dim = mixColors(fg, bg, 0.45); const borderMuted = mixColors(fg, bg, 0.25); // Derive backgrounds const bgShift = isDark ? 12 : -12; const selectedBg = adjustBrightness(bg, bgShift); const userMsgBg = adjustBrightness(bg, Math.round(bgShift * 0.7)); const toolPendingBg = adjustBrightness(bg, Math.round(bgShift * 0.4)); const toolSuccessBg = mixColors(bg, success, 0.88); const toolErrorBg = mixColors(bg, error, 0.88); const customMsgBg = mixColors(bg, accent, 0.92); return { $schema: "https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json", name: themeName, vars: { bg, fg, accent, accentAlt, link, error, success, warning, muted, dim, borderMuted, selectedBg, userMsgBg, toolPendingBg, toolSuccessBg, toolErrorBg, customMsgBg, }, colors: { accent: "accent", border: "link", borderAccent: "accent", borderMuted: "borderMuted", success: "success", error: "error", warning: "warning", muted: "muted", dim: "dim", text: "", thinkingText: "muted", selectedBg: "selectedBg", userMessageBg: "userMsgBg", userMessageText: "", customMessageBg: "customMsgBg", customMessageText: "", customMessageLabel: "accent", toolPendingBg: "toolPendingBg", toolSuccessBg: "toolSuccessBg", toolErrorBg: "toolErrorBg", toolTitle: "", toolOutput: "muted", mdHeading: "warning", mdLink: "link", mdLinkUrl: "dim", mdCode: "accent", mdCodeBlock: "success", mdCodeBlockBorder: "muted", mdQuote: "muted", mdQuoteBorder: "muted", mdHr: "muted", mdListBullet: "accent", toolDiffAdded: "success", toolDiffRemoved: "error", toolDiffContext: "muted", syntaxComment: "muted", syntaxKeyword: "accent", syntaxFunction: "link", syntaxVariable: "accentAlt", syntaxString: "success", syntaxNumber: "accent", syntaxType: "accentAlt", syntaxOperator: "fg", syntaxPunctuation: "muted", thinkingOff: "borderMuted", thinkingMinimal: "muted", thinkingLow: "link", thinkingMedium: "accentAlt", thinkingHigh: "accent", thinkingXhigh: "accent", bashMode: "success", }, export: { pageBg: isDark ? adjustBrightness(bg, -8) : adjustBrightness(bg, 8), cardBg: bg, infoBg: mixColors(bg, warning, 0.88), }, }; } function computeThemeHash(colors: WeztermColors): string { const parts: string[] = []; parts.push(`bg=${colors.background}`); parts.push(`fg=${colors.foreground}`); for (let i = 0; i <= 15; i++) { parts.push(`p${i}=${colors.palette[i] ?? ""}`); } return createHash("sha1").update(parts.join("\n")).digest("hex").slice(0, 8); } function cleanupOldThemes(themesDir: string, keepFile: string): void { try { for (const file of readdirSync(themesDir)) { if (file === keepFile) continue; if (file.startsWith("wezterm-sync-") && file.endsWith(".json")) { unlinkSync(join(themesDir, file)); } } } catch { // Best-effort cleanup } } export default function (pi: ExtensionAPI) { pi.on("session_start", async (_event, ctx) => { const configDir = findConfigDir(); if (!configDir) { return; } const lua = findLua(); if (!lua) { return; } const colors = getWeztermColors(configDir, lua); if (!colors) { return; } const themesDir = join(homedir(), ".pi", "agent", "themes"); if (!existsSync(themesDir)) { mkdirSync(themesDir, { recursive: true }); } const hash = computeThemeHash(colors); const themeName = `wezterm-sync-${hash}`; const themeFile = `${themeName}.json`; const themePath = join(themesDir, themeFile); // Skip if already on the correct synced theme (avoids repaint) if (ctx.ui.theme.name === themeName) { return; } const themeJson = generatePiTheme(colors, themeName); writeFileSync(themePath, JSON.stringify(themeJson, null, 2)); // Remove old generated themes cleanupOldThemes(themesDir, themeFile); // Set by name so pi loads from the file we just wrote const result = ctx.ui.setTheme(themeName); if (!result.success) { ctx.ui.notify(`WezTerm theme sync failed: ${result.error}`, "error"); } }); }