Files
2026-07-13 12:22:33 +08:00

530 lines
20 KiB
Swift

import CodexBarCore
import Foundation
#if canImport(Glibc)
import Glibc
#elseif canImport(Musl)
import Musl
#elseif canImport(Darwin)
import Darwin
#endif
struct CLICardMetric: Sendable, Equatable {
let label: String
let remainingPercent: Double
let resetText: String?
let resetAt: Date?
let detailText: String?
init(
label: String,
remainingPercent: Double,
resetText: String?,
resetAt: Date? = nil,
detailText: String? = nil)
{
self.label = label
self.remainingPercent = remainingPercent
self.resetText = resetText
self.resetAt = resetAt
self.detailText = detailText
}
}
struct CLICardModel: Sendable, Equatable {
let provider: UsageProvider
let title: String
let sourceLabel: String
let planBadge: String?
let accountLine: String?
let infoLines: [String]
let metrics: [CLICardMetric]
let extraLines: [String]
let statusLine: String?
}
struct CLICardFailure: Sendable, Equatable {
let provider: UsageProvider
let accountLabel: String?
let message: String
}
struct CLICardBuildInput: Sendable {
let provider: UsageProvider
let snapshot: UsageSnapshot
let credits: CreditsSnapshot?
let source: String
let status: ProviderStatusPayload?
let notes: [String]
let useColor: Bool
let resetStyle: ResetTimeDisplayStyle
let weeklyWorkDays: Int?
let now: Date
}
enum CLICardsRenderer {
static let minCardWidth = 38
static let maxCardWidth = 42
static let cardGap = 2
static func terminalColumnCount() -> Int {
if let value = terminalColumnCountFromTTY(), value > 0 {
return value
}
if let columns = ProcessInfo.processInfo.environment["COLUMNS"],
let value = Int(columns.trimmingCharacters(in: .whitespacesAndNewlines)),
value > 0
{
return value
}
return 80
}
private static func terminalColumnCountFromTTY(fileDescriptor: Int32 = STDOUT_FILENO) -> Int? {
guard isatty(fileDescriptor) == 1 else { return nil }
var windowSize = winsize(ws_row: 0, ws_col: 0, ws_xpixel: 0, ws_ypixel: 0)
guard ioctl(fileDescriptor, UInt(TIOCGWINSZ), &windowSize) == 0 else { return nil }
let columns = Int(windowSize.ws_col)
return columns > 0 ? columns : nil
}
static func columnCount(terminalWidth: Int, minCardWidth: Int = Self.minCardWidth) -> Int {
let usable = max(minCardWidth, terminalWidth)
return max(1, (usable + Self.cardGap) / (minCardWidth + Self.cardGap))
}
static func cardWidth(terminalWidth: Int, columns: Int) -> Int {
let totalGaps = (columns - 1) * Self.cardGap
let availableWidth = max(1, (terminalWidth - totalGaps) / columns)
return min(Self.maxCardWidth, availableWidth)
}
static func makeCard(_ input: CLICardBuildInput) -> CLICardModel {
let provider = input.provider
let snapshot = input.snapshot
let displayName = ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName
let context = RenderContext(
header: displayName,
status: input.status,
useColor: input.useColor,
resetStyle: input.resetStyle,
weeklyWorkDays: input.weeklyWorkDays,
notes: input.notes)
let infoLines = CLIRenderer.collectCardInfoLines(
provider: provider,
snapshot: snapshot,
credits: input.credits,
notes: input.notes,
useColor: input.useColor,
now: input.now)
let metrics = CLIRenderer.collectCardMetrics(
provider: provider,
snapshot: snapshot,
resetStyle: input.resetStyle,
now: input.now)
let extraLines = CLIRenderer.collectCardExtraLines(
provider: provider,
snapshot: snapshot,
credits: input.credits,
context: context,
now: input.now)
let statusLine: String?
if let status = input.status {
let line = "Status: \(status.indicator.label)\(status.descriptionSuffix)"
statusLine = CLIRenderer.colorizeStatusLine(line, indicator: status.indicator, useColor: input.useColor)
} else {
statusLine = nil
}
return CLICardModel(
provider: provider,
title: displayName,
sourceLabel: Self.normalizedSourceLabel(input.source),
planBadge: CLIRenderer.planBadgeText(provider: provider, snapshot: snapshot),
accountLine: snapshot.accountEmail(for: provider),
infoLines: infoLines,
metrics: metrics,
extraLines: extraLines,
statusLine: statusLine)
}
static func render(
cards: [CLICardModel],
failures: [CLICardFailure],
terminalWidth: Int,
useColor: Bool,
enhanced: Bool = false) -> String
{
guard !cards.isEmpty else {
return self.renderFailuresOnly(failures, useColor: useColor)
}
let columns = Self.columnCount(terminalWidth: terminalWidth)
let width = Self.cardWidth(terminalWidth: terminalWidth, columns: columns)
var chunks: [String] = []
for rowStart in stride(from: 0, to: cards.count, by: columns) {
let rowCards = Array(cards[rowStart..<min(rowStart + columns, cards.count)])
let rendered = rowCards.map { Self.renderCard($0, width: width, useColor: useColor, enhanced: enhanced) }
let rowHeight = rendered.map(\.count).max() ?? 0
for lineIndex in 0..<rowHeight {
let parts = rendered.map { lines -> String in
if lineIndex < lines.count - 1 {
return lines[lineIndex]
}
if lineIndex == rowHeight - 1, let bottom = lines.last {
return bottom
}
return Self.emptyCardLine(width: width, useColor: useColor, enhanced: enhanced)
}
chunks.append(parts.joined(separator: String(repeating: " ", count: Self.cardGap)))
}
if rowStart + columns < cards.count {
chunks.append("")
}
}
if !failures.isEmpty {
if !chunks.isEmpty {
chunks.append("")
}
chunks.append(Self.renderFailureFooter(failures: failures, useColor: useColor))
}
return chunks.joined(separator: "\n")
}
static func renderCard(_ card: CLICardModel, width: Int, useColor: Bool, enhanced: Bool = false) -> [String] {
let innerWidth = max(12, width - 4)
var lines: [String] = []
lines.append(Self.boxLine(kind: .top, innerWidth: innerWidth, useColor: useColor, enhanced: enhanced))
lines.append(Self.headerLine(card: card, innerWidth: innerWidth, useColor: useColor, enhanced: enhanced))
if let account = card.accountLine?.trimmingCharacters(in: .whitespacesAndNewlines), !account.isEmpty {
let accountText = "@ \(account)"
lines.append(Self.contentLine(
accountText,
innerWidth: innerWidth,
useColor: useColor,
enhanced: enhanced,
style: .subtle))
}
lines.append(Self.separatorLine(innerWidth: innerWidth, useColor: useColor, enhanced: enhanced))
for infoLine in card.infoLines {
lines.append(Self.detailLine(
infoLine,
innerWidth: innerWidth,
useColor: useColor,
enhanced: enhanced))
}
if !card.metrics.isEmpty, !card.infoLines.isEmpty {
lines.append(Self.contentLine("", innerWidth: innerWidth, useColor: useColor, enhanced: enhanced))
}
for (index, metric) in card.metrics.enumerated() {
if index > 0 {
lines.append(Self.contentLine("", innerWidth: innerWidth, useColor: useColor, enhanced: enhanced))
}
lines.append(Self.metricLabelLine(
metric: metric,
innerWidth: innerWidth,
useColor: useColor,
enhanced: enhanced))
lines.append(Self.metricBarLine(
metric: metric,
innerWidth: innerWidth,
useColor: useColor,
enhanced: enhanced))
if let resetText = metric.resetText {
lines.append(Self.contentLine(
resetText,
innerWidth: innerWidth,
useColor: useColor,
enhanced: enhanced,
style: .subtle))
}
if let detailText = metric.detailText {
lines.append(Self.contentLine(
detailText,
innerWidth: innerWidth,
useColor: useColor,
enhanced: enhanced,
style: .subtle))
}
}
for extraLine in card.extraLines {
lines.append(Self.detailLine(extraLine, innerWidth: innerWidth, useColor: useColor, enhanced: enhanced))
}
if let statusLine = card.statusLine {
lines.append(Self.contentLine(statusLine, innerWidth: innerWidth, useColor: useColor, enhanced: enhanced))
}
lines.append(Self.boxLine(kind: .bottom, innerWidth: innerWidth, useColor: useColor, enhanced: enhanced))
return lines
}
private enum BoxLineKind {
case top
case bottom
}
private enum ContentStyle: Equatable {
case normal
case subtle
case border
}
private static func boxLine(kind: BoxLineKind, innerWidth: Int, useColor: Bool, enhanced: Bool) -> String {
let chars = switch kind {
case .top: ("╭", "╮")
case .bottom: ("╰", "╯")
}
let line = chars.0 + String(repeating: "─", count: innerWidth + 2) + chars.1
return Self.styleBorder(line, useColor: useColor, enhanced: enhanced)
}
private static func headerLine(card: CLICardModel, innerWidth: Int, useColor: Bool, enhanced: Bool) -> String {
let title: String
let badge: String
if useColor, enhanced {
title = CLIRenderer.colorizeEnhancedAccentBold(card.title)
badge = CLIRenderer.colorizeEnhancedBadge(card.sourceLabel)
} else if useColor {
title = CLIRenderer.colorizeAccentBold(card.title)
badge = CLIRenderer.colorizeCardBadge(card.sourceLabel)
} else {
title = card.title
badge = "[\(card.sourceLabel)]"
}
let left = "\(title) \(badge)"
let leftVisible = Self.visibleLength(left)
let rawPlanText = card.planBadge.map { "PLAN \($0)" } ?? ""
let maxPlanWidth = max(0, innerWidth - leftVisible - 1)
let planText = maxPlanWidth >= 8 ? Self.truncatePlain(rawPlanText, width: maxPlanWidth) : ""
let planVisible = Self.visibleLength(planText)
let gap = max(1, innerWidth - leftVisible - planVisible)
let plan = Self.planPill(text: planText, useColor: useColor, enhanced: enhanced)
let content = left + String(repeating: " ", count: gap) + plan
return Self.sideBorder(content, innerWidth: innerWidth, useColor: useColor, enhanced: enhanced)
}
private static func planPill(text: String, useColor: Bool, enhanced: Bool) -> String {
guard !text.isEmpty else { return "" }
let pieces = text.split(separator: " ", maxSplits: 1).map(String.init)
guard pieces.count == 2 else {
return useColor ? CLIRenderer.colorizeCardPlanBox(text) : text
}
if useColor, enhanced {
return CLIRenderer.colorizeEnhancedPlanLabel(pieces[0])
+ " "
+ CLIRenderer.colorizeEnhancedPlanValue(pieces[1])
}
if useColor {
return CLIRenderer.colorizeCardPlanBox(pieces[0])
+ " "
+ CLIRenderer.colorizeWarning(pieces[1])
}
return text
}
private static func separatorLine(innerWidth: Int, useColor: Bool, enhanced: Bool) -> String {
self.sideBorder(
String(repeating: "─", count: innerWidth),
innerWidth: innerWidth,
useColor: useColor,
enhanced: enhanced,
contentStyle: .border)
}
private static func metricLabelLine(
metric: CLICardMetric,
innerWidth: Int,
useColor: Bool,
enhanced: Bool) -> String
{
let percentText = UsageFormatter.usageLine(
remaining: metric.remainingPercent,
used: 100 - metric.remainingPercent,
showUsed: false)
let coloredPercent: String = if useColor, enhanced {
CLIRenderer.colorizeEnhancedRemainingPercent(percentText, remainingPercent: metric.remainingPercent)
} else {
CLIRenderer.colorizeCardPercent(
percentText,
remainingPercent: metric.remainingPercent,
useColor: useColor)
}
let label: String = if useColor, enhanced {
CLIRenderer.colorizeEnhancedReadable(metric.label)
} else if useColor {
CLIRenderer.colorizeReadable(metric.label)
} else {
metric.label
}
let gap = max(1, innerWidth - Self.visibleLength(label) - Self.visibleLength(coloredPercent))
let content = label + String(repeating: " ", count: gap) + coloredPercent
return Self.sideBorder(content, innerWidth: innerWidth, useColor: useColor, enhanced: enhanced)
}
private static func metricBarLine(
metric: CLICardMetric,
innerWidth: Int,
useColor: Bool,
enhanced: Bool) -> String
{
let barWidth = max(4, innerWidth - 4)
let bar: String = if useColor, enhanced {
CLIRenderer.gradientRemainingTrackBar(remainingPercent: metric.remainingPercent, width: barWidth)
} else {
CLIRenderer.cardBlockBar(
remainingPercent: metric.remainingPercent,
width: barWidth,
useColor: useColor)
}
return Self.sideBorder("[ \(bar) ]", innerWidth: innerWidth, useColor: useColor, enhanced: enhanced)
}
private static func detailLine(_ content: String, innerWidth: Int, useColor: Bool, enhanced: Bool) -> String {
let normalized = Self.normalizeGlyphs(content)
let plain = TextParsing.stripANSICodes(normalized)
let parts = plain.split(separator: ":", maxSplits: 1).map(String.init)
guard parts.count == 2 else {
return Self.contentLine(normalized, innerWidth: innerWidth, useColor: useColor, enhanced: enhanced)
}
let rawLabel = parts[0].trimmingCharacters(in: .whitespacesAndNewlines) + ":"
let rawValue = parts[1].trimmingCharacters(in: .whitespacesAndNewlines)
let label: String = if useColor, enhanced {
CLIRenderer.colorizeEnhancedReadable(rawLabel)
} else if useColor {
CLIRenderer.colorizeReadable(rawLabel)
} else {
rawLabel
}
let value: String = if useColor, enhanced {
CLIRenderer.colorizeEnhancedGood(rawValue)
} else if useColor {
CLIRenderer.colorizeAccent(rawValue)
} else {
rawValue
}
let gap = max(1, innerWidth - Self.visibleLength(label) - Self.visibleLength(value))
let line = label + String(repeating: " ", count: gap) + value
return Self.sideBorder(line, innerWidth: innerWidth, useColor: useColor, enhanced: enhanced)
}
private static func contentLine(
_ content: String,
innerWidth: Int,
useColor: Bool,
enhanced: Bool,
style: ContentStyle = .normal) -> String
{
let normalized = Self.normalizeGlyphs(content)
let stripped = TextParsing.stripANSICodes(normalized)
let clipped = stripped.count <= innerWidth
? normalized
: (innerWidth <= 1 ? String(stripped.prefix(innerWidth)) : String(stripped.prefix(innerWidth - 1)) + "…")
let display: String = if style == .subtle, useColor, enhanced {
CLIRenderer.colorizeEnhancedSubtle(TextParsing.stripANSICodes(clipped))
} else if style == .subtle, useColor {
CLIRenderer.colorizeSubtle(TextParsing.stripANSICodes(clipped))
} else {
clipped
}
return Self.sideBorder(display, innerWidth: innerWidth, useColor: useColor, enhanced: enhanced)
}
private static func sideBorder(
_ content: String,
innerWidth: Int,
useColor: Bool,
enhanced: Bool,
contentStyle: ContentStyle = .normal) -> String
{
let fitted = Self.fitContent(content, width: innerWidth)
let padding = max(0, innerWidth - Self.visibleLength(fitted))
let padded = fitted + String(repeating: " ", count: padding)
let visible = "│ \(padded) │"
guard useColor else { return visible }
let left = Self.styleBorder("│ ", useColor: useColor, enhanced: enhanced)
let right = Self.styleBorder(" │", useColor: useColor, enhanced: enhanced)
let styledContent: String = if contentStyle == .border {
Self.styleBorder(padded, useColor: useColor, enhanced: enhanced)
} else {
padded
}
return left + styledContent + right
}
private static func styleBorder(_ text: String, useColor: Bool, enhanced: Bool) -> String {
guard useColor else { return text }
if enhanced {
return CLIRenderer.colorizeEnhancedBorder(text)
}
return CLIRenderer.colorizeCardBorder(text)
}
private static func emptyCardLine(width: Int, useColor: Bool, enhanced: Bool) -> String {
let innerWidth = max(12, width - 4)
return Self.sideBorder("", innerWidth: innerWidth, useColor: useColor, enhanced: enhanced)
}
private static func visibleLength(_ text: String) -> Int {
TextParsing.stripANSICodes(self.normalizeGlyphs(text)).count
}
private static func truncatePlain(_ text: String, width: Int) -> String {
guard width > 0 else { return "" }
guard text.count > width else { return text }
if width <= 1 { return String(text.prefix(width)) }
return String(text.prefix(width - 1)) + "…"
}
private static func fitContent(_ text: String, width: Int) -> String {
guard self.visibleLength(text) > width else { return text }
return self.truncatePlain(TextParsing.stripANSICodes(text), width: width)
}
private static func normalizeGlyphs(_ text: String) -> String {
text
.replacingOccurrences(of: "👤", with: "@")
.replacingOccurrences(of: "⏳ Resets in ", with: "Reset in ")
.replacingOccurrences(of: "⏳ Resets ", with: "Reset ")
.replacingOccurrences(of: "⏳ ", with: "Reset ")
}
static func renderFailureFooter(failures: [CLICardFailure], useColor: Bool) -> String {
var lines = ["Failed providers:"]
for failure in failures {
let name = ProviderDescriptorRegistry.descriptor(for: failure.provider).metadata.displayName
if let account = failure.accountLabel, !account.isEmpty {
lines.append(" - \(name) (\(account)): \(failure.message)")
} else {
lines.append(" - \(name): \(failure.message)")
}
}
let text = lines.joined(separator: "\n")
guard useColor else { return text }
return CLIRenderer.colorizeError(text)
}
static func renderFailuresOnly(_ failures: [CLICardFailure], useColor: Bool) -> String {
guard !failures.isEmpty else { return "" }
return self.renderFailureFooter(failures: failures, useColor: useColor)
}
private static func normalizedSourceLabel(_ source: String) -> String {
let trimmed = source.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { return "auto" }
if trimmed.contains("oauth") { return "oauth" }
if trimmed.contains("web") || trimmed.contains("openai-web") { return "web" }
if trimmed.contains("api") { return "api" }
if trimmed.contains("cli") { return "cli" }
return trimmed
}
}