import { visibleWidth } from "@earendil-works/pi-tui"; // Selective reset: clears bold/dim/italic/underline/fg but NOT background const RESET = "\x1b[22;23;24;39m"; const BOLD = "\x1b[1m"; const DIM_CODE = "\x1b[2m"; function hexToAnsi(color: string): string { const h = color.replace("#", ""); const r = parseInt(h.slice(0, 2), 16); const g = parseInt(h.slice(2, 4), 16); const b = parseInt(h.slice(4, 6), 16); return `\x1b[38;2;${r};${g};${b}m`; } // ThemeColor names — matched to pi's Theme.fg() API export const COLORS = { accent: "accent", success: "success", warning: "warning", header: "text", muted: "muted", } as const; // Hex fallbacks used when no pi theme is injected (tests, cold start) const FALLBACK_HEX: Record = { accent: "#febc38", success: "#5faf5f", warning: "#ff9500", text: "#00afaf", muted: "#6c6c6c", // Thinking-level tokens — default values from pi's dark theme (used only // before a live pi theme is injected, e.g. tests / cold start). thinkingOff: "#555555", thinkingMinimal: "#6e6e6e", thinkingLow: "#5f87af", thinkingMedium: "#81a2be", thinkingHigh: "#b294bb", thinkingXhigh: "#d183e8", thinkingMax: "#ff5fff", }; let _piTheme: any = null; export function setPiTheme(t: any): void { _piTheme = t; } export function bold(text: string): string { if (_piTheme) return _piTheme.bold(text); return `${BOLD}${text}${RESET}`; } export function dim(text: string): string { if (_piTheme) return _piTheme.fg("dim", text); return `${DIM_CODE}${text}${RESET}`; } export function fg(colorName: string, text: string): string { if (_piTheme) return _piTheme.fg(colorName, text); const hex = FALLBACK_HEX[colorName] ?? "#ffffff"; return `${hexToAnsi(hex)}${text}${RESET}`; } /** * Map a thinking level to pi's theme color token, mirroring pi's * `getThinkingBorderColor`. e.g. "high" -> "thinkingHigh", "xhigh" -> * "thinkingXhigh". Pass the result to `fg()` to reuse pi's own color. */ export function thinkingColorName(level: string): string { return "thinking" + level.charAt(0).toUpperCase() + level.slice(1); } export function formatDuration(ms: number): string { const s = Math.floor(ms / 1000); const m = Math.floor(s / 60); if (m > 0) return `${m}m${s % 60}s`; return `${s}s`; } export function formatRelativeTime(ms: number): string { return `${formatDuration(ms)} ago`; } export function formatTokens(n: number): string { if (n < 1000) return n.toString(); if (n < 1_000_000) return `${Math.round(n / 1000)}k`; return `${(n / 1_000_000).toFixed(1)}M`; } export const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; export function spinnerFrame(): string { return SPINNER_FRAMES[Math.floor(Date.now() / 80) % SPINNER_FRAMES.length]; } /** Frame at an explicit index (wraps). Use with a tick counter for animation. */ export function spinnerFrameAt(i: number): string { return SPINNER_FRAMES[((i % SPINNER_FRAMES.length) + SPINNER_FRAMES.length) % SPINNER_FRAMES.length] ?? SPINNER_FRAMES[0]!; } export function formatDiffStat(added: number, removed: number): string { if (removed > 0) return `+${added} -${removed}`; return `+${added}`; } export function trunc(text: string, max: number): string { if (max <= 0) return ""; if (visibleWidth(text) <= max) return text; let result = ""; let w = 0; for (const ch of text) { const cw = visibleWidth(ch); if (w + cw > max - 1) break; result += ch; w += cw; } return result + "…"; } export function panelHeader(title: string, width: number): string[] { const separatorLen = Math.max(0, width); return [ bold(` ${title}`), dim("─".repeat(separatorLen)), ]; }