pi config update

This commit is contained in:
Jonas H
2026-03-19 07:58:49 +01:00
parent a3c9183485
commit 871caa5adc
24 changed files with 6198 additions and 555 deletions

View File

@@ -0,0 +1,444 @@
/**
* 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");
}
});
}