/** * pi-shimmer-quotes-spinner — Spinner animation module * * Provides frame generators for each spinner style, speed calculation, * and the colour-sweep shimmer engine used on the verb text. */ import type { WorkingIndicatorOptions } from "@earendil-works/pi-coding-agent"; import type { SpinnerStyle } from "./config"; // ─── Shimmer Glyphs (Claude Code style) ──────────────────────────────── const SHIMMER_CHARS = ["·", "✢", "✳", "✶", "✻", "✽"]; /** * Ping-pong sequence: forward then immediately reverse. * Claude Code uses this exact pattern. */ export const SHIMMER_FRAMES: string[] = [ ...SHIMMER_CHARS, ...[...SHIMMER_CHARS].reverse(), ]; // ─── Other Style Frames ──────────────────────────────────────────────── const CLASSIC_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; const MINIMAL_FRAMES = ["·", "•", "●", "•"]; const DOTS_FRAMES = ["⣀", "⣠", "⣴", "⣾", "⣿", "⣾", "⣴", "⣠"]; const BRAILLE_FRAMES = [ "⠋⠙", "⠙⠸", "⠹⠴", "⠸⠦", "⠼⠧", "⠴⠇", "⠦⠏", "⠧⠋", "⠇⠙", "⠏⠹", ]; // ─── Style Registry ──────────────────────────────────────────────────── interface StyleEntry { frames: string[]; defaultIntervalMs: number; } const STYLE_MAP: Record = { shimmer: { frames: SHIMMER_FRAMES, defaultIntervalMs: 120, }, classic: { frames: CLASSIC_FRAMES, defaultIntervalMs: 100, }, minimal: { frames: MINIMAL_FRAMES, defaultIntervalMs: 150, }, dots: { frames: DOTS_FRAMES, defaultIntervalMs: 130, }, braille: { frames: BRAILLE_FRAMES, defaultIntervalMs: 110, }, }; // ─── Public API ──────────────────────────────────────────────────────── /** * Return the spinner frame array for a given style. */ export function getSpinnerFrames(style: SpinnerStyle): string[] { return STYLE_MAP[style]?.frames ?? STYLE_MAP.shimmer.frames; } /** * Compute the interval between frames based on speed multiplier. */ export function intervalMs(style: SpinnerStyle, speedMultiplier: number): number { const entry = STYLE_MAP[style] ?? STYLE_MAP.shimmer; const base = entry.defaultIntervalMs; return Math.max(40, Math.min(400, base / Math.max(0.25, speedMultiplier))); } /** * Build a WorkingIndicatorOptions object for `ctx.ui.setWorkingIndicator()`. */ export function buildIndicatorOptions( style: SpinnerStyle, speed: number, colorFn: (text: string) => string, ): WorkingIndicatorOptions { const frames = getSpinnerFrames(style); return { frames: frames.map((f) => colorFn(f)), intervalMs: intervalMs(style, speed), }; } // ─── Shimmer Color-Sweep Engine ──────────────────────────────────────── const SHIMMER_BAND = 4; // highlight-band width in characters 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 blend( a: [number, number, number], b: [number, number, number], t: number, ): [number, number, number] { return [ Math.round(a[0] + (b[0] - a[0]) * t), Math.round(a[1] + (b[1] - a[1]) * t), Math.round(a[2] + (b[2] - a[2]) * t), ]; } /** * Apply a moving colour-sweep highlight across the verb text. * Each character gets an ANSI 24-bit colour depending on its * distance from a moving "band" position. * * @param text – the verb (e.g. "Brewing") * @param frame – animation frame index (increments each tick) * @param baseHex – base colour for un-highlighted characters * @param shimmerHex – peak colour at the centre of the band * @param reverse – sweep direction (true = right-to-left) */ export function colorSweep( text: string, frame: number, baseHex: string, shimmerHex: string, reverse: boolean, ): string { const base = hexToRgb(baseHex); const shimmer = hexToRgb(shimmerHex); const total = text.length + SHIMMER_BAND * 2; const rawPos = frame % total; const pos = reverse ? total - 1 - rawPos : rawPos; let out = ""; for (let i = 0; i < text.length; i++) { const dist = Math.abs(i - pos); const t = Math.max(0, 1 - dist / SHIMMER_BAND); const c = blend(base, shimmer, t); out += `\x1b[38;2;${c[0]};${c[1]};${c[2]}m${text[i]}`; } return out + "\x1b[0m"; } /** * Static colour (no shimmer sweep). */ export function staticColor(text: string, hex: string): string { const c = hexToRgb(hex); return `\x1b[38;2;${c[0]};${c[1]};${c[2]}m${text}\x1b[0m`; }