deleted redundant extensions

This commit is contained in:
2026-09-04 15:48:20 +02:00
parent bcc4340cad
commit dc30540dab
5 changed files with 47 additions and 924 deletions

View File

@@ -1,99 +0,0 @@
/**
* Git Checkout Guard Extension
*
* Prevents models from using `git checkout` or `git restore` to silently
* discard uncommitted changes in files.
*/
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { isToolCallEventType } from "@mariozechner/pi-coding-agent";
import { execSync } from "child_process";
/**
* Parse file paths from a git checkout/restore command.
* Returns null if the command doesn't look like a file-restore operation.
*/
function parseFileRestoreArgs(command: string): string[] | null {
// Normalize whitespace
const cmd = command.trim().replace(/\s+/g, " ");
// Match: git checkout -- <files>
// Match: git checkout <ref> -- <files>
const checkoutDashDash = cmd.match(/\bgit\s+checkout\b.*?\s--\s+(.+)/);
if (checkoutDashDash) {
return checkoutDashDash[1].trim().split(/\s+/);
}
// Match: git restore [--staged] [--source=<ref>] <files>
// (git restore always operates on files)
const restore = cmd.match(/\bgit\s+restore\s+(.+)/);
if (restore) {
// Filter out flags like --staged, --source=..., --worktree, --patch
const args = restore[1].trim().split(/\s+/);
const files = args.filter((a) => !a.startsWith("-"));
return files.length > 0 ? files : null;
}
return null;
}
/**
* Check which of the given file paths have uncommitted changes (staged or unstaged).
* Returns the subset that are dirty.
*/
function getDirtyFiles(files: string[], cwd: string): string[] {
const dirty: string[] = [];
for (const file of files) {
try {
// --porcelain output is empty for clean files
const out = execSync(`git status --porcelain -- ${JSON.stringify(file)}`, {
cwd,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
if (out.length > 0) {
dirty.push(file);
}
} catch {
// Not a git repo or other error — skip
}
}
return dirty;
}
export default function (pi: ExtensionAPI) {
pi.on("tool_call", async (event, ctx) => {
if (!isToolCallEventType("bash", event)) return undefined;
const command: string = event.input.command ?? "";
const files = parseFileRestoreArgs(command);
if (!files || files.length === 0) return undefined;
const cwd = process.cwd();
const dirty = getDirtyFiles(files, cwd);
if (dirty.length === 0) return undefined; // nothing to protect
const fileList = dirty.map((f) => `${f}`).join("\n");
if (!ctx.hasUI) {
return {
block: true,
reason: `git-checkout-guard: the following files have uncommitted changes and cannot be silently reverted:\n${fileList}\nShow the diff to the user and ask for explicit confirmation first.`,
};
}
const choice = await ctx.ui.select(
`⚠️ git-checkout-guard\n\nThe command:\n ${command}\n\nwould discard uncommitted changes in:\n${fileList}\n\nProceed?`,
["No, cancel (show diff instead)", "Yes, discard changes anyway"],
);
if (choice !== "Yes, discard changes anyway") {
return {
block: true,
reason: `Blocked by git-checkout-guard. Run \`git diff ${dirty.join(" ")}\` and review before discarding.`,
};
}
return undefined;
});
}

View File

@@ -1,114 +0,0 @@
/**
* Subagent pane lifecycle reminder.
*
* When the parent agent dispatches to a `pane: true` agent via `subagent`,
* this extension floats a cleanup reminder to the parent only:
*
* - immediately: appended to the `subagent` tool result of that call;
* - persistently: a one-line system-prompt note on later turns until the
* parent calls `stop_subagent` for that agent (or the session ends).
*
* Nothing is injected for bg agents (`pane: false`), for `delegate_subagent`
* (pane targets are rejected there anyway), for `/agents` commands, or in
* sessions that never spawn a pane agent. Other sessions and workflows stay
* untouched.
*/
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import * as fs from "node:fs";
import * as path from "node:path";
import { homedir } from "node:os";
interface SubagentInput {
agent?: unknown;
tasks?: Array<{ agent?: unknown }>;
chain?: Array<{ agent?: unknown }>;
}
const USER_AGENT_DIRS: string[] = [
path.join(homedir(), ".pi", "agent", "agents"),
path.join(homedir(), ".claude", "agents"),
];
function projectAgentDirs(cwd: string): string[] {
const dirs: string[] = [];
let current = path.resolve(cwd);
for (;;) {
dirs.push(path.join(current, ".pi", "agents"), path.join(current, ".claude", "agents"));
const parent = path.dirname(current);
if (parent === current) break;
current = parent;
}
return dirs;
}
function hasPaneFrontmatter(text: string): boolean {
const block = text.match(/^---\r?\n([\s\S]*?)\r?\n---/);
return block ? /^\s*pane\s*:\s*(?:true|yes|on)\s*$/im.test(block[1]) : false;
}
function isPaneAgent(name: string, cwd: string): boolean {
for (const dir of [...USER_AGENT_DIRS, ...projectAgentDirs(cwd)]) {
for (const ext of [".md", ".markdown"]) {
try {
if (hasPaneFrontmatter(fs.readFileSync(path.join(dir, `${name}${ext}`), "utf8"))) return true;
} catch {
// not present in this directory
}
}
}
return false;
}
function agentNamesFrom(input: SubagentInput): string[] {
const names = new Set<string>();
const add = (value: unknown) => {
if (typeof value === "string" && value.trim()) names.add(value.trim());
};
add(input.agent);
for (const item of input.tasks ?? []) add(item.agent);
for (const item of input.chain ?? []) add(item.agent);
return [...names];
}
function reminderText(names: string[]): string {
const quoted = names.map((name) => `\`${name}\``).join(", ");
return (
`\n\nPane cleanup: you spawned persistent pane agent(s) ${quoted}. ` +
`After you collect the result, call \`stop_subagent\` with the agent name to kill its tmux pane. ` +
`Skip only if you plan a follow-up (steer or another subagent call to the same agent); stop it at the end of the workflow. ` +
`Stopping preserves the session file, so a later \`subagent\` call resumes the agent's memory.`
);
}
export default function (pi: ExtensionAPI) {
// Agent names whose panes are still alive this session.
const pending = new Set<string>();
pi.on("tool_call", (event) => {
if (event.toolName === "stop_subagent") {
const agent = (event.input as { agent?: unknown } | undefined)?.agent;
if (typeof agent === "string") pending.delete(agent);
}
});
pi.on("tool_result", (event, ctx) => {
if (event.toolName !== "subagent" || event.isError) return;
const input = (event.input as SubagentInput | undefined) ?? {};
const paneAgents = agentNamesFrom(input).filter((name) => isPaneAgent(name, ctx.cwd));
if (paneAgents.length === 0) return;
for (const name of paneAgents) pending.add(name);
return {
content: [...(event.content ?? []), { type: "text" as const, text: reminderText(paneAgents) }],
};
});
pi.on("before_agent_start", (event) => {
if (pending.size === 0) return;
const note =
"\n\nPane cleanup (pending): " +
`[${[...pending].join(", ")}]. ` +
"After collecting their results, call stop_subagent for each (unless a follow-up is planned).";
return { systemPrompt: event.systemPrompt + note };
});
}

View File

@@ -32,9 +32,10 @@ import { topBorder, bottomBorder, frameLine, splitBorder, borderColor, sanitize,
* result.details.diff, result.details.truncation, context.args.
* FRAGILE (private — only needed to box OTHER extensions' tools):
* patchAllToolBlocks() monkey-patches ToolExecutionComponent.prototype.render
* and reads private members: this.hideComponent, this.hasRendererDefinition(),
* this.getRenderShell(), this.isPartial, this.result(.isError), this.toolName,
* plus the global theme symbol Symbol.for("@earendil-works/pi-coding-agent:theme").
* AND prototype.updateDisplay, and reads private members: this.hideComponent,
* this.hasRendererDefinition(), this.getRenderShell(), this.isPartial,
* this.result(.isError), this.toolName, plus the global theme symbol
* Symbol.for("@earendil-works/pi-coding-agent:theme").
* It is wrapped in try/catch that falls back to the original renderer, so a pi
* bump degrades gracefully (those blocks just lose the box) instead of crashing.
* If you don't need third-party tools boxed, deleting patchAllToolBlocks() and
@@ -45,6 +46,9 @@ import { topBorder, bottomBorder, frameLine, splitBorder, borderColor, sanitize,
* (pi's own Text/Markdown do this). ToolBoxTop/ToolBoxBody follow that contract;
* without it a large bash/read output is re-wrapped every frame (measured
* 26k64k line-wraps/sec) and pi slows to a crawl as the session grows.
* patchAllToolBlocks() now follows the same contract: framed lines are cached
* per block instance and rebuilt only when updateDisplay() runs (content
* change), or when width/theme change.
*/
/** Timing info per tool call (only for calls executed in this process). */
@@ -140,6 +144,22 @@ function patchAllToolBlocks() {
proto.__boxedBlocks = true;
const originalRender = proto.render;
// Content-version tracking. Every state change on a tool block funnels
// through updateDisplay(): new result, partial streaming update, ctrl+o
// expand toggle, hide, renderer-driven invalidate(), async kitty-image
// conversion. Bump a per-instance version there so the outer cache can tell
// "same content" from "content changed" without reading more internals.
// If pi ever renames/removes updateDisplay, the version stays undefined and
// the render path falls back to the old uncached behavior.
if (typeof proto.updateDisplay === "function") {
const originalUpdateDisplay = proto.updateDisplay;
proto.updateDisplay = function (...args: unknown[]) {
const result = originalUpdateDisplay.apply(this, args);
this.__boxedVersion = (this.__boxedVersion ?? 0) + 1;
return result;
};
}
proto.render = function (width: number) {
try {
if (this.hideComponent) return originalRender.call(this, width);
@@ -149,6 +169,17 @@ function patchAllToolBlocks() {
const theme = (globalThis as any)[Symbol.for("@earendil-works/pi-coding-agent:theme")];
if (!theme || width < 12) return originalRender.call(this, width);
// Cache the framed lines per block instance, keyed on width + theme +
// content version (bumped in the updateDisplay wrapper above). Cache hits
// return a fresh shallow copy: pi's render pipeline mutates the returned
// array in place (applyLineResets replaces every line with a
// reset-appended copy), so handing out the cached array itself would
// accumulate duplicate resets and make every line "changed" every frame.
const cache = this.__boxedCache ?? (this.__boxedCache = { width: -1, theme: undefined, version: -1, lines: [] });
if (this.__boxedVersion !== undefined && cache.width === width && cache.theme === theme && cache.version === this.__boxedVersion) {
return [...cache.lines];
}
const inner = originalRender.call(this, width - 4);
// Kitty graphics can't be re-framed - leave those blocks untouched.
if (inner.some((l: string) => l.includes("\x1b_G"))) return originalRender.call(this, width);
@@ -157,7 +188,13 @@ function patchAllToolBlocks() {
const isBlank = (l: string) => l.replace(/\x1b\[[0-9;]*m/g, "").trim() === "";
while (inner.length > 0 && isBlank(inner[0])) inner.shift();
while (inner.length > 0 && isBlank(inner[inner.length - 1])) inner.pop();
if (inner.length === 0) return [];
if (inner.length === 0) {
cache.width = width;
cache.theme = theme;
cache.version = this.__boxedVersion;
cache.lines = [];
return [];
}
const status = this.isPartial ? "running" : this.result ? (this.result.isError ? "error" : "ok") : "running";
const color = borderColor(theme, status);
@@ -170,7 +207,12 @@ function patchAllToolBlocks() {
const lines = ["", topBorder(width, title, color)];
for (const line of inner) lines.push(frameLine(width, line, color));
lines.push(bottomBorder(width, "", color));
return lines;
cache.width = width;
cache.theme = theme;
cache.version = this.__boxedVersion;
cache.lines = lines;
return [...lines];
} catch {
return originalRender.call(this, width);
}

View File

@@ -1,245 +0,0 @@
import { wrapTextWithAnsi, visibleWidth, truncateToWidth, matchesKey, Key } from "@earendil-works/pi-tui";
import { topBorder, splitBorder, sanitize } from "./lib/boxes.ts";
/**
* transcript-viewer: A scrollable transcript overlay with a claude-cloak style
* scrollbar. User messages are marked with `*` on the scrollbar track and you
* can jump between them.
*
* pi has no API to scroll the live chat (it lives in the terminal's native
* scrollback), so this renders the session transcript in an overlay with its
* own scroll state.
*
* Keys:
* ctrl+q open the viewer
* n / N jump to next / previous user message
* j / k, ↓ / ↑ scroll one line
* ctrl+d / ctrl+u half page down / up
* g / G top / bottom
* q / esc close
*/
function contentToText(content: any): string {
if (typeof content === "string") return content;
if (Array.isArray(content)) {
return content
.map((block: any) => (block?.type === "text" ? block.text : block?.type === "image" ? "[image]" : ""))
.filter((t: string) => t !== "")
.join("\n");
}
return "";
}
function argsSummary(args: any) {
if (!args) return "";
const preferred = ["command", "path", "pattern", "url"];
for (const key of preferred) {
if (typeof args[key] === "string") return args[key].replace(/\s+/g, " ");
}
const first = Object.values(args).find((v) => typeof v === "string");
return first ? (first as string).replace(/\s+/g, " ") : "";
}
interface Item {
kind: "user" | "assistant" | "tool" | "info";
text: string;
}
/** Flatten the current session branch into displayable items. */
function buildItems(ctx: any): Item[] {
const items: Item[] = [];
for (const entry of ctx.sessionManager.getBranch()) {
if (entry.type === "message") {
const msg = entry.message;
if (msg.role === "user") {
const text = contentToText(msg.content).trim();
if (text) items.push({ kind: "user", text });
} else if (msg.role === "assistant") {
for (const block of msg.content ?? []) {
if (block?.type === "text" && block.text?.trim()) {
items.push({ kind: "assistant", text: block.text.trim() });
} else if (block?.type === "toolCall") {
items.push({ kind: "tool", text: `${block.name} ${argsSummary(block.arguments)}`.trim() });
}
}
}
} else if (entry.type === "custom_message" && entry.display) {
const text = contentToText(entry.content).trim();
if (text) items.push({ kind: "info", text });
} else if (entry.type === "compaction") {
items.push({ kind: "info", text: "— context compacted —" });
} else if (entry.type === "branch_summary") {
items.push({ kind: "info", text: "— branched —" });
}
}
return items;
}
class TranscriptViewer {
/** -1 = not yet laid out; opens scrolled to the bottom. */
scroll = -1;
cacheWidth = -1;
lines: string[] = [];
userOffsets: number[] = [];
lastViewHeight = 10;
lastMaxScroll = 0;
constructor(
public items: Item[],
public tui: any,
public theme: any,
public done: (v?: any) => void,
) {}
invalidate() {
this.cacheWidth = -1;
}
/** Wrap all items to the inner width; record the first line of each user message. */
layout(inner: number) {
if (this.cacheWidth === inner) return;
this.cacheWidth = inner;
this.lines = [];
this.userOffsets = [];
const theme = this.theme;
for (const item of this.items) {
if (this.lines.length > 0) this.lines.push("");
const text = sanitize(item.text);
switch (item.kind) {
case "user": {
this.userOffsets.push(this.lines.length);
const wrapped = wrapTextWithAnsi(text, Math.max(1, inner - 2));
wrapped.forEach((line: string, i: number) => {
this.lines.push(
theme.fg("accent", theme.bold(i === 0 ? " " : " ")) + theme.fg("userMessageText", theme.bold(line)),
);
});
break;
}
case "assistant":
for (const line of wrapTextWithAnsi(text, inner)) this.lines.push(line);
break;
case "tool":
this.lines.push(theme.fg("muted", truncateToWidth(`${text}`, inner, "…")));
break;
case "info":
this.lines.push(theme.fg("dim", theme.italic(truncateToWidth(text, inner, "…"))));
break;
}
}
}
render(width: number) {
const theme = this.theme;
const border = (s: string) => theme.fg("border", s);
if (width < 12) return [truncateToWidth(theme.fg("dim", "too narrow"), Math.max(1, width))];
const rows = this.tui?.terminal?.rows || process.stdout.rows || 24;
const viewHeight = Math.max(5, rows - 8);
// Layout: "│ " + content + " " + scrollbar-column
const inner = width - 4;
this.layout(inner);
const total = this.lines.length;
const maxScroll = Math.max(0, total - viewHeight);
if (this.scroll === -1) this.scroll = maxScroll;
this.scroll = Math.max(0, Math.min(this.scroll, maxScroll));
this.lastViewHeight = viewHeight;
this.lastMaxScroll = maxScroll;
// Scrollbar track: markers map the WHOLE transcript onto the track,
// thumb shows the current viewport (claude-cloak math).
const markerRows = new Set<number>();
for (const offset of this.userOffsets) {
const row = total <= viewHeight ? offset : Math.floor((offset * viewHeight) / total);
markerRows.add(Math.min(row, viewHeight - 1));
}
let thumbTop = -1;
let thumbLen = 0;
if (total > viewHeight) {
thumbLen = Math.min(viewHeight, Math.max(1, Math.floor((viewHeight * viewHeight) / total)));
thumbTop = maxScroll === 0 ? 0 : Math.floor((this.scroll * (viewHeight - thumbLen)) / maxScroll);
}
const out: string[] = [];
out.push(topBorder(width, theme.fg("toolTitle", theme.bold("transcript")), border));
for (let row = 0; row < viewHeight; row++) {
const line = this.lines[this.scroll + row] ?? "";
const pad = Math.max(0, inner - visibleWidth(line));
let track: string;
if (thumbTop !== -1 && row >= thumbTop && row < thumbTop + thumbLen) {
track = theme.fg("accent", "█");
} else if (markerRows.has(row)) {
track = theme.fg("warning", theme.bold("*"));
} else {
track = border("│");
}
out.push(border("│ ") + line + " ".repeat(pad) + " " + track);
}
const hints = theme.fg("dim", "n/N prompts · j/k · ctrl+d/u · g/G · q closes");
const pos = theme.fg("dim", `${Math.min(total, this.scroll + viewHeight)}/${total}`);
out.push(splitBorder(width, hints, pos, border, ["╰", "╯"]));
return out;
}
handleInput(data: string) {
const half = Math.max(1, Math.floor(this.lastViewHeight / 2));
if (data === "q" || matchesKey(data, Key.escape)) {
this.done();
return;
} else if (data === "j" || matchesKey(data, Key.down)) {
this.scroll += 1;
} else if (data === "k" || matchesKey(data, Key.up)) {
this.scroll -= 1;
} else if (matchesKey(data, Key.ctrl("d")) || matchesKey(data, Key.pageDown)) {
this.scroll += half;
} else if (matchesKey(data, Key.ctrl("u")) || matchesKey(data, Key.pageUp)) {
this.scroll -= half;
} else if (data === "g" || matchesKey(data, Key.home)) {
this.scroll = 0;
} else if (data === "G" || matchesKey(data, Key.end)) {
this.scroll = this.lastMaxScroll;
} else if (data === "n") {
const next = this.userOffsets.find((o) => o > this.scroll);
if (next !== undefined) this.scroll = next;
} else if (data === "N") {
const prev = [...this.userOffsets].reverse().find((o) => o < this.scroll);
if (prev !== undefined) this.scroll = prev;
} else {
return;
}
this.scroll = Math.max(0, Math.min(this.scroll, this.lastMaxScroll));
this.tui.requestRender();
}
}
export default function (pi: any) {
let open = false;
pi.registerShortcut("ctrl+q", {
description: "Open transcript viewer (scrollbar + user-message jumps)",
handler: async (ctx: any) => {
if (open) return;
open = true;
try {
await ctx.ui.custom(
(tui: any, theme: any, _keybindings: any, done: any) =>
new TranscriptViewer(buildItems(ctx), tui, theme, () => done(undefined)),
{
overlay: true,
overlayOptions: {
width: "85%",
minWidth: 50,
anchor: "center",
},
},
);
} finally {
open = false;
}
},
});
}

View File

@@ -1,461 +0,0 @@
/**
* 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, watch, 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,
add_to_config_reload_watch_list = 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
}
}
function syncTheme(ctx: any) {
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");
}
}
export default function (pi: ExtensionAPI) {
let currentCtx: any = null;
pi.on("session_start", async (_event, ctx) => {
currentCtx = ctx;
syncTheme(ctx);
});
// Watch theme-state file for dark/light toggle changes
const stateFile = join(homedir(), ".config", "theme-state");
try {
watch(stateFile, { persistent: false }, (_event) => {
// Debounce: wait a tick for the file write to complete
setTimeout(() => {
if (currentCtx) {
syncTheme(currentCtx);
}
}, 100);
});
} catch {
// File may not exist yet — non-fatal
}
}