const SEC = 1_000; const MIN = 60 * SEC; const HOUR = 60 * MIN; const DAY = 24 * HOUR; /** * Format a duration in milliseconds to a short human-readable string. * Examples: "123ms", "1.5s", "30m15s", "2h30m", "3d2h" */ export function formatDuration(ms: number): string { if (ms < SEC) return `${ms}ms`; // Truncate below 60.0s instead of rounding up into the next unit (the minute // branch below), mirroring roundBelow/formatByteUnit: e.g. 59_999ms -> "59.9s". if (ms < MIN) return `${(roundBelow(ms / 100, 600) / 10).toFixed(1)}s`; if (ms < HOUR) { const mins = Math.floor(ms / MIN); const secs = Math.floor((ms % MIN) / SEC); return secs > 0 ? `${mins}m${secs}s` : `${mins}m`; } if (ms < DAY) { const hours = Math.floor(ms / HOUR); const mins = Math.floor((ms % HOUR) / MIN); return mins > 0 ? `${hours}h${mins}m` : `${hours}h`; } const days = Math.floor(ms / DAY); const hours = Math.floor((ms % DAY) / HOUR); return hours > 0 ? `${days}d${hours}h` : `${days}d`; } /** * Format a number with K/M/B suffix for compact display. * Uses 1 decimal for small leading digits when non-zero, rounded otherwise. * Examples: "999", "1K", "1.5K", "25K", "1M", "1.5M", "25M", "1.5B" */ export function formatNumber(n: number): string { if (n < 1_000) return n.toString(); if (n < 10_000) return `${trim1(n / 1_000)}K`; if (n < 1_000_000) return `${roundBelow(n / 1_000, 1_000)}K`; if (n < 10_000_000) return `${trim1(n / 1_000_000)}M`; if (n < 1_000_000_000) return `${roundBelow(n / 1_000_000, 1_000)}M`; if (n < 10_000_000_000) return `${trim1(n / 1_000_000_000)}B`; return `${Math.round(n / 1_000_000_000)}B`; } /** Format with up to 1 decimal place, dropping trailing `.0`. */ function trim1(n: number): string { const s = n.toFixed(1); return s.endsWith(".0") ? s.slice(0, -2) : s; } /** Round to an integer without crossing the next compact suffix boundary. */ function roundBelow(n: number, nextUnit: number): number { return Math.min(Math.round(n), nextUnit - 1); } /** * Format a byte count to a human-readable string. * Examples: "512B", "1.5KB", "2.3MB", "1.2GB" */ export function formatBytes(bytes: number): string { if (bytes < 1024) return `${bytes}B`; if (bytes < 1024 * 1024) return `${formatByteUnit(bytes, 1024)}KB`; if (bytes < 1024 * 1024 * 1024) return `${formatByteUnit(bytes, 1024 * 1024)}MB`; // The GB branch is terminal (no larger unit), so it must not clamp below the // next-unit boundary the way KB/MB do; clamping here reports every value >= // 1 TiB as "1023.9GB" (e.g. 2 TiB -> "1023.9GB" instead of "2048.0GB"). return `${formatByteUnit(bytes, 1024 * 1024 * 1024, false)}GB`; } /** * Format bytes to 1 decimal. Intermediate units (KB/MB) clamp just below the * next-unit boundary so a value like 1023.95 KB never rounds up to "1024.0KB"; * the terminal GB unit passes `clampToNextUnit: false` because it has no next * unit to protect against. */ function formatByteUnit(bytes: number, unit: number, clampToNextUnit = true): string { const tenths = Math.round((bytes / unit) * 10); return ((clampToNextUnit ? Math.min(tenths, 1024 * 10 - 1) : tenths) / 10).toFixed(1); } /** * Truncate a string to maxLen characters, appending an ellipsis if truncated. * For display-width-aware truncation (terminals), use truncateToWidth from @gajae-code/tui. */ export function truncate(str: string, maxLen: number, ellipsis = "…"): string { if (str.length <= maxLen) return str; const sliceLen = Math.max(0, maxLen - ellipsis.length); return `${str.slice(0, sliceLen)}${ellipsis}`; } /** * Format count with pluralized label (e.g., "3 files", "1 error"). */ export function formatCount(label: string, count: number): string { const safeCount = Number.isFinite(count) ? count : 0; return `${safeCount} ${pluralize(label, safeCount)}`; } /** * Format age from seconds to human-readable string. */ export function formatAge(ageSeconds: number | null | undefined): string { if (!ageSeconds) return ""; const mins = Math.floor(ageSeconds / 60); const hours = Math.floor(mins / 60); const days = Math.floor(hours / 24); const weeks = Math.floor(days / 7); const months = Math.floor(days / 30); if (months > 0) return `${months}mo ago`; if (weeks > 0) return `${weeks}w ago`; if (days > 0) return `${days}d ago`; if (hours > 0) return `${hours}h ago`; if (mins > 0) return `${mins}m ago`; return "just now"; } /** * Pluralize a label based on the count. */ export function pluralize(label: string, count: number): string { if (count === 1) return label; if (/(?:ch|sh|s|x|z)$/i.test(label)) return `${label}es`; if (/[^aeiou]y$/i.test(label)) return `${label.slice(0, -1)}ies`; return `${label}s`; } /** * Format a ratio as a percentage. */ export function formatPercent(ratio: number): string { return `${(ratio * 100).toFixed(1)}%`; }