source dump of claude code
at main 308 lines 9.4 kB view raw
1// Pure display formatters — leaf-safe (no Ink). Width-aware truncation lives in ./truncate.ts. 2 3import { getRelativeTimeFormat, getTimeZone } from './intl.js' 4 5/** 6 * Formats a byte count to a human-readable string (KB, MB, GB). 7 * @example formatFileSize(1536) → "1.5KB" 8 */ 9export function formatFileSize(sizeInBytes: number): string { 10 const kb = sizeInBytes / 1024 11 if (kb < 1) { 12 return `${sizeInBytes} bytes` 13 } 14 if (kb < 1024) { 15 return `${kb.toFixed(1).replace(/\.0$/, '')}KB` 16 } 17 const mb = kb / 1024 18 if (mb < 1024) { 19 return `${mb.toFixed(1).replace(/\.0$/, '')}MB` 20 } 21 const gb = mb / 1024 22 return `${gb.toFixed(1).replace(/\.0$/, '')}GB` 23} 24 25/** 26 * Formats milliseconds as seconds with 1 decimal place (e.g. `1234` → `"1.2s"`). 27 * Unlike formatDuration, always keeps the decimal — use for sub-minute timings 28 * where the fractional second is meaningful (TTFT, hook durations, etc.). 29 */ 30export function formatSecondsShort(ms: number): string { 31 return `${(ms / 1000).toFixed(1)}s` 32} 33 34export function formatDuration( 35 ms: number, 36 options?: { hideTrailingZeros?: boolean; mostSignificantOnly?: boolean }, 37): string { 38 if (ms < 60000) { 39 // Special case for 0 40 if (ms === 0) { 41 return '0s' 42 } 43 // For durations < 1s, show 1 decimal place (e.g., 0.5s) 44 if (ms < 1) { 45 const s = (ms / 1000).toFixed(1) 46 return `${s}s` 47 } 48 const s = Math.floor(ms / 1000).toString() 49 return `${s}s` 50 } 51 52 let days = Math.floor(ms / 86400000) 53 let hours = Math.floor((ms % 86400000) / 3600000) 54 let minutes = Math.floor((ms % 3600000) / 60000) 55 let seconds = Math.round((ms % 60000) / 1000) 56 57 // Handle rounding carry-over (e.g., 59.5s rounds to 60s) 58 if (seconds === 60) { 59 seconds = 0 60 minutes++ 61 } 62 if (minutes === 60) { 63 minutes = 0 64 hours++ 65 } 66 if (hours === 24) { 67 hours = 0 68 days++ 69 } 70 71 const hide = options?.hideTrailingZeros 72 73 if (options?.mostSignificantOnly) { 74 if (days > 0) return `${days}d` 75 if (hours > 0) return `${hours}h` 76 if (minutes > 0) return `${minutes}m` 77 return `${seconds}s` 78 } 79 80 if (days > 0) { 81 if (hide && hours === 0 && minutes === 0) return `${days}d` 82 if (hide && minutes === 0) return `${days}d ${hours}h` 83 return `${days}d ${hours}h ${minutes}m` 84 } 85 if (hours > 0) { 86 if (hide && minutes === 0 && seconds === 0) return `${hours}h` 87 if (hide && seconds === 0) return `${hours}h ${minutes}m` 88 return `${hours}h ${minutes}m ${seconds}s` 89 } 90 if (minutes > 0) { 91 if (hide && seconds === 0) return `${minutes}m` 92 return `${minutes}m ${seconds}s` 93 } 94 return `${seconds}s` 95} 96 97// `new Intl.NumberFormat` is expensive, so cache formatters for reuse 98let numberFormatterForConsistentDecimals: Intl.NumberFormat | null = null 99let numberFormatterForInconsistentDecimals: Intl.NumberFormat | null = null 100const getNumberFormatter = ( 101 useConsistentDecimals: boolean, 102): Intl.NumberFormat => { 103 if (useConsistentDecimals) { 104 if (!numberFormatterForConsistentDecimals) { 105 numberFormatterForConsistentDecimals = new Intl.NumberFormat('en-US', { 106 notation: 'compact', 107 maximumFractionDigits: 1, 108 minimumFractionDigits: 1, 109 }) 110 } 111 return numberFormatterForConsistentDecimals 112 } else { 113 if (!numberFormatterForInconsistentDecimals) { 114 numberFormatterForInconsistentDecimals = new Intl.NumberFormat('en-US', { 115 notation: 'compact', 116 maximumFractionDigits: 1, 117 minimumFractionDigits: 0, 118 }) 119 } 120 return numberFormatterForInconsistentDecimals 121 } 122} 123 124export function formatNumber(number: number): string { 125 // Only use minimumFractionDigits for numbers that will be shown in compact notation 126 const shouldUseConsistentDecimals = number >= 1000 127 128 return getNumberFormatter(shouldUseConsistentDecimals) 129 .format(number) // eg. "1321" => "1.3K", "900" => "900" 130 .toLowerCase() // eg. "1.3K" => "1.3k", "1.0K" => "1.0k" 131} 132 133export function formatTokens(count: number): string { 134 return formatNumber(count).replace('.0', '') 135} 136 137type RelativeTimeStyle = 'long' | 'short' | 'narrow' 138 139type RelativeTimeOptions = { 140 style?: RelativeTimeStyle 141 numeric?: 'always' | 'auto' 142} 143 144export function formatRelativeTime( 145 date: Date, 146 options: RelativeTimeOptions & { now?: Date } = {}, 147): string { 148 const { style = 'narrow', numeric = 'always', now = new Date() } = options 149 const diffInMs = date.getTime() - now.getTime() 150 // Use Math.trunc to truncate towards zero for both positive and negative values 151 const diffInSeconds = Math.trunc(diffInMs / 1000) 152 153 // Define time intervals with custom short units 154 const intervals = [ 155 { unit: 'year', seconds: 31536000, shortUnit: 'y' }, 156 { unit: 'month', seconds: 2592000, shortUnit: 'mo' }, 157 { unit: 'week', seconds: 604800, shortUnit: 'w' }, 158 { unit: 'day', seconds: 86400, shortUnit: 'd' }, 159 { unit: 'hour', seconds: 3600, shortUnit: 'h' }, 160 { unit: 'minute', seconds: 60, shortUnit: 'm' }, 161 { unit: 'second', seconds: 1, shortUnit: 's' }, 162 ] as const 163 164 // Find the appropriate unit 165 for (const { unit, seconds: intervalSeconds, shortUnit } of intervals) { 166 if (Math.abs(diffInSeconds) >= intervalSeconds) { 167 const value = Math.trunc(diffInSeconds / intervalSeconds) 168 // For short style, use custom format 169 if (style === 'narrow') { 170 return diffInSeconds < 0 171 ? `${Math.abs(value)}${shortUnit} ago` 172 : `in ${value}${shortUnit}` 173 } 174 // For days and longer, use long style regardless of the style parameter 175 return getRelativeTimeFormat('long', numeric).format(value, unit) 176 } 177 } 178 179 // For values less than 1 second 180 if (style === 'narrow') { 181 return diffInSeconds <= 0 ? '0s ago' : 'in 0s' 182 } 183 return getRelativeTimeFormat(style, numeric).format(0, 'second') 184} 185 186export function formatRelativeTimeAgo( 187 date: Date, 188 options: RelativeTimeOptions & { now?: Date } = {}, 189): string { 190 const { now = new Date(), ...restOptions } = options 191 if (date > now) { 192 // For future dates, just return the relative time without "ago" 193 return formatRelativeTime(date, { ...restOptions, now }) 194 } 195 196 // For past dates, force numeric: 'always' to ensure we get "X units ago" 197 return formatRelativeTime(date, { ...restOptions, numeric: 'always', now }) 198} 199 200/** 201 * Formats log metadata for display (time, size or message count, branch, tag, PR) 202 */ 203export function formatLogMetadata(log: { 204 modified: Date 205 messageCount: number 206 fileSize?: number 207 gitBranch?: string 208 tag?: string 209 agentSetting?: string 210 prNumber?: number 211 prRepository?: string 212}): string { 213 const sizeOrCount = 214 log.fileSize !== undefined 215 ? formatFileSize(log.fileSize) 216 : `${log.messageCount} messages` 217 const parts = [ 218 formatRelativeTimeAgo(log.modified, { style: 'short' }), 219 ...(log.gitBranch ? [log.gitBranch] : []), 220 sizeOrCount, 221 ] 222 if (log.tag) { 223 parts.push(`#${log.tag}`) 224 } 225 if (log.agentSetting) { 226 parts.push(`@${log.agentSetting}`) 227 } 228 if (log.prNumber) { 229 parts.push( 230 log.prRepository 231 ? `${log.prRepository}#${log.prNumber}` 232 : `#${log.prNumber}`, 233 ) 234 } 235 return parts.join(' · ') 236} 237 238export function formatResetTime( 239 timestampInSeconds: number | undefined, 240 showTimezone: boolean = false, 241 showTime: boolean = true, 242): string | undefined { 243 if (!timestampInSeconds) return undefined 244 245 const date = new Date(timestampInSeconds * 1000) 246 const now = new Date() 247 const minutes = date.getMinutes() 248 249 // Calculate hours until reset 250 const hoursUntilReset = (date.getTime() - now.getTime()) / (1000 * 60 * 60) 251 252 // If reset is more than 24 hours away, show the date as well 253 if (hoursUntilReset > 24) { 254 // Show date and time for resets more than a day away 255 const dateOptions: Intl.DateTimeFormatOptions = { 256 month: 'short', 257 day: 'numeric', 258 hour: showTime ? 'numeric' : undefined, 259 minute: !showTime || minutes === 0 ? undefined : '2-digit', 260 hour12: showTime ? true : undefined, 261 } 262 263 // Add year if it's not the current year 264 if (date.getFullYear() !== now.getFullYear()) { 265 dateOptions.year = 'numeric' 266 } 267 268 const dateString = date.toLocaleString('en-US', dateOptions) 269 270 // Remove the space before AM/PM and make it lowercase 271 return ( 272 dateString.replace(/ ([AP]M)/i, (_match, ampm) => ampm.toLowerCase()) + 273 (showTimezone ? ` (${getTimeZone()})` : '') 274 ) 275 } 276 277 // For resets within 24 hours, show just the time (existing behavior) 278 const timeString = date.toLocaleTimeString('en-US', { 279 hour: 'numeric', 280 minute: minutes === 0 ? undefined : '2-digit', 281 hour12: true, 282 }) 283 284 // Remove the space before AM/PM and make it lowercase, then add timezone 285 return ( 286 timeString.replace(/ ([AP]M)/i, (_match, ampm) => ampm.toLowerCase()) + 287 (showTimezone ? ` (${getTimeZone()})` : '') 288 ) 289} 290 291export function formatResetText( 292 resetsAt: string, 293 showTimezone: boolean = false, 294 showTime: boolean = true, 295): string { 296 const dt = new Date(resetsAt) 297 return `${formatResetTime(Math.floor(dt.getTime() / 1000), showTimezone, showTime)}` 298} 299 300// Back-compat: truncate helpers moved to ./truncate.ts (needs ink/stringWidth) 301export { 302 truncate, 303 truncatePathMiddle, 304 truncateStartToWidth, 305 truncateToWidth, 306 truncateToWidthNoEllipsis, 307 wrapText, 308} from './truncate.js'