chore: initialize recovered claude workspace
This commit is contained in:
308
src/utils/format.ts
Normal file
308
src/utils/format.ts
Normal file
@@ -0,0 +1,308 @@
|
||||
// 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'
|
||||
Reference in New Issue
Block a user