// Pure display formatters — leaf-safe (no Ink). Width-aware truncation lives in ./truncate.ts. import { getRelativeTimeFormat, getTimeZone } from './intl.js' /** * Formats a byte count to a human-readable string (KB, MB, GB). * @example formatFileSize(1536) → "1.5KB" */ export function formatFileSize(sizeInBytes: number): string { const kb = sizeInBytes / 1024 if (kb < 1) { return `${sizeInBytes} bytes` } if (kb < 1024) { return `${kb.toFixed(1).replace(/\.0$/, '')}KB` } const mb = kb / 1024 if (mb < 1024) { return `${mb.toFixed(1).replace(/\.0$/, '')}MB` } const gb = mb / 1024 return `${gb.toFixed(1).replace(/\.0$/, '')}GB` } /** * Formats milliseconds as seconds with 1 decimal place (e.g. `1234` → `"1.2s"`). * Unlike formatDuration, always keeps the decimal — use for sub-minute timings * where the fractional second is meaningful (TTFT, hook durations, etc.). */ export function formatSecondsShort(ms: number): string { return `${(ms / 1000).toFixed(1)}s` } export function formatDuration( ms: number, options?: { hideTrailingZeros?: boolean; mostSignificantOnly?: boolean }, ): string { if (ms < 60000) { // Special case for 0 if (ms === 0) { return '0s' } // For durations < 1s, show 1 decimal place (e.g., 0.5s) if (ms < 1) { const s = (ms / 1000).toFixed(1) return `${s}s` } const s = Math.floor(ms / 1000).toString() return `${s}s` } let days = Math.floor(ms / 86400000) let hours = Math.floor((ms % 86400000) / 3600000) let minutes = Math.floor((ms % 3600000) / 60000) let seconds = Math.round((ms % 60000) / 1000) // Handle rounding carry-over (e.g., 59.5s rounds to 60s) if (seconds === 60) { seconds = 0 minutes++ } if (minutes === 60) { minutes = 0 hours++ } if (hours === 24) { hours = 0 days++ } const hide = options?.hideTrailingZeros if (options?.mostSignificantOnly) { if (days > 0) return `${days}d` if (hours > 0) return `${hours}h` if (minutes > 0) return `${minutes}m` return `${seconds}s` } if (days > 0) { if (hide && hours === 0 && minutes === 0) return `${days}d` if (hide && minutes === 0) return `${days}d ${hours}h` return `${days}d ${hours}h ${minutes}m` } if (hours > 0) { if (hide && minutes === 0 && seconds === 0) return `${hours}h` if (hide && seconds === 0) return `${hours}h ${minutes}m` return `${hours}h ${minutes}m ${seconds}s` } if (minutes > 0) { if (hide && seconds === 0) return `${minutes}m` return `${minutes}m ${seconds}s` } return `${seconds}s` } // `new Intl.NumberFormat` is expensive, so cache formatters for reuse let numberFormatterForConsistentDecimals: Intl.NumberFormat | null = null let numberFormatterForInconsistentDecimals: Intl.NumberFormat | null = null const getNumberFormatter = ( useConsistentDecimals: boolean, ): Intl.NumberFormat => { if (useConsistentDecimals) { if (!numberFormatterForConsistentDecimals) { numberFormatterForConsistentDecimals = new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 1, minimumFractionDigits: 1, }) } return numberFormatterForConsistentDecimals } else { if (!numberFormatterForInconsistentDecimals) { numberFormatterForInconsistentDecimals = new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 1, minimumFractionDigits: 0, }) } return numberFormatterForInconsistentDecimals } } export function formatNumber(number: number): string { // Only use minimumFractionDigits for numbers that will be shown in compact notation const shouldUseConsistentDecimals = number >= 1000 return getNumberFormatter(shouldUseConsistentDecimals) .format(number) // eg. "1321" => "1.3K", "900" => "900" .toLowerCase() // eg. "1.3K" => "1.3k", "1.0K" => "1.0k" } export function formatTokens(count: number): string { return formatNumber(count).replace('.0', '') } type RelativeTimeStyle = 'long' | 'short' | 'narrow' type RelativeTimeOptions = { style?: RelativeTimeStyle numeric?: 'always' | 'auto' } export function formatRelativeTime( date: Date, options: RelativeTimeOptions & { now?: Date } = {}, ): string { const { style = 'narrow', numeric = 'always', now = new Date() } = options const diffInMs = date.getTime() - now.getTime() // Use Math.trunc to truncate towards zero for both positive and negative values const diffInSeconds = Math.trunc(diffInMs / 1000) // Define time intervals with custom short units const intervals = [ { unit: 'year', seconds: 31536000, shortUnit: 'y' }, { unit: 'month', seconds: 2592000, shortUnit: 'mo' }, { unit: 'week', seconds: 604800, shortUnit: 'w' }, { unit: 'day', seconds: 86400, shortUnit: 'd' }, { unit: 'hour', seconds: 3600, shortUnit: 'h' }, { unit: 'minute', seconds: 60, shortUnit: 'm' }, { unit: 'second', seconds: 1, shortUnit: 's' }, ] as const // Find the appropriate unit for (const { unit, seconds: intervalSeconds, shortUnit } of intervals) { if (Math.abs(diffInSeconds) >= intervalSeconds) { const value = Math.trunc(diffInSeconds / intervalSeconds) // For short style, use custom format if (style === 'narrow') { return diffInSeconds < 0 ? `${Math.abs(value)}${shortUnit} ago` : `in ${value}${shortUnit}` } // For days and longer, use long style regardless of the style parameter return getRelativeTimeFormat('long', numeric).format(value, unit) } } // For values less than 1 second if (style === 'narrow') { return diffInSeconds <= 0 ? '0s ago' : 'in 0s' } return getRelativeTimeFormat(style, numeric).format(0, 'second') } export function formatRelativeTimeAgo( date: Date, options: RelativeTimeOptions & { now?: Date } = {}, ): string { const { now = new Date(), ...restOptions } = options if (date > now) { // For future dates, just return the relative time without "ago" return formatRelativeTime(date, { ...restOptions, now }) } // For past dates, force numeric: 'always' to ensure we get "X units ago" return formatRelativeTime(date, { ...restOptions, numeric: 'always', now }) } /** * Formats log metadata for display (time, size or message count, branch, tag, PR) */ export function formatLogMetadata(log: { modified: Date messageCount: number fileSize?: number gitBranch?: string tag?: string agentSetting?: string prNumber?: number prRepository?: string }): string { const sizeOrCount = log.fileSize !== undefined ? formatFileSize(log.fileSize) : `${log.messageCount} messages` const parts = [ formatRelativeTimeAgo(log.modified, { style: 'short' }), ...(log.gitBranch ? [log.gitBranch] : []), sizeOrCount, ] if (log.tag) { parts.push(`#${log.tag}`) } if (log.agentSetting) { parts.push(`@${log.agentSetting}`) } if (log.prNumber) { parts.push( log.prRepository ? `${log.prRepository}#${log.prNumber}` : `#${log.prNumber}`, ) } return parts.join(' · ') } export function formatResetTime( timestampInSeconds: number | undefined, showTimezone: boolean = false, showTime: boolean = true, ): string | undefined { if (!timestampInSeconds) return undefined const date = new Date(timestampInSeconds * 1000) const now = new Date() const minutes = date.getMinutes() // Calculate hours until reset const hoursUntilReset = (date.getTime() - now.getTime()) / (1000 * 60 * 60) // If reset is more than 24 hours away, show the date as well if (hoursUntilReset > 24) { // Show date and time for resets more than a day away const dateOptions: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric', hour: showTime ? 'numeric' : undefined, minute: !showTime || minutes === 0 ? undefined : '2-digit', hour12: showTime ? true : undefined, } // Add year if it's not the current year if (date.getFullYear() !== now.getFullYear()) { dateOptions.year = 'numeric' } const dateString = date.toLocaleString('en-US', dateOptions) // Remove the space before AM/PM and make it lowercase return ( dateString.replace(/ ([AP]M)/i, (_match, ampm) => ampm.toLowerCase()) + (showTimezone ? ` (${getTimeZone()})` : '') ) } // For resets within 24 hours, show just the time (existing behavior) const timeString = date.toLocaleTimeString('en-US', { hour: 'numeric', minute: minutes === 0 ? undefined : '2-digit', hour12: true, }) // Remove the space before AM/PM and make it lowercase, then add timezone return ( timeString.replace(/ ([AP]M)/i, (_match, ampm) => ampm.toLowerCase()) + (showTimezone ? ` (${getTimeZone()})` : '') ) } export function formatResetText( resetsAt: string, showTimezone: boolean = false, showTime: boolean = true, ): string { const dt = new Date(resetsAt) return `${formatResetTime(Math.floor(dt.getTime() / 1000), showTimezone, showTime)}` } // Back-compat: truncate helpers moved to ./truncate.ts (needs ink/stringWidth) export { truncate, truncatePathMiddle, truncateStartToWidth, truncateToWidth, truncateToWidthNoEllipsis, wrapText, } from './truncate.js'