import React, { useEffect, useRef, useState } from 'react' import { Text } from 'ink' import { theme, PANEL_WIDTH } from './theme.js' const CONTENT_WIDTH = PANEL_WIDTH - 4 export const SPINNER_VERBS: string[] = [ 'accomplishing', 'actioning', 'actualizing', 'architecting', 'baking', 'beaming', "beboppin'", 'befuddling', 'billowing', 'blanching', 'bloviating', 'boogieing', 'boondoggling', 'booping', 'bootstrapping', 'brewing', 'bunning', 'burrowing', 'calculating', 'canoodling', 'caramelizing', 'cascading', 'catapulting', 'cerebrating', 'channeling', 'choreographing', 'churning', 'coalescing', 'cogitating', 'combobulating', 'composing', 'computing', 'concocting', 'considering', 'contemplating', 'cooking', 'crafting', 'creating', 'crunching', 'crystallizing', 'cultivating', 'deciphering', 'deliberating', 'determining', 'dilly-dallying', 'discombobulating', 'doing', 'doodling', 'drizzling', 'ebbing', 'effecting', 'elucidating', 'embellishing', 'enchanting', 'envisioning', 'evaporating', 'fermenting', 'fiddle-faddling', 'finagling', 'flambéing', 'flibbertigibbeting', 'flowing', 'flummoxing', 'fluttering', 'forging', 'forming', 'frolicking', 'frosting', 'gallivanting', 'galloping', 'garnishing', 'generating', 'gesticulating', 'germinating', 'grooving', 'gusting', 'harmonizing', 'hashing', 'hatching', 'herding', 'honking', 'hullaballooing', 'hyperspacing', 'ideating', 'imagining', 'improvising', 'incubating', 'inferring', 'infusing', 'ionizing', 'jitterbugging', 'julienning', 'kneading', 'leavening', 'levitating', 'lollygagging', 'manifesting', 'marinating', 'meandering', 'metamorphosing', 'misting', 'moonwalking', 'moseying', 'mulling', 'mustering', 'musing', 'nebulizing', 'nesting', 'noodling', 'nucleating', 'orbiting', 'orchestrating', 'osmosing', 'perambulating', 'percolating', 'perusing', 'philosophising', 'photosynthesizing', 'pollinating', 'pondering', 'pontificating', 'pouncing', 'precipitating', 'prestidigitating', 'processing', 'proofing', 'propagating', 'puttering', 'puzzling', 'quantumizing', 'razzle-dazzling', 'razzmatazzing', 'recombobulating', 'reticulating', 'roosting', 'ruminating', 'sautéing', 'scampering', 'schlepping', 'scurrying', 'seasoning', 'shenaniganing', 'shimmying', 'simmering', 'skedaddling', 'sketching', 'slithering', 'smooshing', 'sock-hopping', 'spelunking', 'spinning', 'sprouting', 'stewing', 'sublimating', 'swirling', 'swooping', 'symbioting', 'synthesizing', 'tempering', 'thinking', 'thundering', 'tinkering', 'tomfoolering', 'topsy-turvying', 'transfiguring', 'transmuting', 'twisting', 'undulating', 'unfurling', 'unravelling', 'vibing', 'waddling', 'wandering', 'warping', 'whatchamacalliting', 'whirlpooling', 'whirring', 'whisking', 'wibbling', 'working', 'wrangling', 'zesting', 'zigzagging', ] export function pickVerb(): string { const idx = Math.floor(Math.random() * SPINNER_VERBS.length) return SPINNER_VERBS[idx] ?? 'thinking' } export function spinnerText(value: string): string { const text = restoreSpinnerTerms(value.toLowerCase()) return text.replace(/^(\s*)([a-z])/, (_match, prefix: string, letter: string) => `${prefix}${letter.toUpperCase()}`) } export function spinnerHintText(value: string): string { return restoreSpinnerTerms(value.toLowerCase()) } type SpinnerProps = { active?: boolean hint?: string label?: string verb?: string color?: string startedAt?: number showElapsed?: boolean } const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] export const Spinner: React.FC = ({ active = true, hint: rawHint, label, verb, color = theme.accentPeriwinkle, startedAt, showElapsed = true, }) => { const stickyVerbRef = useRef(null) const internalStartedAtRef = useRef(Date.now()) const [frame, setFrame] = useState(0) const [shimmerPos, setShimmerPos] = useState(0) useEffect(() => { if (!active) { stickyVerbRef.current = null internalStartedAtRef.current = Date.now() return } if (label === undefined && stickyVerbRef.current === null) { stickyVerbRef.current = verb ?? pickVerb() } }, [active, verb, label]) useEffect(() => { if (!active) return internalStartedAtRef.current = startedAt ?? Date.now() }, [active, startedAt]) useEffect(() => { if (!active) return const timer = setInterval(() => { setFrame(prev => (prev + 1) % FRAMES.length) }, 80) return () => clearInterval(timer) }, [active]) const autoLabel = stickyVerbRef.current ?? verb ?? 'thinking' const elapsed = showElapsed ? formatElapsedSeconds(Date.now() - (startedAt ?? internalStartedAtRef.current)) : null const renderedHint = [rawHint, elapsed].filter(Boolean).join(' · ') const hint = renderedHint ? spinnerHintText(renderedHint) : '' const hintReserve = hint ? hint.length + 3 : 0 const labelBudget = Math.max(0, CONTENT_WIDTH - 2 - hintReserve) let text = spinnerText(label ?? `${autoLabel}…`) if (text.length > labelBudget) { text = text.slice(0, Math.max(0, labelBudget - 1)) + '…' } useEffect(() => { if (!active) return const period = text.length + 12 const timer = setInterval(() => { setShimmerPos(prev => (prev + 1) % period) }, 90) return () => clearInterval(timer) }, [active, text.length]) if (!active) return null const glyph = FRAMES[frame] ?? '⠋' const shimmerStart = shimmerPos - 1 const shimmerEnd = shimmerPos + 1 const visibleStart = Math.max(0, shimmerStart) const visibleEnd = Math.min(text.length, shimmerEnd + 1) const before = text.slice(0, visibleStart) const shimmer = shimmerStart < text.length && shimmerEnd >= 0 ? text.slice(visibleStart, visibleEnd) : '' const after = text.slice(visibleEnd) return ( {glyph} {before ? {before} : null} {shimmer ? {shimmer} : null} {after ? {after} : null} {hint ? · {hint} : null} ) } function formatElapsedSeconds(milliseconds: number): string { const seconds = Math.max(0, Math.floor(milliseconds / 1000)) if (seconds < 60) return `${seconds}s` const hours = Math.floor(seconds / 3600) const minutes = Math.floor((seconds % 3600) / 60) const remainingSeconds = seconds % 60 if (hours > 0) { return remainingSeconds > 0 ? `${hours}h ${minutes}min ${remainingSeconds}s` : `${hours}h ${minutes}min` } return remainingSeconds > 0 ? `${minutes}min ${remainingSeconds}s` : `${minutes}min` } function restoreSpinnerTerms(value: string): string { return value .replace(/\bapi\b/g, 'API') .replace(/\bens\b/g, 'ENS') .replace(/\berc-8004\b/g, 'ERC-8004') .replace(/\bipfs\b/g, 'IPFS') .replace(/\bjson\b/g, 'JSON') .replace(/\bjwt\b/g, 'JWT') .replace(/\bmemory\.md\b/g, 'MEMORY.md') .replace(/\bos\b/g, 'OS') .replace(/\brpc\b/g, 'RPC') .replace(/\bsoul\.md\b/g, 'SOUL.md') .replace(/\buri\b/g, 'URI') .replace(/\burl\b/g, 'URL') .replace(/\bbase\b/g, 'Base') .replace(/\bethereum mainnet\b/g, 'Ethereum Mainnet') .replace(/\bethereum\b/g, 'Ethereum') }