import { watch, type FSWatcher } from "node:fs"; import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; import { basename, dirname, join } from "node:path"; import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; const LIGHT_THEME = "light"; const DARK_THEME = "dark"; const STATE_FILE = join(homedir(), ".pi", "agent", "theme-flip"); const STATE_DIR = dirname(STATE_FILE); const STATE_NAME = basename(STATE_FILE); type ThemeChoice = typeof LIGHT_THEME | typeof DARK_THEME; type CommandChoice = ThemeChoice | "toggle" | "status"; function normalizeChoice(value: string): CommandChoice | undefined { const normalized = value.trim().toLowerCase(); if (!normalized || normalized === "toggle") return "toggle"; if (normalized === "status") return "status"; if (normalized === LIGHT_THEME) return LIGHT_THEME; if (normalized === DARK_THEME) return DARK_THEME; return undefined; } function isThemeChoice(value: string): value is ThemeChoice { return value === LIGHT_THEME || value === DARK_THEME; } function currentThemeName(ctx: ExtensionContext): string | undefined { return ctx.ui.theme.name; } async function readSharedTheme(): Promise { try { const value = (await readFile(STATE_FILE, "utf8")).trim().toLowerCase(); return isThemeChoice(value) ? value : undefined; } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; throw error; } } async function nextTheme(ctx: ExtensionContext): Promise { const current = (await readSharedTheme()) ?? currentThemeName(ctx); return current === DARK_THEME ? LIGHT_THEME : DARK_THEME; } function setPiTheme(ctx: ExtensionContext, themeName: ThemeChoice, announce = false): void { if (!ctx.hasUI) return; if (currentThemeName(ctx) !== themeName) { const result = ctx.ui.setTheme(themeName); if (!result.success) { ctx.ui.notify(`theme flip failed: ${result.error ?? "unknown error"}`, "error"); return; } } if (announce) ctx.ui.notify(`theme: ${themeName}`, "info"); } async function writeSharedTheme(themeName: ThemeChoice): Promise { await mkdir(STATE_DIR, { recursive: true }); const temporary = join(STATE_DIR, `.${STATE_NAME}.${process.pid}-${Date.now()}`); try { await writeFile(temporary, `${themeName}\n`); await rename(temporary, STATE_FILE); } finally { await rm(temporary, { force: true }).catch(() => undefined); } } function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } export default function (pi: ExtensionAPI) { let watcher: FSWatcher | undefined; async function syncTheme(ctx: ExtensionContext): Promise { try { const sharedTheme = await readSharedTheme(); if (sharedTheme) setPiTheme(ctx, sharedTheme); } catch (error) { if (ctx.hasUI) ctx.ui.notify(`theme sync failed: ${errorMessage(error)}`, "warning"); } } async function start(ctx: ExtensionContext): Promise { if (!ctx.hasUI) return; try { await mkdir(STATE_DIR, { recursive: true }); if (!watcher) { watcher = watch(STATE_DIR, (_eventType, filename) => { if (!filename || filename.toString() === STATE_NAME) void syncTheme(ctx); }); watcher.on("error", (error) => { watcher = undefined; if (ctx.hasUI) ctx.ui.notify(`theme watcher failed: ${error.message}`, "warning"); }); } await syncTheme(ctx); } catch (error) { if (ctx.hasUI) ctx.ui.notify(`theme watcher failed: ${errorMessage(error)}`, "warning"); } } pi.on("session_start", async (_event, ctx) => { await start(ctx); }); pi.on("input", async (_event, ctx) => { await start(ctx); }); pi.on("session_shutdown", async () => { watcher?.close(); watcher = undefined; }); pi.registerCommand("theme-flip", { description: "Toggle all pi sessions between dark and light. Use /theme-flip dark|light|status.", handler: async (args, ctx) => { const requested = normalizeChoice(args); if (!requested) { ctx.ui.notify("usage: /theme-flip [toggle|dark|light|status]", "warning"); return; } if (requested === "status") { await syncTheme(ctx); ctx.ui.notify(`theme: ${currentThemeName(ctx) ?? "unknown"}`, "info"); return; } if (!ctx.hasUI) return; try { const themeName = requested === "toggle" ? await nextTheme(ctx) : requested; await writeSharedTheme(themeName); setPiTheme(ctx, themeName, true); } catch (error) { ctx.ui.notify(`theme flip failed: ${errorMessage(error)}`, "error"); } }, }); }