/** * pi-themes extension * * Bundles 10 popular color themes and provides a /theme command and * Ctrl+Shift+T shortcut to switch themes interactively. * * Themes included: * catppuccin-mocha, catppuccin-latte, tokyo-night, * gruvbox-dark, gruvbox-light, nord, dracula, * solarized-dark, solarized-light, one-dark * * Usage: * /theme → open selector * /theme tokyo-night → switch directly by name * Ctrl+Shift+T → cycle through all themes */ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import { DynamicBorder } from "@earendil-works/pi-coding-agent"; import { Container, Key, SelectList, Text, type AutocompleteItem, type SelectItem } from "@earendil-works/pi-tui"; export default function piThemesExtension(pi: ExtensionAPI) { // The name of the last theme activated through this extension. // We can't read the "current" theme back from the API, so we only track // changes that go through our own commands/shortcuts. let activeThemeName: string | undefined; // Cached list populated once on session_start for autocomplete. let cachedThemeNames: string[] = []; // ────────────────────────────────────────────────────────── // Helpers // ────────────────────────────────────────────────────────── function updateStatus(ctx: ExtensionContext) { if (activeThemeName) { ctx.ui.setStatus("pi-themes", ctx.ui.theme.fg("accent", `theme:${activeThemeName}`)); } else { ctx.ui.setStatus("pi-themes", undefined); } } function applyTheme(name: string, ctx: ExtensionContext): boolean { const result = ctx.ui.setTheme(name); if (result.success) { activeThemeName = name; updateStatus(ctx); return true; } ctx.ui.notify(`Failed to apply theme "${name}": ${result.error}`, "error"); return false; } // ────────────────────────────────────────────────────────── // Theme selector (custom SelectList UI) // ────────────────────────────────────────────────────────── async function showThemeSelector(ctx: ExtensionContext): Promise { const themes = ctx.ui.getAllThemes(); if (themes.length === 0) { ctx.ui.notify("No themes available.", "warning"); return; } const items: SelectItem[] = themes.map((t) => ({ value: t.name, label: t.name === activeThemeName ? `${t.name} (active)` : t.name, description: t.path ? shortenPath(t.path) : "built-in", })); const selected = await ctx.ui.custom((tui, theme, _kb, done) => { const container = new Container(); container.addChild(new DynamicBorder((str) => theme.fg("accent", str))); container.addChild(new Text(theme.fg("accent", theme.bold(" Select Theme")))); const selectList = new SelectList(items, Math.min(items.length, 14), { selectedPrefix: (text) => theme.fg("accent", text), selectedText: (text) => theme.fg("accent", text), description: (text) => theme.fg("dim", 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( new Text(theme.fg("dim", " ↑↓ navigate • / filter • enter select • esc cancel")) ); container.addChild(new DynamicBorder((str) => theme.fg("accent", str))); return { render(width: number) { return container.render(width); }, invalidate() { container.invalidate(); }, handleInput(data: string) { selectList.handleInput(data); tui.requestRender(); }, }; }); if (!selected) return; if (applyTheme(selected, ctx)) { ctx.ui.notify(`Theme "${selected}" activated`, "info"); } } // ────────────────────────────────────────────────────────── // Cycle through all available themes // ────────────────────────────────────────────────────────── function cycleTheme(ctx: ExtensionContext): void { const themes = ctx.ui.getAllThemes(); if (themes.length === 0) return; const names = themes.map((t) => t.name); const currentIdx = activeThemeName ? names.indexOf(activeThemeName) : -1; const nextName = names[(currentIdx + 1) % names.length]; if (applyTheme(nextName, ctx)) { ctx.ui.notify(`Theme: ${nextName}`, "info"); } } // ────────────────────────────────────────────────────────── // Registration // ────────────────────────────────────────────────────────── pi.registerShortcut(Key.ctrlShift("t"), { description: "Cycle themes (pi-themes)", handler: async (ctx) => { cycleTheme(ctx); }, }); pi.registerCommand("theme", { description: "Select or switch pi theme • /theme or Ctrl+Shift+T to cycle", getArgumentCompletions(prefix: string): AutocompleteItem[] | null { const filtered = cachedThemeNames.filter((n) => n.startsWith(prefix)); if (filtered.length === 0) return null; return filtered.map((n) => ({ value: n, label: n })); }, handler: async (args, ctx) => { const name = args?.trim(); if (name) { // Direct switch by name const allNames = ctx.ui.getAllThemes().map((t) => t.name); if (!allNames.includes(name)) { ctx.ui.notify( `Unknown theme "${name}". Available: ${allNames.join(", ")}`, "error" ); return; } if (applyTheme(name, ctx)) { ctx.ui.notify(`Theme "${name}" activated`, "info"); } return; } // No arg → interactive selector await showThemeSelector(ctx); }, }); // ────────────────────────────────────────────────────────── // Session lifecycle // ────────────────────────────────────────────────────────── pi.on("session_start", async (_event, ctx) => { cachedThemeNames = ctx.ui.getAllThemes().map((t) => t.name); updateStatus(ctx); }); } // ────────────────────────────────────────────────────────── // Utilities // ────────────────────────────────────────────────────────── /** Abbreviate long absolute paths for the selector description column. */ function shortenPath(p: string): string { const home = process.env.HOME ?? ""; if (home && p.startsWith(home)) { return "~" + p.slice(home.length); } return p; }