import { S_BAR, cancel, isCI, log, settings, unicode } from "@clack/prompts" import { dim, gray, yellow } from "ansis" import { clearScreenDown, cursorTo, moveCursor } from "node:readline" import { isJsonOutput } from "./json-output" let activeSpinner: SpinnerHandle | undefined let sigintHandlerInstalled = false /** * Starts a spinner before an operation and clears it after successful * completion. * * @param message - Status message displayed while the operation runs. * @param operation - Asynchronous operation to execute. */ export async function withSpinner( message: string, operation: () => Promise, ): Promise { startSpinner(message) return await operation().then((result) => { clearSpinner() return result }) } /** * Replaces any active spinner with a new status message. * * @param message - Status message to display. * @param interruptMessage - Message shown when the user interrupts the wait. */ export function startSpinner(message: string, interruptMessage = "Cancelled") { clearSpinner() if (isJsonOutput()) return ensureSigintHandler() activeSpinner = createSpinner(message, interruptMessage) } /** Clears the active spinner and removes its terminal output. */ export function clearSpinner() { activeSpinner?.clear() activeSpinner = undefined } /** Handles interruption by clearing and marking the active operation canceled. */ function handleSigint() { if (!activeSpinner) return const { interruptMessage, message } = activeSpinner clearSpinner() log.error(dim.strikethrough(message)) cancel(interruptMessage) process.exit(0) } /** Installs the process interruption handler at most once. */ function ensureSigintHandler() { if (sigintHandlerInstalled) return sigintHandlerInstalled = true process.on("SIGINT", handleSigint) } type SpinnerHandle = { clear(): void interruptMessage: string message: string } /** * Creates an interactive spinner or a static status line for non-TTY output. * * @param message - Status message to display. * @param interruptMessage - Message shown when the user interrupts the wait. */ function createSpinner( message: string, interruptMessage: string, ): SpinnerHandle { const frames = unicode ? ["◒", "◐", "◓", "◑"] : ["•", "o", "O", "0"] let frameIndex = 0 let interval: ReturnType | undefined let previousOutput: string | undefined let wroteGuide = false let active = true /** Removes the previously rendered spinner frame from the terminal. */ function clearPreviousOutput() { if (previousOutput === undefined) return const lines = previousOutput.split("\n") if (lines.length > 1) moveCursor(process.stderr, 0, -(lines.length - 1)) cursorTo(process.stderr, 0) clearScreenDown(process.stderr) } /** Renders the next spinner frame. */ function render() { if (previousOutput !== undefined) { const lines = previousOutput.split("\n") if (lines.length > 1) moveCursor(process.stderr, 0, -(lines.length - 1)) cursorTo(process.stderr, 0) } previousOutput = `${yellow(frames[frameIndex] ?? frames[0]!)} ${message}` process.stderr.write(previousOutput) frameIndex = frameIndex + 1 < frames.length ? frameIndex + 1 : 0 } /** Stops animation and removes all spinner output. */ function clear() { if (!active) return active = false if (interval) clearInterval(interval) clearPreviousOutput() if (wroteGuide) { moveCursor(process.stderr, 0, -1) cursorTo(process.stderr, 0) clearScreenDown(process.stderr) } } if (!process.stderr.isTTY || isCI()) { log.message(message, { output: process.stderr, symbol: "-" }) return { clear, interruptMessage, message } } if (settings.withGuide) { wroteGuide = true process.stderr.write(`${gray(S_BAR)}\n`) } render() interval = setInterval(render, unicode ? 80 : 120) return { clear, interruptMessage, message } }