diff --git a/pi/.pi/agent/extensions/git-checkout-guard.ts b/pi/.pi/agent/extensions/git-checkout-guard.ts deleted file mode 100644 index 56f1fad..0000000 --- a/pi/.pi/agent/extensions/git-checkout-guard.ts +++ /dev/null @@ -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 -- - // Match: git checkout -- - const checkoutDashDash = cmd.match(/\bgit\s+checkout\b.*?\s--\s+(.+)/); - if (checkoutDashDash) { - return checkoutDashDash[1].trim().split(/\s+/); - } - - // Match: git restore [--staged] [--source=] - // (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; - }); -} diff --git a/pi/.pi/agent/extensions/subagent-pane-reminder.ts b/pi/.pi/agent/extensions/subagent-pane-reminder.ts deleted file mode 100644 index 770088e..0000000 --- a/pi/.pi/agent/extensions/subagent-pane-reminder.ts +++ /dev/null @@ -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(); - 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(); - - 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 }; - }); -} diff --git a/pi/.pi/agent/extensions/tool-blocks.ts b/pi/.pi/agent/extensions/tool-blocks.ts.disabled similarity index 87% rename from pi/.pi/agent/extensions/tool-blocks.ts rename to pi/.pi/agent/extensions/tool-blocks.ts.disabled index 18e20bd..bd2feb0 100644 --- a/pi/.pi/agent/extensions/tool-blocks.ts +++ b/pi/.pi/agent/extensions/tool-blocks.ts.disabled @@ -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 * 26k–64k 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); } diff --git a/pi/.pi/agent/extensions/transcript-viewer.ts b/pi/.pi/agent/extensions/transcript-viewer.ts deleted file mode 100644 index d56d1ec..0000000 --- a/pi/.pi/agent/extensions/transcript-viewer.ts +++ /dev/null @@ -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(); - 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; - } - }, - }); -} diff --git a/pi/.pi/agent/extensions/wezterm-theme-sync/index.ts b/pi/.pi/agent/extensions/wezterm-theme-sync/index.ts deleted file mode 100644 index 4a07eb3..0000000 --- a/pi/.pi/agent/extensions/wezterm-theme-sync/index.ts +++ /dev/null @@ -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; -} - -/** - * 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 - } -}