| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308 |
- // 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'
|