/** * Theme Switcher Pi package * * Features: * - /theme - Show interactive theme selector * - /theme - Switch directly to a theme * - /theme-status - Show package/debug status * - Ctrl+Shift+T - Cycle through available themes * - switch_theme tool - Let the LLM change themes */ import * as fs from "node:fs"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; import type { ExtensionAPI, ExtensionContext, } from "@earendil-works/pi-coding-agent"; import { DynamicBorder } from "@earendil-works/pi-coding-agent"; import { Container, Key, type SelectItem, SelectList, Text, truncateToWidth, } from "@earendil-works/pi-tui"; import { Type } from "typebox"; interface PackageMetadata { name: string; version: string; packageRoot: string; sourcePath: string; } const sourcePath = fileURLToPath(import.meta.url); const packageRoot = path.resolve(path.dirname(sourcePath), ".."); let cachedPackageMetadata: PackageMetadata | null = null; function getPackageMetadata(): PackageMetadata { if (cachedPackageMetadata) { return cachedPackageMetadata; } let name = "pi-theme-switcher"; let version = "0.1.0"; try { const packageJsonPath = path.join(packageRoot, "package.json"); const packageJson = JSON.parse( fs.readFileSync(packageJsonPath, "utf8"), ) as { name?: string; version?: string; }; name = packageJson.name ?? name; version = packageJson.version ?? version; } catch { // Best-effort metadata only. } cachedPackageMetadata = { name, version, packageRoot, sourcePath, }; return cachedPackageMetadata; } export default function themeSwitcherExtension(pi: ExtensionAPI) { let currentThemeName: string | undefined; let themeNames: string[] = []; let swatchTimer: ReturnType | null = null; function refreshThemes(ctx: ExtensionContext) { themeNames = ctx.ui.getAllThemes().map((theme) => theme.name); } function updateStatus(ctx: ExtensionContext) { if (!currentThemeName) { ctx.ui.setStatus("theme-switcher", undefined); return; } ctx.ui.setStatus( "theme-switcher", ctx.ui.theme.fg("accent", `theme:${currentThemeName}`), ); } function sendVisibleMessage( content: string, details?: Record, ) { pi.sendMessage({ customType: "theme-switcher-status", content, details, display: true, }); } function showSwatch(ctx: ExtensionContext) { if (!ctx.hasUI) return; if (swatchTimer) { clearTimeout(swatchTimer); swatchTimer = null; } ctx.ui.setWidget( "theme-swatch", (_tui: unknown, theme) => ({ invalidate() {}, render(width: number): string[] { const block = "\u2588\u2588\u2588"; const swatch = theme.fg("success", block) + " " + theme.fg("accent", block) + " " + theme.fg("warning", block) + " " + theme.fg("dim", block) + " " + theme.fg("muted", block); const label = theme.fg("accent", " 🎨 ") + theme.fg( "muted", currentThemeName ?? ctx.ui.theme.name ?? "active theme", ) + " " + swatch; const border = theme.fg( "borderMuted", "─".repeat(Math.max(0, width)), ); return [border, truncateToWidth(` ${label}`, width), border]; }, }), { placement: "belowEditor" }, ); swatchTimer = setTimeout(() => { ctx.ui.setWidget("theme-swatch", undefined); swatchTimer = null; }, 3000); } function applyTheme(name: string, ctx: ExtensionContext): boolean { const result = ctx.ui.setTheme(name); if (!result.success) { ctx.ui.notify( `Failed to switch theme: ${result.error ?? "unknown error"}`, "error", ); return false; } currentThemeName = name; ctx.ui.notify(`🎨 Theme switched to "${name}"`, "info"); updateStatus(ctx); showSwatch(ctx); return true; } function isThemeSwitchingAvailable(ctx: ExtensionContext): boolean { refreshThemes(ctx); return themeNames.length > 0; } async function showThemeSelector(ctx: ExtensionContext): Promise { if (!isThemeSwitchingAvailable(ctx)) { ctx.ui.notify( "Theme switching is available only in the interactive TUI.", "warning", ); return; } if (!ctx.hasUI) { sendVisibleMessage( `Interactive theme selector requires UI. Available themes: ${themeNames.join(", ")}`, ); return; } const items: SelectItem[] = themeNames.map((name) => ({ value: name, label: name === currentThemeName ? `${name} (active)` : name, description: name === currentThemeName ? "Currently active" : undefined, })); const result = await ctx.ui.custom( (tui, theme, _kb, done) => { const container = new Container(); const title = new Text("", 0, 0); const footer = new Text("", 0, 0); const syncTheme = () => { title.setText(theme.fg("accent", theme.bold("Select Theme"))); footer.setText( theme.fg( "dim", "↑↓ navigate • enter select • esc cancel • type to filter", ), ); }; container.addChild(new DynamicBorder((str) => theme.fg("accent", str))); container.addChild(title); const selectList = new SelectList(items, Math.min(items.length, 10), { selectedPrefix: (text) => theme.fg("accent", text), selectedText: (text) => theme.fg("accent", text), description: (text) => theme.fg("muted", text), scrollInfo: (text) => theme.fg("dim", text), noMatch: (text) => theme.fg("warning", text), }); selectList.onSelect = (item) => done(item.value); selectList.onCancel = () => done(null); container.addChild(selectList); container.addChild(footer); container.addChild(new DynamicBorder((str) => theme.fg("accent", str))); syncTheme(); return { render(width: number) { return container.render(width); }, invalidate() { container.invalidate(); syncTheme(); }, handleInput(data: string) { selectList.handleInput(data); tui.requestRender(); }, }; }, ); if (result) { applyTheme(result, ctx); } } async function cycleTheme(ctx: ExtensionContext): Promise { if (!isThemeSwitchingAvailable(ctx)) { ctx.ui.notify( "Theme switching is available only in the interactive TUI.", "warning", ); return; } const currentIndex = currentThemeName ? themeNames.indexOf(currentThemeName) : -1; const nextIndex = currentIndex === -1 ? 0 : (currentIndex + 1) % themeNames.length; const nextName = themeNames.at(nextIndex); if (!nextName) { ctx.ui.notify("No next theme found.", "warning"); return; } applyTheme(nextName, ctx); } pi.registerCommand("theme", { description: "Switch TUI theme on the fly", getArgumentCompletions: (prefix: string) => { const lowered = prefix.toLowerCase(); const matches = themeNames .filter((name) => name.toLowerCase().startsWith(lowered)) .map((name) => ({ value: name, label: name })); return matches.length > 0 ? matches : null; }, handler: async (args, ctx) => { if (!isThemeSwitchingAvailable(ctx)) { ctx.ui.notify( "Theme switching is available only in the interactive TUI.", "warning", ); return; } if (args?.trim()) { const name = args.trim(); if (!themeNames.includes(name)) { ctx.ui.notify( `Unknown theme "${name}". Available: ${themeNames.join(", ")}`, "error", ); return; } applyTheme(name, ctx); return; } await showThemeSelector(ctx); }, }); pi.registerCommand("theme-status", { description: "Show theme switcher package status", handler: async (_args, ctx) => { refreshThemes(ctx); const metadata = getPackageMetadata(); sendVisibleMessage( [ `${metadata.name} v${metadata.version}`, `source: ${metadata.sourcePath}`, `active: ${currentThemeName ?? ctx.ui.theme.name ?? "unknown"}`, `themes: ${themeNames.length > 0 ? themeNames.join(", ") : "none"}`, ].join("\n"), { packageName: metadata.name, version: metadata.version, sourcePath: metadata.sourcePath, packageRoot: metadata.packageRoot, activeTheme: currentThemeName ?? ctx.ui.theme.name ?? null, themes: themeNames, }, ); }, }); pi.registerShortcut(Key.ctrlShift("t"), { description: "Cycle TUI themes", handler: async (ctx) => { await cycleTheme(ctx); }, }); pi.registerTool({ name: "switch_theme", label: "Switch Theme", description: "Switch the TUI theme on the fly without reloading.", promptSnippet: "Switch the visual theme of the TUI interface.", promptGuidelines: [ "Use switch_theme only when the user explicitly requests a theme change or mentions readability, eye strain, or preference for a different color scheme.", "Do not switch themes spontaneously. Provide the available theme names if the user asks what themes are installed.", ], parameters: Type.Object({ name: Type.String({ description: "Name of the theme to activate (e.g. dark, light, tokyo-night)", }), }), async execute( _toolCallId: string, params: { name: string }, _signal: AbortSignal, _onUpdate: ((update: unknown) => void) | undefined, ctx: ExtensionContext, ) { if (!isThemeSwitchingAvailable(ctx)) { throw new Error( "switch_theme is available only in the interactive TUI.", ); } if (!ctx.hasUI) { throw new Error("switch_theme requires an interactive TUI session."); } if (!themeNames.includes(params.name)) { throw new Error( `Unknown theme "${params.name}". Available themes: ${themeNames.join(", ")}`, ); } const result = ctx.ui.setTheme(params.name); if (!result.success) { throw new Error( `Failed to switch theme: ${result.error ?? "unknown error"}`, ); } currentThemeName = params.name; updateStatus(ctx); showSwatch(ctx); return { content: [ { type: "text", text: `Theme switched to "${params.name}".`, }, ], details: { theme: params.name }, terminate: true, }; }, }); pi.on("session_shutdown", async (_event, ctx) => { if (swatchTimer) { clearTimeout(swatchTimer); swatchTimer = null; } ctx.ui.setWidget("theme-swatch", undefined); }); pi.on("session_start", async (event, ctx) => { refreshThemes(ctx); currentThemeName = ctx.ui.theme.name; if (!currentThemeName && themeNames.length > 0) { currentThemeName = themeNames[0]; } updateStatus(ctx); if ("reason" in event && event.reason === "reload") { showSwatch(ctx); } }); }