interface TokenBreakdown { input: number; output: number; cacheCreation: number; cacheRead: number; } /** Local calendar date as `YYYY-MM-DD`, the key the usage caches bucket on. */ export function formatLocalDate(date: Date): string { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, "0"); const day = String(date.getDate()).padStart(2, "0"); return `${year}-${month}-${day}`; } export function formatCost(cost: number | null): string { if (cost === null) return "$0.00"; if (cost < 0.01) return "<$0.01"; return `$${cost.toFixed(2)}`; } export function formatTokens(tokens: number | null): string { if (tokens === null) return "0 tokens"; if (tokens === 0) return "0 tokens"; // Compare against the rounding threshold so 999_950 becomes "1.0M", not "1000.0K" if (tokens >= 999_950) { return `${(tokens / 1_000_000).toFixed(1)}M tokens`; } else if (tokens >= 1_000) { return `${(tokens / 1_000).toFixed(1)}K tokens`; } return `${tokens} tokens`; } export function formatTokenBreakdown(breakdown: TokenBreakdown | null): string { if (!breakdown) return "0 tokens"; const parts: string[] = []; if (breakdown.input > 0) { parts.push(`${formatTokenCount(breakdown.input)} in`); } if (breakdown.output > 0) { parts.push(`${formatTokenCount(breakdown.output)} out`); } if (breakdown.cacheCreation > 0 || breakdown.cacheRead > 0) { const totalCached = breakdown.cacheCreation + breakdown.cacheRead; parts.push(`${formatTokenCount(totalCached)} cached`); } return parts.length > 0 ? parts.join(" + ") : "0 tokens"; } export function formatTimeSince(seconds: number): string { if (seconds < 60) return `${seconds}s`; if (seconds < 3600) return `${Math.floor(seconds / 60)}m`; if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`; if (seconds < 604800) return `${Math.floor(seconds / 86400)}d`; return `${Math.floor(seconds / 604800)}w`; } export function formatDuration(seconds: number): string { // Thresholds account for rounding so 3599s becomes "1.0h", not "60m" if (seconds < 59.5) { return `${seconds.toFixed(0)}s`; } else if (seconds < 3570) { return `${(seconds / 60).toFixed(0)}m`; } else if (seconds < 86220) { return `${(seconds / 3600).toFixed(1)}h`; } else { return `${(seconds / 86400).toFixed(1)}d`; } } const CLAUDE_MODEL_PATTERN = /^(?:(?:global|apac|au|eu|us|us-east-\d|us-west-\d|eu-west-\d|eu-central-\d)\.)?(?:anthropic\.|azure_ai\/|bedrock\/|vertex_ai\/)?claude-(?:(?opus|sonnet|haiku|fable|mythos)-(?\d+)(?:-(?\d))?|(?\d+)(?:-(?\d))?-(?opus|sonnet|haiku|fable|mythos))(?:[-@]\d{8})?(?:-v\d+:\d+)?(?:-latest)?$/i; export function formatModelName(rawName: string): string { if (!rawName) { return "Claude"; } const match = rawName.trim().match(CLAUDE_MODEL_PATTERN); if (!match?.groups) { return rawName; } const { family, newMajor, newMinor, oldMajor, oldMinor, oldFamily } = match.groups; const modelFamily = family || oldFamily; const major = newMajor || oldMajor; const minor = newMinor || oldMinor; if (modelFamily && major) { const capitalizedFamily = modelFamily.charAt(0).toUpperCase() + modelFamily.slice(1).toLowerCase(); const version = minor ? `${major}.${minor}` : major; return `${capitalizedFamily} ${version}`; } return rawName; } export function abbreviateFishStyle(dirPath: string): string { const sep = dirPath.includes("/") ? "/" : "\\"; const parts = dirPath.split(sep); return parts .map((part, index) => { if (index === parts.length - 1) { return part; } if (part === "~" || part === "") { return part; } return part.charAt(0); }) .join(sep); } export function formatResponseTime(seconds: number): string { // Pick the unit from the rounded value, so 59.97s reads "1.0m" and not "60.0s" const secs = Math.round(seconds * 10) / 10; if (secs < 60) { return `${secs.toFixed(1)}s`; } const mins = Math.round(seconds / 6) / 10; if (mins < 60) { return `${mins.toFixed(1)}m`; } return `${(seconds / 3600).toFixed(1)}h`; } export function formatTokenCount(tokens: number | null): string { return formatTokens(tokens).replace(" tokens", ""); } export function formatBurnRate(rate: number | null | undefined): string { if (rate === null || rate === undefined || rate <= 0) return ""; return rate < 1 ? `${(rate * 100).toFixed(0)}c/h` : `$${rate.toFixed(2)}/h`; } function isSeparator(char: string | undefined): boolean { return char === "/" || char === "\\"; } /** * The part of `dirPath` below `baseDir`: "" when they are the same directory, * otherwise a path starting with a separator. Null when `dirPath` is outside * `baseDir`. Matching whole segments keeps `/home/al` from claiming * `/home/alex`, and ignoring the base's trailing separators makes `/home/al/` * behave like `/home/al`. A root base such as `/` keeps its separator, so it * only matches itself. */ export function pathBelow(dirPath: string, baseDir: string): string | null { let end = baseDir.length; while (end > 1 && isSeparator(baseDir[end - 1])) end--; const base = baseDir.slice(0, end); if (!dirPath.startsWith(base)) return null; const rest = dirPath.slice(base.length); return rest === "" || isSeparator(rest[0]) ? rest : null; } export function collapseHome(dirPath: string, homeDir?: string): string { const home = homeDir ?? globalThis.process?.env?.HOME ?? globalThis.process?.env?.USERPROFILE; const rest = home ? pathBelow(dirPath, home) : null; return rest === null ? dirPath : `~${rest}`; } export function formatTimeRemaining(totalMinutes: number): string { const hours = Math.floor(totalMinutes / 60); const minutes = totalMinutes % 60; return hours > 0 ? `${hours}h ${minutes}m left` : `${minutes}m left`; } export function formatLongTimeRemaining(totalMinutes: number): string { if (totalMinutes >= 1440) { const days = Math.floor(totalMinutes / 1440); const hours = Math.floor((totalMinutes % 1440) / 60); return hours > 0 ? `${days}d ${hours}h` : `${days}d`; } else if (totalMinutes >= 60) { const hours = Math.floor(totalMinutes / 60); const minutes = totalMinutes % 60; return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`; } return `${totalMinutes}m`; } export function minutesUntilReset(epochSeconds: number): number { return Math.round(Math.max(0, epochSeconds * 1000 - Date.now()) / 60000); } export const BLOCK_WINDOW_MINUTES = 5 * 60; export const WEEKLY_WINDOW_MINUTES = 7 * 24 * 60; /** Usage percentage an even spend over the window would have reached by now. */ export function pacePercentage( minutesRemaining: number, windowMinutes: number, ): number { return Math.round(Math.max(0, 1 - minutesRemaining / windowMinutes) * 100); } /** * Filled cells for a `length`-cell bar. Rate-limit usage can pass 100%, and * an out-of-range count would make `String.repeat` throw. */ export function filledCells(pct: number, length: number): number { if (!Number.isFinite(pct)) return 0; return Math.max(0, Math.min(length, Math.round((pct / 100) * length))); } export function formatCacheTimerElapsed(seconds: number): string { if (seconds >= 3600) return "1h+"; if (seconds >= 300) return `${Math.floor(seconds / 60)}m`; const m = Math.floor(seconds / 60); const s = Math.floor(seconds % 60); return `${m}:${s.toString().padStart(2, "0")}`; } export function formatCacheTimerRemaining(remainingSeconds: number): string { if (remainingSeconds <= 0) return "cold"; const m = Math.floor(remainingSeconds / 60); const s = Math.floor(remainingSeconds % 60); return `${m}:${s.toString().padStart(2, "0")}`; }