format.ts 9.2 KB

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