pi config update
This commit is contained in:
444
pi/.pi/agent/extensions/wezterm-theme-sync/index.ts
Normal file
444
pi/.pi/agent/extensions/wezterm-theme-sync/index.ts
Normal 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");
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user