import type { AutocompleteItem } from "@earendil-works/pi-tui"; import { SelectList, truncateToWidth } from "@earendil-works/pi-tui"; import type { Theme } from "@earendil-works/pi-coding-agent"; import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; declare const process: any; type SelectItem = { value: string; label: string; description?: string }; const DEFAULT_NEXT_SHORTCUT = "alt+]"; const DEFAULT_PREVIOUS_SHORTCUT = "alt+["; export default function (pi: ExtensionAPI) { let swatchTimer: ReturnType | undefined; let cachedCompletions: AutocompleteItem[] = []; function updateStatus(ctx: ExtensionContext) { if (!ctx.hasUI) return; ctx.ui.setStatus("theme", `🎨 ${ctx.ui.theme.name ?? "current"}`); } function safeFg(theme: any, color: string, text: string) { try { return theme.fg(color, text); } catch { return text; } } function showSwatch(ctx: ExtensionContext) { if (!ctx.hasUI) return; if (swatchTimer) clearTimeout(swatchTimer); ctx.ui.setWidget( "theme-swatch", (_tui: any, theme: any) => ({ invalidate() {}, render(width: number): string[] { if (width < 10) { return [ truncateToWidth( ` 🎨 ${safeFg(theme, "accent", ctx.ui.theme.name ?? "current")}`, width, ), ]; } const swatchColors = [ { key: "success", label: "s" }, { key: "accent", label: "a" }, { key: "warning", label: "w" }, { key: "thinkingHigh", label: "H" }, { key: "thinkingXhigh", label: "X" }, { key: "thinkingMax", label: "M" }, { key: "thinkingMedium", label: "m" }, { key: "muted", label: "u" }, ] as const; const compact = width < 48; const swatch = swatchColors .map(({ key, label }) => compact ? `${label}${safeFg(theme, key, "●")}` : `${label}:${safeFg(theme, key, "██")}`, ) .join(compact ? " " : " "); const name = safeFg(theme, "accent", ctx.ui.theme.name ?? "current"); const header = compact ? `🎨 ${name} ${swatch}` : `🎨 ${name} ${swatch}`; const border = safeFg( theme, "borderMuted", "─".repeat(Math.max(0, width)), ); return [border, truncateToWidth(` ${header}`, width), border]; }, }), { placement: "belowEditor" }, ); swatchTimer = setTimeout(() => { ctx.ui.setWidget("theme-swatch", undefined); swatchTimer = undefined; }, 3000); } function getThemes(ctx: ExtensionContext) { return ctx.ui.getAllThemes() as Array<{ name: string; path?: string }>; } function refreshCompletions(ctx: ExtensionContext) { cachedCompletions = getThemes(ctx).map((theme) => ({ value: theme.name, label: theme.name, description: theme.path ? theme.path : "built-in", })); } function findCurrentIndex(ctx: ExtensionContext) { return getThemes(ctx).findIndex( (theme) => theme.name === ctx.ui.theme.name, ); } // setTheme: when a Theme instance is passed, it does NOT persist to settings // (live preview). When a string name is passed, it DOES persist. function setTheme(ctx: ExtensionContext, nameOrTheme: string | Theme, notify = true, show = true) { const result = ctx.ui.setTheme(nameOrTheme); if (!result.success) { if (notify) ctx.ui.notify(`Failed to set theme: ${result.error}`, "error"); return false; } updateStatus(ctx); if (show) showSwatch(ctx); if (notify) { const name = typeof nameOrTheme === "string" ? nameOrTheme : nameOrTheme.name ?? "theme"; ctx.ui.notify(`Theme: ${name}`, "info"); } return true; } function cycleTheme(ctx: ExtensionContext, direction: 1 | -1) { if (!ctx.hasUI) return; const themes = getThemes(ctx); if (themes.length === 0) { ctx.ui.notify("No themes available", "warning"); return; } let index = findCurrentIndex(ctx); if (index === -1) index = 0; index = (index + direction + themes.length) % themes.length; const theme = themes[index]!; // Pass string name for persistence on keyboard cycle. // Suppress setTheme's own notification — we emit a single position-aware one below. if (setTheme(ctx, theme.name, false, true)) { ctx.ui.notify(`${theme.name} (${index + 1}/${themes.length})`, "info"); } } async function selectThemeWithPreview(ctx: ExtensionContext) { const themes = getThemes(ctx); if (themes.length === 0) { ctx.ui.notify("No themes available", "warning"); return; } const originalTheme = ctx.ui.theme; const original = originalTheme.name ?? ""; const items: SelectItem[] = themes.map((theme) => ({ value: theme.name, label: theme.name, description: theme.path ? theme.path : "built-in", })); const currentIndex = Math.max( 0, themes.findIndex((theme) => theme.name === original), ); let chosen: string | null = null; try { await ctx.ui.custom( (tui: any, theme: any, _kb: any, done: (value: void) => void) => { const list = new SelectList( items, Math.min(items.length, 12), { selectedPrefix: (text: string) => ctx.ui.theme.fg("accent", text), selectedText: (text: string) => ctx.ui.theme.fg("accent", text), description: (text: string) => ctx.ui.theme.fg("muted", text), scrollInfo: (text: string) => ctx.ui.theme.fg("dim", text), noMatch: (text: string) => ctx.ui.theme.fg("warning", text), }, { maxPrimaryColumnWidth: 28 }, ); list.setSelectedIndex(currentIndex); list.onSelectionChange = (item: SelectItem) => { // Theme objects preview without persisting settings. getTheme() // resolves vars and supports both built-in and custom themes. const preview = ctx.ui.getTheme(item.value); if (preview) setTheme(ctx, preview, false, false); tui.requestRender(); }; list.onSelect = (item: SelectItem) => { chosen = item.value; // Persist with string name on final selection setTheme(ctx, item.value, false, false); done(undefined); }; list.onCancel = () => { chosen = null; done(undefined); }; return { render(width: number) { return [ safeFg(ctx.ui.theme, "accent", "Select Theme"), safeFg( ctx.ui.theme, "dim", "Move selection to preview below. Enter keeps, Esc restores previous theme.", ), "", ...list.render(width), ].map((line) => truncateToWidth(line, width, "…")); }, invalidate() { list.invalidate(); }, handleInput(data: string) { list.handleInput(data); tui.requestRender(); }, }; }, ); } finally { if (!chosen) setTheme(ctx, originalTheme, false, false); } if (chosen) { updateStatus(ctx); showSwatch(ctx); ctx.ui.notify(`Theme: ${chosen}`, "info"); } else { ctx.ui.notify(`Theme unchanged: ${original || "current theme"}`, "info"); } } pi.registerShortcut( process.env.PI_THEME_NEXT_SHORTCUT ?? DEFAULT_NEXT_SHORTCUT, { description: "Cycle theme forward", handler: async (ctx) => cycleTheme(ctx, 1), }, ); pi.registerShortcut( process.env.PI_THEME_PREVIOUS_SHORTCUT ?? DEFAULT_PREVIOUS_SHORTCUT, { description: "Cycle theme backward", handler: async (ctx) => cycleTheme(ctx, -1), }, ); pi.registerCommand("theme", { description: "Select a theme: /theme or /theme ", getArgumentCompletions: (prefix: string) => { const query = prefix.trim().toLowerCase(); const items = cachedCompletions.filter( (item) => !query || item.value.toLowerCase().includes(query), ); return items.length ? items : null; }, handler: async (args: string, ctx: ExtensionContext) => { if (!ctx.hasUI) return; const arg = args.trim(); const themes = getThemes(ctx); if (arg) { const exact = themes.find( (theme) => theme.name.toLowerCase() === arg.toLowerCase(), ); setTheme(ctx, exact?.name ?? arg); return; } await selectThemeWithPreview(ctx); }, }); pi.on("session_start", async (_event: unknown, ctx: ExtensionContext) => { if (ctx.hasUI) refreshCompletions(ctx); updateStatus(ctx); }); pi.on("session_shutdown", async (_event: unknown, ctx: ExtensionContext) => { if (swatchTimer) clearTimeout(swatchTimer); swatchTimer = undefined; if (ctx.hasUI) ctx.ui.setWidget("theme-swatch", undefined); }); }