445 lines
12 KiB
TypeScript
445 lines
12 KiB
TypeScript
/**
|
|
* 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<number, string>;
|
|
}
|
|
|
|
/**
|
|
* 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");
|
|
}
|
|
});
|
|
}
|