/** * pi-shimmer-quotes-spinner — Renderer module * * Combines the spinner verb, shimmer effect, status information, and * optional quote into a single working-message string suitable for * `ctx.ui.setWorkingMessage()`. * * Adapts to narrow terminals by truncating the quote. */ import type { SQConfig } from "./config"; import { colorSweep, staticColor } from "./spinner"; // ─── Colour Palettes Per Intensity ───────────────────────────────────── interface IntensityColors { verbBase: string; // base verb colour verbShimmer: string; // peak shimmer highlight dimText: string; // status / quote foreground } const PALETTE: Record = { dim: { verbBase: "#B8865C", verbShimmer: "#D4A574", dimText: "#777777", }, normal: { verbBase: "#D77757", verbShimmer: "#F59575", dimText: "#999999", }, bright: { verbBase: "#FF9A6C", verbShimmer: "#FFB896", dimText: "#BBBBBB", }, }; // ─── ANSI-Aware Character Width Utilities ────────────────────────────── /** Strip ANSI escape codes from a string. */ function ansiStrip(text: string): string { return text.replace(/\x1b\[[0-9;:]+[a-zA-Z]/g, ""); } function isWideCodePoint(code: number): boolean { return ( (code >= 0x1100 && code <= 0x115f) || // Hangul Jamo (code >= 0x2e80 && code <= 0xa4cf) || // CJK radicals → Yi (code >= 0xac00 && code <= 0xd7a3) || // Hangul syllables (code >= 0xf900 && code <= 0xfaff) || // CJK compatibility ideographs (code >= 0xfe30 && code <= 0xfe4f) || // CJK compatibility forms (code >= 0xff01 && code <= 0xff60) || // Fullwidth forms (code >= 0xffe0 && code <= 0xffe6) || // Fullwidth signs (code >= 0x1f300 && code <= 0x1faff) || // Emoji & symbols (code >= 0x20000 && code <= 0x3fffd) // CJK extensions B+ ); } /** Visible width of a string (accounting for CJK / emoji). */ export function displayWidth(text: string): number { let w = 0; for (const char of text) { const cp = char.codePointAt(0) ?? 0; if (cp === 0x200b || cp === 0x200d || cp === 0xfe0f || (cp >= 0x0300 && cp <= 0x036f)) { continue; } w += isWideCodePoint(cp) ? 2 : 1; } return w; } /** * Truncate `text` so its visible width ≤ `maxWidth`, appending an ellipsis. */ function truncateToWidth(text: string, maxWidth: number, ellipsis = "…"): string { if (displayWidth(text) <= maxWidth) return text; let result = ""; let w = 0; const ew = displayWidth(ellipsis); for (const char of text) { const cw = isWideCodePoint(char.codePointAt(0) ?? 0) ? 2 : 1; if (w + cw + ew > maxWidth) break; result += char; w += cw; } return result + ellipsis; } // ─── Terminal Width Detection ────────────────────────────────────────── /** * Return usable terminal content width. * The loader's Text component adds ~2 columns of padding. */ export function terminalWidth(): number { return Math.max(20, (process.stdout.columns ?? 80) - 2); } // ─── Message Builder ─────────────────────────────────────────────────── export interface BuildMessageParams { /** Current verb (e.g. "Brewing") */ verb: string; /** Shimmer animation frame index */ shimmerFrame: number; /** Active configuration */ config: SQConfig; /** Status segments — elapsed time, token count, thinking state */ statusParts: string[]; /** Current quote text (or null to skip) */ quote: string | null; } /** * Build the complete working-message string. * * Format (inline): * Crunching… (↓ 1,234 tokens · 32s) "A quote here" — Author * * Format (multiline): * Crunching… (↓ 1,234 tokens · 32s) * "A quote here" — Author * * Returns a plain string (no spinner glyph — that goes via * `setWorkingIndicator()`). */ export function buildWorkingMessage(params: BuildMessageParams): string { const { verb, shimmerFrame, config, statusParts, quote } = params; const palette = PALETTE[config.textIntensity] ?? PALETTE.normal; const RESET = "\x1b[0m"; // ── Verb with optional shimmer ────────────────────────────── let verbText: string; if (config.enableShimmer) { verbText = colorSweep(verb + "…", shimmerFrame, palette.verbBase, palette.verbShimmer, true); } else { verbText = staticColor(verb + "…", palette.verbBase); } // ── Status portion ────────────────────────────────────────── const statusStr = statusParts.length > 0 ? ` \x1b[38;2;${hexToRGB(palette.dimText)}m(${statusParts.join(" · ")})${RESET}` : ""; // ── Quote portion ─────────────────────────────────────────── if (quote && config.quoteMode === "inline") { const tw = terminalWidth(); // Measure visible width of verb + status (strip ANSI first) const verbDisplay = displayWidth(verb) + 1; // "Brewing… " const statusDisplay = displayWidth(ansiStrip(statusStr)); const padding = 4; // spaces around quote const available = Math.max(8, tw - verbDisplay - statusDisplay - padding); const truncated = truncateToWidth(quote, available); const styled = `\x1b[38;2;${hexToRGB(palette.dimText)}m${truncated}${RESET}`; return `${verbText}${statusStr} ${styled}`; } if (quote && config.quoteMode === "multiline") { const tw = terminalWidth(); const padLeft = Math.max(0, Math.floor((tw - displayWidth(quote)) / 2)); const centered = `${" ".repeat(padLeft)}${quote}`; const styled = `\x1b[38;2;${hexToRGB(palette.dimText)}m${centered}${RESET}`; return `${verbText}${statusStr}\n${styled}`; } return `${verbText}${statusStr}`; } // ─── Internal ────────────────────────────────────────────────────────── function hexToRGB(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)}`; }