/** * pi-shimmer-quotes-spinner — Main extension entry point * * Merges three concepts: * 🌀 Claude-style shimmer spinner (✢ ✳ ✶ ✻ ✽) * 💬 Rotating quotes on the working message * ⚙ Full customisation options * * Commands: * /shimmer — show current status * /shimmer style — switch spinner style * /shimmer speed — animation speed (0.25 – 4.0) * /shimmer shimmer on|off — toggle colour-sweep shimmer * /shimmer mode inline|multiline — quote layout * /shimmer interval — quote rotation interval (1000–60000) * /shimmer intensity dim|normal|bright — verb brightness * /shimmer quotes edit — open user quotes file in editor * /shimmer quotes reload — reload quotes from disk * /shimmer quotes path — show quotes file path * /shimmer on|off — master toggle * /shimmer reset — restore all defaults * * Events: * session_start – initialise state * agent_start – start animation when Pi begins working * agent_end – stop animation, show completion summary * turn_start – pick a new verb, reset counters * turn_end – stop quote rotation * message_update – track token count, thinking state, elapsed time */ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import type { SQConfig } from "./config"; import { DEFAULTS, loadConfig, saveConfig } from "./config"; import { buildIndicatorOptions } from "./spinner"; import { loadQuotes, pickQuote, describeQuotes, ensureUserQuotesFile, getUserQuotesFile } from "./quotes"; import type { LoadedQuotes } from "./quotes"; import { buildWorkingMessage } from "./renderer"; import { readFileSync, writeFileSync } from "node:fs"; // ─── Verbs ───────────────────────────────────────────────────────────── const VERBS = [ "Analyzing", "Architecting", "Baking", "Brewing", "Building", "Calculating", "Composing", "Computing", "Concocting", "Cooking", "Crafting", "Creating", "Crunching", "Debugging", "Deciphering", "Designing", "Developing", "Engineering", "Evaluating", "Exploring", "Forging", "Generating", "Hatching", "Ideating", "Implementing", "Inspecting", "Investigating", "Optimizing", "Planning", "Pondering", "Processing", "Refactoring", "Researching", "Reviewing", "Ruminating", "Searching", "Solving", "Synthesizing", "Testing", "Thinking", "Tinkering", "Working", "Wrangling", ]; const COMPLETION_VERBS = [ "Baked", "Brewed", "Churned", "Cogitated", "Cooked", "Crunched", "Sautéed", "Worked", ]; // ─── State ───────────────────────────────────────────────────────────── interface State { config: SQConfig; loaded: LoadedQuotes; enabled: boolean; // Current turn verb: string; agentStart: number; turnStart: number; thinkingStart: number; thinkingDuration: number | null; thoughtSetAt: number; turnActive: boolean; // Token tracking responseLen: number; lastTokenTime: number; activeToolCount: number; // Shimmer shimmerFrame: number; shimmerTimer: ReturnType | null; // Quote rotation currentQuote: string | null; quoteTimer: ReturnType | null; // Thought clear timeout thoughtTimer: ReturnType | null; // Context reference for async updates ctx: ExtensionContext | null; } function createState(): State { const config = loadConfig(); return { config, loaded: loadQuotes(config.enableFallbackQuotes), enabled: config.enabled, verb: "Working", agentStart: 0, turnStart: 0, thinkingStart: 0, thinkingDuration: null, thoughtSetAt: 0, turnActive: false, responseLen: 0, lastTokenTime: 0, activeToolCount: 0, shimmerFrame: 0, shimmerTimer: null, currentQuote: null, quoteTimer: null, thoughtTimer: null, ctx: null, }; } // ─── Extension ───────────────────────────────────────────────────────── export default function (pi: ExtensionAPI) { const s = createState(); // ── Helpers ────────────────────────────────────────────────── function pickVerb(): string { return VERBS[Math.floor(Math.random() * VERBS.length)]!; } function formatDuration(ms: number): string { const totalS = Math.floor(ms / 1000); const m = Math.floor(totalS / 60); const sec = totalS % 60; return m > 0 ? `${m}m ${sec}s` : `${sec}s`; } function formatCount(n: number): string { return new Intl.NumberFormat("en-US").format(n); } function buildStatusParts(): string[] { const elapsed = Date.now() - (s.agentStart || s.turnStart || Date.now()); const tokens = Math.round(Math.max(0, s.responseLen) / 4); // chars → approx tokens const parts: string[] = []; // Thinking state if (s.thinkingDuration === null && s.thinkingStart > 0) { parts.push("thinking"); } else if (s.thinkingDuration !== null) { parts.push(`thought ${Math.max(1, Math.round(s.thinkingDuration / 1000))}s`); } // Token count if (tokens > 0) { parts.push(`↓ ${formatCount(tokens)} tok`); } // Elapsed time (after 15s) if (elapsed > 15_000) { parts.push(formatDuration(elapsed)); } return parts.reverse(); } function getCurrentQuote(): string | null { if (!s.enabled || !s.currentQuote) return null; return s.currentQuote; } function updateDisplay(): void { if (!s.ctx?.ui) return; s.ctx.ui.setWorkingMessage( buildWorkingMessage({ verb: s.verb, shimmerFrame: s.shimmerFrame, config: s.config, statusParts: buildStatusParts(), quote: getCurrentQuote(), }), ); } function applyIndicator(): void { if (!s.ctx?.ui) return; if (!s.enabled || !s.config.enableCustomSpinner) { s.ctx.ui.setWorkingIndicator(); return; } const color = s.config.textIntensity === "dim" ? "#B8865C" : s.config.textIntensity === "bright" ? "#FF9A6C" : "#D77757"; const colorFn = (t: string) => `\x1b[38;2;${hexVals(color)}m${t}\x1b[0m`; s.ctx.ui.setWorkingIndicator( buildIndicatorOptions(s.config.spinnerStyle, s.config.animationSpeed, colorFn), ); } // ── Timer Management ───────────────────────────────────────── function startAnimation(): void { stopAnimation(); s.shimmerFrame = 0; s.currentQuote = s.loaded.quotes.length > 0 ? pickQuote(s.loaded) : null; applyIndicator(); updateDisplay(); // Shimmer tick const shimmerInterval = Math.max( 40, Math.min(300, 120 / Math.max(0.25, s.config.animationSpeed)), ); s.shimmerTimer = setInterval(() => { s.shimmerFrame++; updateDisplay(); }, shimmerInterval); // Quote rotation if (s.loaded.quotes.length > 1) { s.quoteTimer = setInterval(() => { s.currentQuote = pickQuote(s.loaded); updateDisplay(); }, s.config.quoteInterval); } } function stopAnimation(): void { if (s.shimmerTimer) { clearInterval(s.shimmerTimer); s.shimmerTimer = null; } if (s.quoteTimer) { clearInterval(s.quoteTimer); s.quoteTimer = null; } if (s.thoughtTimer) { clearTimeout(s.thoughtTimer); s.thoughtTimer = null; } } // ── Turn Init ──────────────────────────────────────────────── function initTurn(): void { s.turnActive = true; s.turnStart = Date.now(); if (!s.agentStart) s.agentStart = s.turnStart; s.verb = pickVerb(); s.responseLen = 0; s.lastTokenTime = 0; s.activeToolCount = 0; s.thinkingDuration = null; s.thinkingStart = 0; s.thoughtSetAt = 0; startAnimation(); } function resetTurn(): void { s.turnActive = false; stopAnimation(); if (s.ctx?.ui) s.ctx.ui.setWorkingMessage(); s.responseLen = 0; } // ── Event Handlers ─────────────────────────────────────────── // session_start — load config, prepare state pi.on("session_start", (_event, ctx) => { s.ctx = ctx; s.config = loadConfig(); s.enabled = s.config.enabled; s.loaded = loadQuotes(s.config.enableFallbackQuotes); if (s.enabled && s.ctx?.ui) { applyIndicator(); updateDisplay(); } }); // agent_start — Pi begins working. Start our animation. pi.on("agent_start", (_event, ctx) => { s.ctx = ctx; if (!s.enabled) return; if (!s.agentStart) s.agentStart = Date.now(); if (!s.turnActive) initTurn(); }); // turn_start — a new LLM turn begins. Pick a fresh verb. pi.on("turn_start", (_event, ctx) => { s.ctx = ctx; if (!s.enabled) return; if (s.turnActive) return; // already initialised by agent_start initTurn(); }); // turn_end — clean up per-turn state. pi.on("turn_end", () => { if (!s.enabled) return; s.turnActive = false; stopAnimation(); s.activeToolCount = 0; }); // message_update — track tokens & thinking pi.on("message_update", (event, ctx) => { s.ctx = ctx; if (!s.enabled || !s.turnActive) return; const evt = event.assistantMessageEvent; switch (evt.type) { case "thinking_start": s.thinkingStart = Date.now(); s.thinkingDuration = null; if (s.thoughtTimer) { clearTimeout(s.thoughtTimer); s.thoughtTimer = null; } break; case "thinking_end": if (s.thinkingDuration === null) { s.thinkingDuration = Date.now() - s.thinkingStart; s.thoughtSetAt = Date.now(); scheduleThoughtClear(); } break; case "text_delta": s.lastTokenTime = Date.now(); if (typeof evt.delta === "string") { s.responseLen += evt.delta.length; } break; case "text_start": s.lastTokenTime = Date.now(); break; case "done": s.lastTokenTime = Date.now(); break; } }); // tool tracking pi.on("tool_execution_start", () => { if (s.enabled) s.activeToolCount++; }); pi.on("tool_execution_end", () => { if (s.enabled) s.activeToolCount = Math.max(0, s.activeToolCount - 1); }); // agent_end — Pi stops working. Show completion notification. pi.on("agent_end", () => { if (!s.enabled) return; s.turnActive = false; stopAnimation(); const elapsed = Date.now() - (s.turnStart || s.agentStart || Date.now()); s.agentStart = 0; if (s.ctx?.ui) { const v = COMPLETION_VERBS[Math.floor(Math.random() * COMPLETION_VERBS.length)]!; s.ctx.ui.notify(`\x1b[38;2;215;119;87m✻ ${v} for ${formatDuration(elapsed)}\x1b[0m`, "success"); } }); // agent_settled — ensure animation stops after all queued work pi.on("agent_settled", () => { if (!s.enabled) return; s.turnActive = false; stopAnimation(); }); // session_shutdown — clean up everything pi.on("session_shutdown", () => { stopAnimation(); s.ctx = null; s.agentStart = 0; s.turnActive = false; }); // ── Thought Clear Schedule ─────────────────────────────────── const THOUGHT_DISPLAY_MS = 4_000; function scheduleThoughtClear(): void { if (s.thoughtTimer) clearTimeout(s.thoughtTimer); if (s.thinkingDuration === null) return; const remaining = THOUGHT_DISPLAY_MS - (Date.now() - s.thoughtSetAt); if (remaining <= 0) { s.thinkingDuration = null; updateDisplay(); return; } s.thoughtTimer = setTimeout(() => { s.thoughtTimer = null; if (Date.now() - s.thoughtSetAt < THOUGHT_DISPLAY_MS) { scheduleThoughtClear(); return; } s.thinkingDuration = null; if (s.turnActive) updateDisplay(); }, remaining); } // ── Re-apply helper ────────────────────────────────────────── function reapply(ctx: ExtensionContext): void { s.ctx = ctx; applyIndicator(); if (s.turnActive) { updateDisplay(); } else { // Pre-set so the message is ready when the loader appears s.verb = pickVerb(); s.currentQuote = s.loaded.quotes.length > 0 ? pickQuote(s.loaded) : null; updateDisplay(); } } // ── Commands ───────────────────────────────────────────────── pi.registerCommand("shimmer", { description: "Configure pi-shimmer-quotes-spinner (style, speed, quotes, mode)", handler: async (args, ctx) => { s.ctx = ctx; const parts = args.trim().split(/\s+/); const sub = parts[0]?.toLowerCase(); const val = parts.slice(1).join(" "); // ── No args: show status ────────────────────────────── if (!sub) { const p = s.config; ctx.ui.notify( [ `✨ pi-shimmer-quotes-spinner ${s.enabled ? "● ON" : "○ OFF"}`, ` Style: ${p.spinnerStyle} | Speed: ${p.animationSpeed}x | Shimmer: ${p.enableShimmer ? "ON" : "OFF"}`, ` Quotes: ${describeQuotes(s.loaded)} | Mode: ${p.quoteMode} | Interval: ${p.quoteInterval}ms`, ` Intensity: ${p.textIntensity}`, ].join("\n"), "info", ); return; } // ── style ───────────────────────────────────── if (sub === "style") { if (!val) { ctx.ui.notify( `Usage: /shimmer style \nStyles: shimmer, classic, minimal, dots, braille`, "info", ); return; } const valid = ["shimmer", "classic", "minimal", "dots", "braille"]; if (!valid.includes(val)) { ctx.ui.notify( `Unknown style "${val}". Options: ${valid.join(", ")}`, "error", ); return; } s.config.spinnerStyle = val as SQConfig["spinnerStyle"]; saveConfig(s.config); applyIndicator(); ctx.ui.notify(`Spinner style → ${val}`, "info"); return; } // ── speed ──────────────────────────────────────── if (sub === "speed") { const n = parseFloat(val); if (isNaN(n) || n < 0.25 || n > 4.0) { ctx.ui.notify(`/shimmer speed <0.25 – 4.0> (current: ${s.config.animationSpeed}x)`, "info"); return; } s.config.animationSpeed = n; saveConfig(s.config); applyIndicator(); if (s.turnActive) { stopAnimation(); startAnimation(); } ctx.ui.notify(`Animation speed → ${n}x`, "info"); return; } // ── shimmer on|off ─────────────────────────────────── if (sub === "shimmer") { if (val === "on") { s.config.enableShimmer = true; saveConfig(s.config); reapply(ctx); ctx.ui.notify("Shimmer effect ON", "info"); return; } if (val === "off") { s.config.enableShimmer = false; saveConfig(s.config); reapply(ctx); ctx.ui.notify("Shimmer effect OFF", "info"); return; } ctx.ui.notify("Usage: /shimmer shimmer on|off", "info"); return; } // ── mode inline|multiline ──────────────────────────── if (sub === "mode") { if (val === "inline" || val === "multiline") { s.config.quoteMode = val; saveConfig(s.config); reapply(ctx); ctx.ui.notify(`Quote mode → ${val}`, "info"); return; } ctx.ui.notify("Usage: /shimmer mode inline|multiline", "info"); return; } // ── interval ──────────────────────────────────── if (sub === "interval") { const n = parseInt(val, 10); if (isNaN(n) || n < 1000 || n > 60000) { ctx.ui.notify(`/shimmer interval <1000 – 60000> ms (current: ${s.config.quoteInterval}ms)`, "info"); return; } s.config.quoteInterval = n; saveConfig(s.config); // Restart quote rotation with new interval if (s.quoteTimer && s.turnActive) { clearInterval(s.quoteTimer); if (s.loaded.quotes.length > 1) { s.quoteTimer = setInterval(() => { s.currentQuote = pickQuote(s.loaded); updateDisplay(); }, s.config.quoteInterval); } } ctx.ui.notify(`Quote rotation interval → ${n}ms`, "info"); return; } // ── intensity ──────────────────────────────────────── if (sub === "intensity") { const valid = ["dim", "normal", "bright"]; if (!valid.includes(val)) { ctx.ui.notify(`/shimmer intensity dim|normal|bright (current: ${s.config.textIntensity})`, "info"); return; } s.config.textIntensity = val as SQConfig["textIntensity"]; saveConfig(s.config); reapply(ctx); ctx.ui.notify(`Text intensity → ${val}`, "info"); return; } // ── quotes subcommands ─────────────────────────────── if (sub === "quotes") { const qsub = parts[1]?.toLowerCase(); if (qsub === "reload") { s.loaded = loadQuotes(s.config.enableFallbackQuotes); s.currentQuote = s.loaded.quotes.length > 0 ? pickQuote(s.loaded) : null; reapply(ctx); ctx.ui.notify(`${describeQuotes(s.loaded)} — Reloaded`, "info"); return; } if (qsub === "edit") { const file = ensureUserQuotesFile(); const current = readFileSync(file, "utf8"); const edited = await ctx.ui.editor("Edit Quotes", current); if (edited === undefined) { ctx.ui.notify("Quotes edit cancelled", "info"); return; } writeFileSync(file, edited, "utf8"); s.loaded = loadQuotes(s.config.enableFallbackQuotes); s.currentQuote = s.loaded.quotes.length > 0 ? pickQuote(s.loaded) : null; reapply(ctx); ctx.ui.notify(`${describeQuotes(s.loaded)} — Saved`, "info"); return; } if (qsub === "path") { ctx.ui.notify(getUserQuotesFile(), "info"); return; } ctx.ui.notify( "Usage: /shimmer quotes [reload|edit|path]", "info", ); return; } // ── on / off ───────────────────────────────────────── if (sub === "on") { s.enabled = true; s.config.enabled = true; saveConfig(s.config); applyIndicator(); if (!s.turnActive) { // Pre-set the message for the next agent run s.verb = pickVerb(); s.currentQuote = s.loaded.quotes.length > 0 ? pickQuote(s.loaded) : null; updateDisplay(); } ctx.ui.notify("pi-shimmer-quotes-spinner ● ON", "info"); return; } if (sub === "off") { s.enabled = false; s.config.enabled = false; saveConfig(s.config); stopAnimation(); ctx.ui.setWorkingMessage(); ctx.ui.setWorkingIndicator(); ctx.ui.notify("pi-shimmer-quotes-spinner ○ OFF", "info"); return; } // ── reset ──────────────────────────────────────────── if (sub === "reset") { stopAnimation(); s.config = { ...DEFAULTS }; s.enabled = DEFAULTS.enabled; s.config.enabled = DEFAULTS.enabled; s.loaded = loadQuotes(DEFAULTS.enableFallbackQuotes); s.currentQuote = s.loaded.quotes.length > 0 ? pickQuote(s.loaded) : null; saveConfig(s.config); applyIndicator(); if (s.turnActive) { startAnimation(); } else { updateDisplay(); } ctx.ui.notify("Reset to defaults", "info"); return; } // ── Unknown subcommand ─────────────────────────────── ctx.ui.notify( [ "Usage: /shimmer [subcommand]", " style — shimmer, classic, minimal, dots, braille", " speed — 0.25 – 4.0", " shimmer on|off — toggle colour sweep", " mode inline|ml — quote layout", " interval — 1000 – 60000", " intensity d|n|b — dim, normal, bright", " quotes reload — reload quotes file", " quotes edit — edit quotes file", " quotes path — show quotes file path", " on|off — master toggle", " reset — restore defaults", ].join("\n"), "info", ); }, }); } // ─── Colour Helper ────────────────────────────────────────────────────── function hexVals(hex: string): string { const h = hex.replace("#", ""); return `${parseInt(h.slice(0, 2), 16)};${parseInt(h.slice(2, 4), 16)};${parseInt(h.slice(4, 6), 16)}`; }