/** * pi-shimmer — Gentle per-character color sweep for working messages. * * Wraps ctx.ui.setWorkingMessage to intercept plain-text messages set by * other extensions (like pi-powerline-footer's AI-generated vibes) and * applies a moving highlight-band animation across the text. * * ─── Features ──────────────────────────────────────────────────── * • Transparent monkey-patch — no changes needed in other extensions * • 6 built-in color presets + auto (follows theme accent) * • Idle-aware — pauses shimmer when agent is waiting (no flicker) * • Lightweight — interval-driven, per-character ANSI RGB coloring * * ─── Commands ───────────────────────────────────────────────────── * /shimmer Show current preset * /shimmer Switch preset (auto|gold|silver|rose|neon|rainbow|off) * /shimmer speed Set animation interval (default 200) * /shimmer band Set highlight band width (default 4) * * ─── Settings (.pi/settings.json) ────────────────────────────────── * "workingVibeShimmer": "auto" // string shorthand * "workingVibeShimmer": { // or full object * "preset": "gold", * "speed": 150, * "bandWidth": 3 * } * * Based on @dustydonkey/pi-spinner's shimmer implementation. */ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import type { AutocompleteItem } from "@earendil-works/pi-tui"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; // ═══════════════════════════════════════════════════════════════════ // Types // ═══════════════════════════════════════════════════════════════════ type ShimmerPreset = | "auto" | "gold" | "silver" | "rose" | "neon" | "rainbow" | "off"; interface ShimmerConfig { preset: ShimmerPreset; speed: number; // ms between animation frames bandWidth: number; // characters wide the highlight band is } /** Where to persist shimmer config — the project or the global settings file. */ type WriteScope = "project" | "global"; interface ColorPair { base: [number, number, number]; shimmer: [number, number, number]; } // ═══════════════════════════════════════════════════════════════════ // Constants // ═══════════════════════════════════════════════════════════════════ const DEFAULT_CONFIG: ShimmerConfig = { preset: "auto", speed: 200, bandWidth: 4, }; /** Pre-defined color pairs. "auto" extracts from theme; "off" disables. */ const SHIMMER_PRESETS: Record, ColorPair> = { gold: { base: [184, 134, 11], shimmer: [255, 215, 0] }, silver: { base: [128, 128, 128], shimmer: [232, 232, 232] }, rose: { base: [176, 112, 80], shimmer: [232, 168, 124] }, neon: { base: [10, 138, 138], shimmer: [0, 255, 255] }, }; const VALID_PRESETS: ShimmerPreset[] = [ "auto", "gold", "silver", "rose", "neon", "rainbow", "off", ]; /** The seven classic rainbow hues — a rolling neon marquee uses these. */ const RAINBOW_COLORS: [number, number, number][] = [ [255, 82, 82], // red [255, 165, 0], // orange [255, 235, 59], // yellow [0, 230, 118], // green [0, 229, 255], // cyan [41, 121, 255], // blue [177, 64, 255], // violet ]; // ═══════════════════════════════════════════════════════════════════ // Settings I/O // ═══════════════════════════════════════════════════════════════════ /** Project-local settings file: /.pi/settings.json */ function settingsPath(cwd: string): string { return path.join(cwd, ".pi", "settings.json"); } /** * Global settings file, following pi's convention (`getAgentDir()`). * Defaults to `~/.pi/agent/settings.json`, honoring `PI_CODING_AGENT_DIR`. */ function globalSettingsPath(): string { const envDir = process.env.PI_CODING_AGENT_DIR; if (envDir) return path.join(envDir, "settings.json"); return path.join(os.homedir(), ".pi", "agent", "settings.json"); } /** * Apply a raw `workingVibeShimmer` value (string shorthand or object) onto * an existing config, field-by-field (only overrides provided keys). */ function applyConfigValue(config: ShimmerConfig, raw: unknown): void { // String shorthand: "workingVibeShimmer": "gold" if (typeof raw === "string") { const preset = raw.toLowerCase() as ShimmerPreset; if (VALID_PRESETS.includes(preset)) config.preset = preset; return; } // Object format: { "preset": "gold", "speed": 150, "bandWidth": 3 } if (typeof raw === "object" && raw !== null) { const obj = raw as Record; if (VALID_PRESETS.includes(obj.preset as ShimmerPreset)) { config.preset = obj.preset as ShimmerPreset; } if (typeof obj.speed === "number" && obj.speed >= 50) config.speed = obj.speed; if (typeof obj.bandWidth === "number" && obj.bandWidth >= 1) { config.bandWidth = obj.bandWidth; } } } /** Apply shimmer settings from one settings file (if present & valid). */ function tryApplySettingsFile(filePath: string, config: ShimmerConfig): void { try { if (!fs.existsSync(filePath)) return; const parsed = JSON.parse(fs.readFileSync(filePath, "utf-8")) as Record; const saved = parsed.workingVibeShimmer; if (saved === undefined) return; applyConfigValue(config, saved); } catch { // Ignore unreadable or malformed settings files — fall back gracefully. } } /** * Resolve config with pi's merge semantics: * defaults < global (~/.pi/agent/settings.json) < project (/.pi/settings.json). */ function readConfig(cwd: string): ShimmerConfig { const config: ShimmerConfig = { ...DEFAULT_CONFIG }; // Global first (base), then project (overrides global field-by-field). tryApplySettingsFile(globalSettingsPath(), config); tryApplySettingsFile(settingsPath(cwd), config); return config; } function configFilePath(cwd: string, scope: WriteScope): string { return scope === "global" ? globalSettingsPath() : settingsPath(cwd); } /** Where the effective shimmer config currently comes from (project overrides global). */ function effectiveScope(cwd: string): WriteScope { const sp = settingsPath(cwd); try { if (fs.existsSync(sp)) { const parsed = JSON.parse(fs.readFileSync(sp, "utf-8")) as Record; if (parsed.workingVibeShimmer !== undefined) return "project"; } } catch { // Ignore malformed project settings — fall through to global. } return "global"; } /** * Persist shimmer config to the project (/.pi/settings.json) or the global * (~/.pi/agent/settings.json) settings file. Returns false on failure so the * caller can surface an error instead of pretending the config was saved. */ function writeConfig(cwd: string, config: ShimmerConfig, scope: WriteScope = "project"): boolean { const sp = configFilePath(cwd, scope); // Declared outside the try so the catch can clean up a leftover temp file. let tmp = ""; try { let parsed: Record = {}; if (fs.existsSync(sp)) { parsed = JSON.parse(fs.readFileSync(sp, "utf-8")); } parsed.workingVibeShimmer = config; // Ensure the parent dir exists (mirrors pi / pi-tasks / pi-cache-optimizer). fs.mkdirSync(path.dirname(sp), { recursive: true }); // Atomic write: temp file + rename, so a crash can't corrupt settings.json. tmp = `${sp}.${process.pid}.${Date.now()}.tmp`; fs.writeFileSync(tmp, JSON.stringify(parsed, null, 2) + "\n", "utf-8"); fs.renameSync(tmp, sp); return true; } catch (e) { if (tmp) { try { fs.rmSync(tmp, { force: true }); } catch { /* ignore */ } } console.error("[pi-shimmer] Failed to write config:", e); return false; } } // ═══════════════════════════════════════════════════════════════════ // Color Utilities // ═══════════════════════════════════════════════════════════════════ function hexToRgb(hex: string): [number, number, number] { const h = hex.replace("#", ""); return [ parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16), ]; } function blendColors( c1: [number, number, number], c2: [number, number, number], t: number, ): [number, number, number] { return [ Math.round(c1[0] + (c2[0] - c1[0]) * t), Math.round(c1[1] + (c2[1] - c1[1]) * t), Math.round(c1[2] + (c2[2] - c1[2]) * t), ]; } function lightenRgb(r: number, g: number, b: number, amount: number): [number, number, number] { return [ Math.min(255, Math.round(r + (255 - r) * amount)), Math.min(255, Math.round(g + (255 - g) * amount)), Math.min(255, Math.round(b + (255 - b) * amount)), ]; } /** Extract the current theme's accent color as a hex string. */ function getThemeAccentHex(ctx: ExtensionContext): string | null { const sample = ctx.ui.theme.fg("accent", "\u2588"); // full block char const match = sample.match(/\x1b\[38;2;(\d+);(\d+);(\d+)m/); if (!match) return null; const r = parseInt(match[1]!).toString(16).padStart(2, "0"); const g = parseInt(match[2]!).toString(16).padStart(2, "0"); const b = parseInt(match[3]!).toString(16).padStart(2, "0"); return `#${r}${g}${b}`; } /** Strip all ANSI escape codes, leaving only plain text. */ function stripAnsi(text: string): string { // Matches CSI sequences: ESC [ ... m return text.replace(/\x1b\[[0-9;]*m/g, ""); } // ═══════════════════════════════════════════════════════════════════ // Shimmer Core // ═══════════════════════════════════════════════════════════════════ /** * Per-character ANSI RGB color sweep. * * A highlight band of `bandWidth` characters moves across the text from * left to right. Characters near the band center get the shimmer color; * those farther away blend toward the base color. * * @param text - Plain text to colorize * @param frame - Animation frame number (increments each tick) * @param base - Base (dark) RGB color * @param shimmer - Highlight (bright) RGB color * @param bandWidth - Width of the highlight band in characters * @returns ANSI-escaped string with per-character 24-bit color */ function colorSweep( text: string, frame: number, base: [number, number, number], shimmer: [number, number, number], bandWidth: number, ): string { // Total animation span: text + padding on both sides so the band // smoothly enters from the left and exits to the right const totalWidth = text.length + bandWidth * 2; const pos = frame % totalWidth; let result = ""; for (let i = 0; i < text.length; i++) { // Distance from this character to the band center const dist = Math.abs(i - pos); // 0 = full base, 1 = full shimmer (within band) const t = Math.max(0, 1 - dist / bandWidth); const [r, g, b] = blendColors(base, shimmer, t); result += `\x1b[38;2;${r};${g};${b}m${text[i]}\x1b[0m`; } return result; } // ═══════════════════════════════════════════════════════════════════ // Rainbow Shimmer (rolling neon marquee) // ═══════════════════════════════════════════════════════════════════ /** * Rolling rainbow marquee — each character cycles through the seven * rainbow hues, shifting one step every frame. The result is a neon * light trail flowing across the message, glowing in all seven colors. * * @param text - Plain text to colorize * @param frame - Animation frame number (increments each tick) * @param _bandWidth - Not used here; kept for a consistent signature. * @returns ANSI-escaped string with per-character 24-bit rainbow color */ function rainbowSweep( text: string, frame: number, _bandWidth: number, ): string { const len = RAINBOW_COLORS.length; let result = ""; for (let i = 0; i < text.length; i++) { const [r, g, b] = RAINBOW_COLORS[(i + frame) % len]!; result += `\x1b[38;2;${r};${g};${b}m${text[i]}\x1b[0m`; } return result; } // ═══════════════════════════════════════════════════════════════════ // Extension // ═══════════════════════════════════════════════════════════════════ export default function (pi: ExtensionAPI) { // ── Module-level state ────────────────────────────────────────── let config: ShimmerConfig = { ...DEFAULT_CONFIG }; /** Reference to the original (unpatched) setWorkingMessage. */ let originalSetWorkingMessage: ((msg?: string) => void) | null = null; /** Our patched function (for identity check during uninstall). */ let patchedFn: ((msg?: string) => void) | null = null; /** Whether our patch is currently installed on ctx.ui. */ let isPatched = false; /** The plain-text message last set by another extension. */ let currentPlainText: string | undefined; /** Shimmer animation interval handle. */ let shimmerTimer: ReturnType | null = null; /** Current animation frame (increments each tick). */ let shimmerFrame = 0; /** Resolved base color for the current config. */ let baseColor: [number, number, number] | null = null; /** Resolved shimmer (highlight) color for the current config. */ let shimmerColor: [number, number, number] | null = null; /** Latest extension context (refreshed each session_start). */ let currentCtx: ExtensionContext | null = null; // ── Internal helpers ──────────────────────────────────────────── /** Resolve base + shimmer colors from the current config and theme. */ function resolveColors(ctx: ExtensionContext): void { if (config.preset === "off") return; // Rainbow neon marquee has no base/shimmer pair — handled separately. if (config.preset === "rainbow") return; if (config.preset === "auto") { const hex = getThemeAccentHex(ctx); if (hex) { const [r, g, b] = hexToRgb(hex); baseColor = [r, g, b]; shimmerColor = lightenRgb(r, g, b, 0.5); return; } // Fallback: if we can't extract theme color, use a neutral silver baseColor = [128, 128, 128]; shimmerColor = [232, 232, 232]; return; } const preset = SHIMMER_PRESETS[config.preset]; if (preset) { baseColor = preset.base; shimmerColor = preset.shimmer; } } /** * Colorize the current plain text at the current frame. * Returns the ANSI string, or null when there's nothing to paint * (no text, disabled, or the agent is idle). */ function composeFrame(): string | null { if (!currentCtx || !currentPlainText) return null; if (config.preset === "off") return null; // Pause animation when the agent is idle (e.g. waiting for // sub-processes) — avoids pointless re-renders and terminal flicker. if (currentCtx.isIdle()) return null; if (config.preset === "rainbow") { // Neon marquee: no base/shimmer pair needed, rolls through 7 hues. return rainbowSweep(currentPlainText, shimmerFrame, config.bandWidth); } if (!baseColor || !shimmerColor) return null; return colorSweep( currentPlainText, shimmerFrame, baseColor, shimmerColor, config.bandWidth, ); } /** Render one animation frame (advances the frame counter). */ function updateShimmer(): void { const colored = composeFrame(); if (colored === null) return; // Call the ORIGINAL function directly — bypasses our own patch originalSetWorkingMessage?.(colored); shimmerFrame++; } /** * Push the current frame to the display WITHOUT advancing the frame * counter. Used to show freshly-set text immediately (no 200ms delay) * while keeping the animation rhythm owned by the interval. */ function paintCurrentFrame(): void { const colored = composeFrame(); if (colored === null) return; originalSetWorkingMessage?.(colored); } /** Start the shimmer animation timer. */ function startShimmer(ctx: ExtensionContext): void { // Defensive: ensure no duplicate timers stopShimmer(); resolveColors(ctx); // Render the first frame immediately if (currentPlainText) updateShimmer(); // Then continue on an interval shimmerTimer = setInterval(updateShimmer, config.speed); } /** Stop the shimmer animation timer. */ function stopShimmer(): void { if (shimmerTimer !== null) { clearInterval(shimmerTimer); shimmerTimer = null; } } /** Install the monkey-patch on ctx.ui.setWorkingMessage. */ function installPatch(ctx: ExtensionContext): void { if (isPatched) return; originalSetWorkingMessage = ctx.ui.setWorkingMessage.bind(ctx.ui); patchedFn = (message?: string): void => { if (message === undefined) { // Another extension called reset → stop shimmer, pass through currentPlainText = undefined; stopShimmer(); originalSetWorkingMessage!(undefined); return; } // Store the plain text (strip any ANSI that might come from // another extension that already colored it). When text transitions // from empty -> set, treat it as a fresh start and restart the frame // from 0; mid-stream updates keep advancing so the band stays fluid. const hadText = currentPlainText !== undefined; currentPlainText = stripAnsi(message); if (!hadText) shimmerFrame = 0; // Update the running animation in place — do NOT restart the timer or // reset the frame on every set. pi-zero streams working messages rapidly // during thinking/text deltas; restarting on each call keeps clearing the // interval before it ever ticks, which froze the band at frame 0 (the // animation appeared paused while thinking was streaming). Only kick off // the timer once; subsequent sets just swap in fresh text. if (!shimmerTimer && currentCtx) { startShimmer(currentCtx); } else { paintCurrentFrame(); } }; ctx.ui.setWorkingMessage = patchedFn; isPatched = true; } /** Remove the monkey-patch and restore the original method. */ function uninstallPatch(ctx: ExtensionContext): void { if (!isPatched || !originalSetWorkingMessage) return; // Only uninstall if our patched function is still the one installed. // (Another extension may have wrapped ours, in which case we leave it.) if (ctx.ui.setWorkingMessage === patchedFn) { ctx.ui.setWorkingMessage = originalSetWorkingMessage; } isPatched = false; patchedFn = null; } // ── Event hooks ───────────────────────────────────────────────── pi.on("session_start", async (_event, ctx) => { if (!ctx.hasUI) return; currentCtx = ctx; config = readConfig(ctx.cwd); if (config.preset === "off") { uninstallPatch(ctx); currentPlainText = undefined; return; } // Install fresh patch (handles /reload correctly) uninstallPatch(ctx); installPatch(ctx); }); pi.on("agent_end", async (_event, ctx) => { currentCtx = ctx; stopShimmer(); currentPlainText = undefined; }); pi.on("session_shutdown", async (_event, ctx) => { stopShimmer(); currentPlainText = undefined; if (ctx.hasUI) { uninstallPatch(ctx); } currentCtx = null; }); // ── Commands ──────────────────────────────────────────────────── pi.registerCommand("shimmer", { description: "Configure working message shimmer. See /shimmer for details.", getArgumentCompletions: (prefix: string): AutocompleteItem[] | null => { const items: AutocompleteItem[] = [ { value: "auto", label: "auto", description: "Follow theme accent color" }, { value: "gold", label: "gold", description: "Warm golden sweep" }, { value: "silver", label: "silver", description: "Cool silver sweep" }, { value: "rose", label: "rose", description: "Rose gold sweep" }, { value: "neon", label: "neon", description: "Neon cyan sweep" }, { value: "rainbow", label: "rainbow", description: "Rolling rainbow neon marquee" }, { value: "off", label: "off", description: "Disable shimmer" }, { value: "speed ", label: "speed", description: "Set animation speed (ms)" }, { value: "band ", label: "band", description: "Set highlight band width" }, { value: "--global", label: "--global", description: "Save to global settings (~/.pi/agent/settings.json) instead of project" }, { value: "-g", label: "-g", description: "Alias for --global" }, ]; const first = prefix.split(/\s+/)[0]?.toLowerCase() ?? ""; if (first === "speed" || first === "band") return null; // free text const filtered = prefix ? items.filter((i) => i.value.startsWith(prefix.toLowerCase())) : items; return filtered.length > 0 ? filtered : null; }, handler: async (args, ctx) => { // Non-interactive modes (-p / json) don't configure shimmer. Guarding on // hasUI also avoids writing config from a default (uninitialized) config, // which would otherwise overwrite a saved preset when session_start's // readConfig was skipped because ctx.hasUI was false. if (!ctx.hasUI) return; const trimmed = args.trim(); if (!trimmed) { // Show current config + effective source (project overrides global) const presetLabel = config.preset === "auto" ? `auto (theme accent)` : config.preset; const source = effectiveScope(ctx.cwd) === "global" ? "global" : "project"; ctx.ui.notify( `Shimmer: ${presetLabel} | speed: ${config.speed}ms | band: ${config.bandWidth}ch (source: ${source})`, "info", ); return; } // Parse an optional --global / -g flag (any position); target defaults to project. const tokens = trimmed.split(/\s+/); let target: WriteScope = "project"; const restTokens = tokens.filter((t) => { if (t === "--global" || t === "-g") { target = "global"; return false; } return true; }); const parts = restTokens.join(" ").split(/\s+/); const sub = parts[0]!.toLowerCase(); if (sub === "speed" && parts.length > 1) { const n = parseInt(parts[1]!, 10); if (isNaN(n) || n < 50) { ctx.ui.notify("Speed must be >= 50ms (e.g. /shimmer speed 150)", "error"); return; } config = { ...config, speed: n }; if (!writeConfig(ctx.cwd, config, target)) { ctx.ui.notify(`Failed to write config to ${target} settings`, "error"); return; } // Restart shimmer with new speed if (currentPlainText) { stopShimmer(); startShimmer(ctx); } ctx.ui.notify(`Shimmer speed: ${n}ms`, "info"); return; } if (sub === "band" && parts.length > 1) { const n = parseInt(parts[1]!, 10); if (isNaN(n) || n < 1 || n > 10) { ctx.ui.notify("Band width must be 1–10 (e.g. /shimmer band 3)", "error"); return; } config = { ...config, bandWidth: n }; if (!writeConfig(ctx.cwd, config, target)) { ctx.ui.notify(`Failed to write config to ${target} settings`, "error"); return; } ctx.ui.notify(`Shimmer band width: ${n} characters`, "info"); return; } // Preset name const match = VALID_PRESETS.find((p) => p === sub); if (match) { config = { ...config, preset: match }; if (!writeConfig(ctx.cwd, config, target)) { ctx.ui.notify(`Failed to write config to ${target} settings`, "error"); return; } if (match === "off") { stopShimmer(); currentPlainText = undefined; uninstallPatch(ctx); ctx.ui.notify("Shimmer: off", "info"); } else { // Re-install patch if we're coming from "off" (patch was removed) if (!isPatched) { installPatch(ctx); } // Re-resolve colors and restart shimmer if currently showing text resolveColors(ctx); if (currentPlainText) { stopShimmer(); startShimmer(ctx); } ctx.ui.notify(`Shimmer preset: ${match}`, "info"); } return; } ctx.ui.notify( "Usage: /shimmer [auto|gold|silver|rose|neon|rainbow|off|speed |band ]", "error", ); }, }); }