Files
steipete--codexbar/Sources/CodexBarCore/UsageFormatter.swift
T
2026-07-13 12:22:33 +08:00

441 lines
18 KiB
Swift

import Foundation
public enum ResetTimeDisplayStyle: String, Codable, Sendable {
case countdown
case absolute
}
public enum UsageFormatter {
private final class BundleToken {}
private static let localizationLock = NSLock()
private nonisolated(unsafe) static var localizationProvider: (@Sendable (String) -> String)?
private nonisolated(unsafe) static var localeProvider: (@Sendable () -> Locale)?
public static func setLocalizationProvider(_ provider: @escaping @Sendable (String) -> String) {
self.localizationLock.lock()
self.localizationProvider = provider
self.localizationLock.unlock()
}
public static func clearLocalizationProvider() {
self.localizationLock.lock()
self.localizationProvider = nil
self.localizationLock.unlock()
}
public static func setLocaleProvider(_ provider: @escaping @Sendable () -> Locale) {
self.localizationLock.lock()
self.localeProvider = provider
self.localizationLock.unlock()
}
public static func clearLocaleProvider() {
self.localizationLock.lock()
self.localeProvider = nil
self.localizationLock.unlock()
}
private static func currentLocale() -> Locale {
self.localizationLock.lock()
let provider = self.localeProvider
self.localizationLock.unlock()
return provider?() ?? Locale(identifier: "en_US_POSIX")
}
private static func localized(_ key: String) -> String {
self.localizationLock.lock()
let provider = self.localizationProvider
self.localizationLock.unlock()
if let provider {
return provider(key)
}
#if canImport(ObjectiveC)
// Bundle(for:) requires Objective-C bundle introspection. Linux uses the English
// fallback below; app localization is injected through localizationProvider.
let coreBundle = Bundle(for: BundleToken.self)
let coreValue = NSLocalizedString(key, tableName: "Localizable", bundle: coreBundle, value: key, comment: "")
if coreValue != key { return coreValue }
let mainValue = NSLocalizedString(key, tableName: "Localizable", bundle: .main, value: key, comment: "")
if mainValue != key { return mainValue }
#endif
switch key {
case "Updated relative %@": return "Updated %@"
case "Updated absolute %@": return "Updated %@"
case "usage_percent_suffix_left": return "left"
case "usage_percent_suffix_used": return "used"
case "reset_tomorrow_format": return "tomorrow, %@"
case "byte_unit_byte": return "byte"
case "byte_unit_bytes": return "bytes"
case "byte_unit_kilobyte": return "kilobyte"
case "byte_unit_kilobytes": return "kilobytes"
case "byte_unit_megabyte": return "megabyte"
case "byte_unit_megabytes": return "megabytes"
case "byte_unit_gigabyte": return "gigabyte"
case "byte_unit_gigabytes": return "gigabytes"
default: return key
}
}
private static func localized(_ key: String, _ args: CVarArg...) -> String {
let format = self.localized(key)
return String(format: format, locale: self.currentLocale(), arguments: args)
}
public static func percentText(_ percent: Double, suffix: String) -> String {
let clamped = min(100, max(0, percent))
if clamped > 0, clamped < 1 {
return self.localized("<1%% %@", suffix)
}
return self.localized("%.0f%% %@", clamped, suffix)
}
public static func usageLine(remaining: Double, used: Double, showUsed: Bool) -> String {
let percent = showUsed ? used : remaining
let suffix = showUsed
? self.localized("usage_percent_suffix_used")
: self.localized("usage_percent_suffix_left")
return self.percentText(percent, suffix: suffix)
}
public static func percentString(_ percent: Double) -> String {
let clamped = min(100, max(0, percent))
if clamped > 0, clamped < 1 { return "<1%" }
return String(format: "%.0f%%", clamped)
}
public static func resetCountdownDescription(from date: Date, now: Date = .init()) -> String {
let seconds = max(0, date.timeIntervalSince(now))
if seconds < 1 { return "now" }
let totalMinutes = max(1, Int(ceil(seconds / 60.0)))
let days = totalMinutes / (24 * 60)
let hours = (totalMinutes / 60) % 24
let minutes = totalMinutes % 60
if days > 0 {
if hours > 0 { return "in \(days)d \(hours)h" }
if minutes > 0 { return "in \(days)d \(minutes)m" }
return "in \(days)d"
}
if hours > 0 {
if minutes > 0 { return "in \(hours)h \(minutes)m" }
return "in \(hours)h"
}
return "in \(totalMinutes)m"
}
public static func resetDescription(from date: Date, now: Date = .init()) -> String {
// Human-friendly phrasing: today / tomorrow / date+time.
let calendar = Calendar.current
if calendar.isDate(date, inSameDayAs: now) {
return date.formatted(.dateTime.hour().minute().locale(self.currentLocale()))
}
if let tomorrow = calendar.date(byAdding: .day, value: 1, to: now),
calendar.isDate(date, inSameDayAs: tomorrow)
{
let timeStr = date.formatted(.dateTime.hour().minute().locale(self.currentLocale()))
return self.localized("reset_tomorrow_format", timeStr)
}
return date.formatted(.dateTime.month(.abbreviated).day().hour().minute().locale(self.currentLocale()))
}
public static func resetLine(
for window: RateWindow,
style: ResetTimeDisplayStyle,
now: Date = .init()) -> String?
{
if let date = window.resetsAt {
if style == .countdown {
let countdown = self.resetCountdownDescription(from: date, now: now)
if countdown == "now" {
return self.localized("Resets now")
}
if countdown.hasPrefix("in ") {
return self.localized("Resets in %@", String(countdown.dropFirst(3)))
}
return self.localized("Resets %@", countdown)
}
let text = self.resetDescription(from: date, now: now)
return self.localized("Resets %@", text)
}
if let desc = window.resetDescription {
let trimmed = desc.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
if trimmed.lowercased().hasPrefix("resets in ") {
return self.localized("Resets in %@", String(trimmed.dropFirst("Resets in ".count)))
}
if trimmed.lowercased().hasPrefix("resets ") {
return self.localized("Resets %@", String(trimmed.dropFirst("Resets ".count)))
}
return self.localized("Resets %@", trimmed)
}
return nil
}
public static func updatedString(from date: Date, now: Date = .init()) -> String {
let delta = now.timeIntervalSince(date)
if abs(delta) < 60 {
return self.localized("Updated just now")
}
if let hours = Calendar.current.dateComponents([.hour], from: date, to: now).hour, hours < 24 {
#if os(macOS)
let rel = RelativeDateTimeFormatter()
rel.locale = self.currentLocale()
rel.unitsStyle = .abbreviated
return self.localized("Updated relative %@", rel.localizedString(for: date, relativeTo: now))
#else
let seconds = max(0, Int(now.timeIntervalSince(date)))
if seconds < 3600 {
let minutes = max(1, seconds / 60)
return self.localized("Updated %@m ago", String(minutes))
}
let wholeHours = max(1, seconds / 3600)
return self.localized("Updated %@h ago", String(wholeHours))
#endif
} else {
return self.localized(
"Updated absolute %@",
date.formatted(.dateTime.hour().minute().locale(self.currentLocale())))
}
}
public static func creditsString(from value: Double) -> String {
self.localized("%@ left", self.creditsNumberString(from: value))
}
public static func creditsNumberString(from value: Double) -> String {
let number = NumberFormatter()
number.numberStyle = .decimal
number.maximumFractionDigits = 2
// Use explicit locale for consistent formatting on all systems
number.locale = Locale(identifier: "en_US_POSIX")
return number.string(from: NSNumber(value: value)) ?? String(format: "%.2f", value)
}
public static func kiroCreditNumber(_ value: Double) -> String {
let rounded = value.rounded()
if abs(value - rounded) < 0.005 {
return String(format: "%.0f", rounded)
}
return String(format: "%.2f", value)
}
/// Formats a USD value with proper negative handling and thousand separators.
/// Uses Swift's modern FormatStyle API (iOS 15+/macOS 12+) for robust, locale-aware formatting.
public static func usdString(_ value: Double) -> String {
value.formatted(.currency(code: "USD").locale(Locale(identifier: "en_US")))
}
public static let costEstimateHint = "Estimated from local logs · may differ from your bill"
public static func costEstimateHint(provider: UsageProvider) -> String {
switch provider {
case .claude:
"Estimated from local Claude logs at API rates; token totals include cache read/write tokens " +
"and may differ from Claude Code /status."
default:
self.costEstimateHint
}
}
/// Formats a currency value with the specified currency code.
/// Uses FormatStyle with explicit en_US locale to ensure consistent formatting
/// regardless of the user's system locale (e.g., pt-BR users see $54.72 not US$ 54,72).
public static func currencyString(_ value: Double, currencyCode: String) -> String {
value.formatted(.currency(code: currencyCode).locale(Locale(identifier: "en_US")))
}
public static func compactCurrencyString(_ value: Double, currencyCode: String) -> String {
if value != 0, abs(value) < 1 {
return self.currencyString(value, currencyCode: currencyCode)
}
return value.formatted(
.currency(code: currencyCode)
.precision(.fractionLength(0))
.locale(Locale(identifier: "en_US")))
}
public static func tokenCountString(_ value: Int) -> String {
let absValue = abs(value)
let sign = value < 0 ? "-" : ""
let units: [(threshold: Int, divisor: Double, suffix: String)] = [
(1_000_000_000, 1_000_000_000, "B"),
(1_000_000, 1_000_000, "M"),
(1000, 1000, "K"),
]
for unit in units where absValue >= unit.threshold {
let scaled = Double(absValue) / unit.divisor
let formatted: String
if scaled >= 10 {
formatted = String(format: "%.0f", scaled)
} else {
var s = String(format: "%.1f", scaled)
if s.hasSuffix(".0") { s.removeLast(2) }
formatted = s
}
return "\(sign)\(formatted)\(unit.suffix)"
}
return "\(value)"
}
public static func byteCountString(_ bytes: Int64) -> String {
let sign = bytes < 0 ? "-" : ""
let absBytes = Double(bytes.magnitude)
let units: [(threshold: Double, divisor: Double, suffix: String)] = [
(1024 * 1024 * 1024, 1024 * 1024 * 1024, "GB"),
(1024 * 1024, 1024 * 1024, "MB"),
(1024, 1024, "KB"),
]
for unit in units where absBytes >= unit.threshold {
let scaled = absBytes / unit.divisor
let format = scaled >= 10 || scaled.rounded(.towardZero) == scaled ? "%.0f" : "%.1f"
let formatted = String(format: format, scaled)
return "\(sign)\(formatted) \(unit.suffix)"
}
return "\(bytes) B"
}
/// Same magnitudes as `byteCountString`, but spelled out ("megabytes" instead of "MB").
public static func byteCountStringLong(_ bytes: Int64) -> String {
let sign = bytes < 0 ? "-" : ""
let absBytes = Double(bytes.magnitude)
let units: [(threshold: Double, divisor: Double, singularKey: String, pluralKey: String)] = [
(1024 * 1024 * 1024, 1024 * 1024 * 1024, "byte_unit_gigabyte", "byte_unit_gigabytes"),
(1024 * 1024, 1024 * 1024, "byte_unit_megabyte", "byte_unit_megabytes"),
(1024, 1024, "byte_unit_kilobyte", "byte_unit_kilobytes"),
]
for unit in units where absBytes >= unit.threshold {
let scaled = absBytes / unit.divisor
let format = scaled >= 10 || scaled.rounded(.towardZero) == scaled ? "%.0f" : "%.1f"
let formatted = String(format: format, locale: self.currentLocale(), scaled)
let displayScale = format == "%.0f" ? 1.0 : 10.0
let displayedValue = (scaled * displayScale).rounded() / displayScale
let word = self.localized(displayedValue == 1 ? unit.singularKey : unit.pluralKey)
return "\(sign)\(formatted) \(word)"
}
let word = self.localized(bytes.magnitude == 1 ? "byte_unit_byte" : "byte_unit_bytes")
return "\(bytes) \(word)"
}
public static func creditEventSummary(_ event: CreditEvent) -> String {
let formatter = DateFormatter()
formatter.dateStyle = .medium
let number = NumberFormatter()
number.numberStyle = .decimal
number.maximumFractionDigits = 2
let credits = number.string(from: NSNumber(value: event.creditsUsed)) ?? "0"
return "\(formatter.string(from: event.date)) · \(event.service) · \(credits) credits"
}
public static func creditEventCompact(_ event: CreditEvent) -> String {
let formatter = DateFormatter()
formatter.dateFormat = "MMM d"
let number = NumberFormatter()
number.numberStyle = .decimal
number.maximumFractionDigits = 2
let credits = number.string(from: NSNumber(value: event.creditsUsed)) ?? "0"
return "\(formatter.string(from: event.date))\(event.service): \(credits)"
}
public static func creditShort(_ value: Double) -> String {
if value >= 1000 {
let k = value / 1000
return String(format: "%.1fk", k)
}
return String(format: "%.0f", value)
}
public static func truncatedSingleLine(_ text: String, max: Int = 80) -> String {
let single = text
.replacingOccurrences(of: "\n", with: " ")
.trimmingCharacters(in: .whitespacesAndNewlines)
guard single.count > max else { return single }
let idx = single.index(single.startIndex, offsetBy: max)
return "\(single[..<idx])…"
}
public static func modelDisplayName(_ raw: String) -> String {
var cleaned = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !cleaned.isEmpty else { return raw }
if CostUsagePricing.isCodexUnattributedModel(cleaned) { return "Unknown model" }
let patterns = [
#"(?:-|\s)\d{8}$"#,
#"(?:-|\s)\d{4}-\d{2}-\d{2}$"#,
#"\s\d{4}\s\d{4}$"#,
]
for pattern in patterns {
if let range = cleaned.range(of: pattern, options: .regularExpression) {
cleaned.removeSubrange(range)
break
}
}
if let trailing = cleaned.range(of: #"[ \t-]+$"#, options: .regularExpression) {
cleaned.removeSubrange(trailing)
}
return cleaned.isEmpty ? raw : cleaned
}
public static func modelCostDetail(
_ model: String,
costUSD: Double?,
totalTokens: Int? = nil,
currencyCode: String = "USD") -> String?
{
let costDetail: String? = if let label = CostUsagePricing.codexDisplayLabel(model: model) {
label
} else if let costUSD {
self.currencyString(costUSD, currencyCode: currencyCode)
} else {
nil
}
let tokenDetail = totalTokens.map(self.tokenCountString)
let parts = [costDetail, tokenDetail].compactMap(\.self)
guard !parts.isEmpty else { return nil }
return parts.joined(separator: " · ")
}
/// Cleans a provider plan string: strip ANSI/bracket noise, drop boilerplate words, collapse whitespace, and
/// ensure a leading capital if the result starts lowercase.
public static func cleanPlanName(_ text: String) -> String {
let stripped = TextParsing.stripANSICodes(text)
let withoutCodes = stripped.replacingOccurrences(
of: #"^\s*(?:\[\d{1,3}m\s*)+"#,
with: "",
options: [.regularExpression])
let withoutBoilerplate = withoutCodes.replacingOccurrences(
of: #"(?i)\b(claude|codex|account|plan)\b"#,
with: "",
options: [.regularExpression])
var cleaned = withoutBoilerplate
.replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)
.trimmingCharacters(in: .whitespacesAndNewlines)
if cleaned.isEmpty {
cleaned = stripped.trimmingCharacters(in: .whitespacesAndNewlines)
}
if cleaned.lowercased() == "oauth" {
return "Ollama"
}
// Capitalize first letter only if lowercase, preserving acronyms like "AI"
if let first = cleaned.first, first.isLowercase {
return cleaned.prefix(1).uppercased() + cleaned.dropFirst()
}
return cleaned
}
}