/ src / utils / format.ts
format.ts
  1  // Pure display formatters — leaf-safe (no Ink). Width-aware truncation lives in ./truncate.ts.
  2  
  3  import { 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   */
  9  export 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   */
 30  export function formatSecondsShort(ms: number): string {
 31    return `${(ms / 1000).toFixed(1)}s`
 32  }
 33  
 34  export 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
 98  let numberFormatterForConsistentDecimals: Intl.NumberFormat | null = null
 99  let numberFormatterForInconsistentDecimals: Intl.NumberFormat | null = null
100  const 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  
124  export 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  
133  export function formatTokens(count: number): string {
134    return formatNumber(count).replace('.0', '')
135  }
136  
137  type RelativeTimeStyle = 'long' | 'short' | 'narrow'
138  
139  type RelativeTimeOptions = {
140    style?: RelativeTimeStyle
141    numeric?: 'always' | 'auto'
142  }
143  
144  export 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  
186  export 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   */
203  export 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  
238  export 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  
291  export 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)
301  export {
302    truncate,
303    truncatePathMiddle,
304    truncateStartToWidth,
305    truncateToWidth,
306    truncateToWidthNoEllipsis,
307    wrapText,
308  } from './truncate.js'