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

1571 lines
64 KiB
Swift

import AppKit
import CodexBarCore
import SwiftUI
/// SwiftUI card used inside the NSMenu to mirror Apple's rich menu panels.
struct UsageMenuCardView: View {
struct Model {
enum PercentStyle: String {
case left
case used
var labelSuffix: String {
switch self {
case .left: L("usage_percent_suffix_left")
case .used: L("usage_percent_suffix_used")
}
}
var accessibilityLabel: String {
switch self {
case .left: L("Usage remaining")
case .used: L("Usage used")
}
}
}
struct Metric: Identifiable {
let id: String
let title: String
let percent: Double
let percentStyle: PercentStyle
let statusText: String?
let resetText: String?
let detailText: String?
let detailLeftText: String?
let detailRightText: String?
let pacePercent: Double?
let paceOnTop: Bool
let warningMarkerPercents: [Double]
let workdayMarkerPercents: [Double]
let cardStyle: Bool
init(
id: String,
title: String,
percent: Double,
percentStyle: PercentStyle,
statusText: String? = nil,
resetText: String?,
detailText: String?,
detailLeftText: String?,
detailRightText: String?,
pacePercent: Double?,
paceOnTop: Bool,
warningMarkerPercents: [Double] = [],
workdayMarkerPercents: [Double] = [],
cardStyle: Bool = false)
{
self.id = id
self.title = title
self.percent = percent
self.percentStyle = percentStyle
self.statusText = statusText
self.resetText = resetText
self.detailText = detailText
self.detailLeftText = detailLeftText
self.detailRightText = detailRightText
self.pacePercent = pacePercent
self.paceOnTop = paceOnTop
self.warningMarkerPercents = warningMarkerPercents
self.workdayMarkerPercents = workdayMarkerPercents
self.cardStyle = cardStyle
}
var percentLabel: String {
UsageFormatter.percentText(self.percent, suffix: self.percentStyle.labelSuffix)
}
}
enum SubtitleStyle {
case info
case loading
case error
}
struct TokenUsageSection {
let sessionLine: String
let monthLine: String
let comparisonLines: [String]
let hintLine: String?
let errorLine: String?
let errorCopyText: String?
init(
sessionLine: String,
monthLine: String,
comparisonLines: [String] = [],
hintLine: String?,
errorLine: String?,
errorCopyText: String?)
{
self.sessionLine = sessionLine
self.monthLine = monthLine
self.comparisonLines = comparisonLines
self.hintLine = hintLine
self.errorLine = errorLine
self.errorCopyText = errorCopyText
}
}
struct ProviderCostSection {
let title: String
let percentUsed: Double?
let spendLine: String
let percentLine: String?
var personalSpendLine: String?
}
let provider: UsageProvider
let providerName: String
let email: String
let subtitleText: String
let subtitleStyle: SubtitleStyle
var usesLiveSubtitle: Bool = false
let planText: String?
let metrics: [Metric]
let usageNotes: [String]
let openAIAPIUsage: OpenAIAPIUsageSnapshot?
let inlineUsageDashboard: InlineUsageDashboardModel?
let creditsText: String?
let creditsRemaining: Double?
var creditsProgressPercent: Double?, creditsScaleText: String?
let creditsHintText: String?
let creditsHintCopyText: String?
var codexResetCredits: CodexResetCreditsPresentation?
let providerCost: ProviderCostSection?
let tokenUsage: TokenUsageSection?
let placeholder: String?
let progressColor: Color
}
let model: Model
var layoutModel: Model?
let width: CGFloat
var planAction: (() -> Void)?
@Environment(\.menuItemHighlighted) private var isHighlighted
@Environment(\.menuCardRefreshMonitor) private var refreshMonitor
static func popupMetricTitle(provider: UsageProvider, metric: Model.Metric) -> String {
if provider == .openrouter, metric.id == "primary" {
return L("API key limit")
}
return metric.title
}
var body: some View {
let liveModel = self.liveModel
VStack(alignment: .leading, spacing: 0) {
UsageMenuCardHeaderView(
model: self.layoutModel ?? self.model,
planAction: self.planAction)
if Self.hasDetails(for: liveModel) {
Divider()
.padding(.top, UsageMenuCardLayout.headerContentSpacing)
.padding(.bottom, Self.dividerBottomPadding(for: liveModel))
}
if !liveModel.usesStackedDetailLayout {
if let dashboard = liveModel.inlineUsageDashboard {
InlineUsageDashboardContent(model: dashboard)
} else if !liveModel.usageNotes.isEmpty {
UsageNotesContent(notes: liveModel.usageNotes)
} else if let placeholder = liveModel.placeholder {
// Non-stacked placeholders are standalone detail rows; stacked usage placeholders are gated below.
Text(placeholder)
.foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
.font(.subheadline)
}
} else {
let hasUsage = liveModel.hasUsageContent
let hasCredits = liveModel.creditsText != nil
let hasProviderCost = liveModel.providerCost != nil
let hasCost = liveModel.tokenUsage != nil || hasProviderCost
VStack(alignment: .leading, spacing: 12) {
if hasUsage {
UsageMenuCardUsageContentView(model: liveModel, showBottomDivider: false)
}
if hasUsage, hasCredits || hasCost {
Divider()
}
if let credits = liveModel.creditsText {
CreditsBarContent(
creditsText: credits,
creditsRemaining: liveModel.creditsRemaining,
progressPercent: liveModel.creditsProgressPercent,
scaleText: liveModel.creditsScaleText,
hintText: liveModel.creditsHintText,
hintCopyText: liveModel.creditsHintCopyText,
progressColor: liveModel.progressColor)
}
if hasCredits, hasCost {
Divider()
}
if let providerCost = liveModel.providerCost {
ProviderCostContent(
section: providerCost,
progressColor: liveModel.progressColor)
}
if hasProviderCost, liveModel.tokenUsage != nil {
Divider()
}
if let tokenUsage = liveModel.tokenUsage {
VStack(alignment: .leading, spacing: 6) {
Text(L("cost_header_estimated"))
.font(.body)
.fontWeight(.medium)
Text(tokenUsage.sessionLine)
.font(.footnote)
.lineLimit(1)
Text(tokenUsage.monthLine)
.font(.footnote)
.lineLimit(1)
ForEach(tokenUsage.comparisonLines, id: \.self) { line in
Text(line)
.font(.footnote)
.lineLimit(1)
}
if let hint = tokenUsage.hintLine, !hint.isEmpty {
Text(hint)
.font(.footnote)
.foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
.lineLimit(4)
.fixedSize(horizontal: false, vertical: true)
}
if let error = tokenUsage.errorLine, !error.isEmpty {
Text(error)
.font(.footnote)
.foregroundStyle(MenuHighlightStyle.error(self.isHighlighted))
.lineLimit(4)
.fixedSize(horizontal: false, vertical: true)
.overlay {
ClickToCopyOverlay(copyText: tokenUsage.errorCopyText ?? error)
}
}
}
}
}
}
}
.padding(.horizontal, UsageMenuCardLayout.horizontalPadding)
.padding(
.top,
Self.hasDetails(for: liveModel)
? UsageMenuCardLayout.sectionTopPadding
: UsageMenuCardLayout.headerOnlyVerticalPadding)
// AppKit's following separator row adds visual bottom space, so detail cards keep this inset tight.
.padding(
.bottom,
Self.hasDetails(for: liveModel)
? UsageMenuCardLayout.sectionBottomPadding
: UsageMenuCardLayout.headerOnlyVerticalPadding)
.frame(width: self.width, alignment: .leading)
}
private var liveModel: Model {
guard self.model.usesLiveSubtitle else { return self.model }
return self.refreshMonitor?.model(for: self.model.provider, fallback: self.model) ?? self.model
}
private static func hasDetails(for model: Model) -> Bool {
model.hasUsageContent || model.usesStackedDetailLayout
}
static func dividerBottomPadding(for model: Model) -> CGFloat {
if model.usesStackedDetailLayout, model.hasUsageContent {
return UsageMenuCardLayout.postHeaderDividerContentSpacing
}
return UsageMenuCardLayout.sectionBottomPadding
}
}
private struct UsageMenuCardHeaderView: View {
let model: UsageMenuCardView.Model
var planAction: (() -> Void)?
@Environment(\.menuItemHighlighted) private var isHighlighted
@Environment(\.menuCardRefreshMonitor) private var refreshMonitor
var body: some View {
VStack(alignment: .leading, spacing: UsageMenuCardLayout.headerLineSpacing) {
HStack(alignment: .firstTextBaseline, spacing: UsageMenuCardLayout.headerColumnSpacing) {
Text(self.model.providerName).font(.headline)
.fontWeight(.semibold)
.lineLimit(1).truncationMode(.tail).layoutPriority(1)
Spacer()
Text(self.model.email).font(.subheadline)
.foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
.lineLimit(1).truncationMode(.middle)
}
let liveSubtitle = self.liveSubtitle
// Keep the geometry AppKit measured for this hosted row. A new error stays one line
// until the next rebuild; a recovered error keeps its reserved height until then.
let usesErrorLayout = self.model.subtitleStyle == .error
let subtitleAlignment: VerticalAlignment = usesErrorLayout ? .top : .firstTextBaseline
HStack(alignment: subtitleAlignment, spacing: UsageMenuCardLayout.headerColumnSpacing) {
if usesErrorLayout {
Text(self.model.subtitleText)
.font(.footnote)
.lineLimit(4)
.multilineTextAlignment(.leading)
.fixedSize(horizontal: false, vertical: true)
.padding(.bottom, 4)
.hidden()
.overlay(alignment: .topLeading) {
Text(liveSubtitle.text)
.font(.footnote)
.foregroundStyle(self.subtitleColor(for: liveSubtitle.style))
.lineLimit(4)
.multilineTextAlignment(.leading)
.fixedSize(horizontal: false, vertical: true)
}
.clipped()
.layoutPriority(1)
} else {
Text(liveSubtitle.text)
.font(.footnote)
.foregroundStyle(self.subtitleColor(for: liveSubtitle.style))
.lineLimit(1)
.multilineTextAlignment(.leading)
.fixedSize(horizontal: false, vertical: true)
.layoutPriority(1)
}
Spacer()
if usesErrorLayout {
let showsCopyButton = liveSubtitle.style == .error && !liveSubtitle.text.isEmpty
CopyIconButton(
copyText: liveSubtitle.text,
isHighlighted: self.isHighlighted,
isInteractive: showsCopyButton)
.opacity(showsCopyButton ? 1 : 0)
.allowsHitTesting(showsCopyButton)
.accessibilityHidden(!showsCopyButton)
}
if let plan = self.model.planText {
Group {
if let planAction {
Button(action: planAction) {
Text(plan)
}
.buttonStyle(.plain)
.menuCardInteractiveControl()
.accessibilityLabel(plan)
} else {
Text(plan)
}
}
.font(.footnote)
.foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
.lineLimit(1)
}
}
}
}
private var liveSubtitle: MenuCardLiveSubtitle {
let fallback = MenuCardLiveSubtitle(text: self.model.subtitleText, style: self.model.subtitleStyle)
guard self.model.usesLiveSubtitle else { return fallback }
return self.refreshMonitor?.subtitle(for: self.model.provider, fallback: fallback) ?? fallback
}
private func subtitleColor(for style: UsageMenuCardView.Model.SubtitleStyle) -> Color {
switch style {
case .info: MenuHighlightStyle.secondary(self.isHighlighted)
case .loading: MenuHighlightStyle.secondary(self.isHighlighted)
case .error: MenuHighlightStyle.error(self.isHighlighted)
}
}
}
private struct CopyIconButtonStyle: ButtonStyle {
let isHighlighted: Bool
func makeBody(configuration: Configuration) -> some View {
configuration.label
.padding(4)
.background {
RoundedRectangle(cornerRadius: 4, style: .continuous)
.fill(MenuHighlightStyle.secondary(self.isHighlighted).opacity(configuration.isPressed ? 0.18 : 0))
}
.scaleEffect(configuration.isPressed ? 0.94 : 1)
.animation(.easeOut(duration: 0.12), value: configuration.isPressed)
}
}
private struct CopyIconButton: View {
let copyText: String
let isHighlighted: Bool
let isInteractive: Bool
@State private var didCopy = false
@State private var resetTask: Task<Void, Never>?
var body: some View {
Button {
self.handleCopy()
} label: {
Image(systemName: self.didCopy ? "checkmark" : "doc.on.doc")
.font(.caption2.weight(.semibold))
.foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
.frame(width: 18, height: 18)
}
.buttonStyle(CopyIconButtonStyle(isHighlighted: self.isHighlighted))
.menuCardInteractiveControl(isEnabled: self.isInteractive)
.accessibilityLabel(self.didCopy ? L("Copied") : L("Copy error"))
}
private func handleCopy() {
let text = self.copyText
self.resetTask?.cancel()
MenuPasteboardCopy.perform(text, completion: {
self.didCopy = true
self.resetTask = Task { @MainActor in
try? await Task.sleep(for: .seconds(0.9))
self.didCopy = false
}
})
}
}
private struct ProviderCostContent: View {
let section: UsageMenuCardView.Model.ProviderCostSection
let progressColor: Color
@Environment(\.menuItemHighlighted) private var isHighlighted
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Text(self.section.title)
.font(.body)
.fontWeight(.medium)
if let percentUsed = self.section.percentUsed {
UsageProgressBar(
percent: percentUsed,
tint: self.progressColor,
accessibilityLabel: L("Extra usage spent"))
}
HStack(alignment: .firstTextBaseline) {
Text(self.section.spendLine).font(.footnote).lineLimit(1)
Spacer()
if let percentLine = self.section.percentLine {
Text(percentLine)
.font(.footnote)
.foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
.lineLimit(1)
}
}
if let personalSpendLine = self.section.personalSpendLine {
Text(personalSpendLine)
.font(.footnote).foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)).lineLimit(1)
}
}
}
}
private struct MetricRow: View {
let metric: UsageMenuCardView.Model.Metric
let title: String
let progressColor: Color
@Environment(\.menuItemHighlighted) private var isHighlighted
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Text(self.title)
.font(.body)
.fontWeight(.medium)
if let statusText = self.metric.statusText {
Text(statusText)
.font(.footnote)
.foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
.lineLimit(1)
} else {
UsageProgressBar(
percent: self.metric.percent,
tint: self.progressColor,
accessibilityLabel: self.metric.percentStyle.accessibilityLabel,
pacePercent: self.metric.pacePercent,
paceOnTop: self.metric.paceOnTop,
warningMarkerPercents: self.metric.warningMarkerPercents,
workdayMarkerPercents: self.metric.workdayMarkerPercents)
VStack(alignment: .leading, spacing: 2) {
HStack(alignment: .firstTextBaseline) {
Text(self.metric.percentLabel)
.font(.footnote)
.lineLimit(1)
Spacer()
if let rightLabel = self.metric.resetText {
Text(rightLabel)
.font(.footnote)
.foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
.lineLimit(1)
}
}
if self.metric.detailLeftText != nil || self.metric.detailRightText != nil {
HStack(alignment: .firstTextBaseline) {
if let detailLeft = self.metric.detailLeftText {
Text(detailLeft)
.font(.footnote)
.foregroundStyle(MenuHighlightStyle.primary(self.isHighlighted))
.lineLimit(1)
}
Spacer()
if let detailRight = self.metric.detailRightText {
Text(detailRight)
.font(.footnote)
.foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
.lineLimit(1)
}
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
if let detail = self.metric.detailText {
Text(detail)
.font(.footnote)
.foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
.lineLimit(1)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(self.metric.cardStyle ? 10 : 0)
.background(self.metric.cardStyle ? Color.secondary.opacity(self.isHighlighted ? 0.2 : 0.08) : Color.clear)
.clipShape(RoundedRectangle(cornerRadius: self.metric.cardStyle ? 10 : 0))
}
}
private struct UsageNotesContent: View {
let notes: [String]
@Environment(\.menuItemHighlighted) private var isHighlighted
var body: some View {
VStack(alignment: .leading, spacing: 4) {
ForEach(Array(self.notes.enumerated()), id: \.offset) { _, note in
Text(note)
.font(.footnote)
.foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
.lineLimit(2)
.fixedSize(horizontal: false, vertical: true)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
struct UsageMenuCardHeaderSectionView: View {
let model: UsageMenuCardView.Model
let showDivider: Bool
let width: CGFloat
var body: some View {
VStack(alignment: .leading, spacing: UsageMenuCardLayout.headerContentSpacing) {
UsageMenuCardHeaderView(model: self.model, planAction: nil)
if self.showDivider {
Divider()
}
}
.padding(.horizontal, UsageMenuCardLayout.horizontalPadding)
.padding(.top, UsageMenuCardLayout.headerOnlyVerticalPadding)
.padding(.bottom, self.headerBottomPadding)
.frame(width: self.width, alignment: .leading)
}
private var headerBottomPadding: CGFloat {
if self.model.subtitleStyle == .error {
return UsageMenuCardLayout.sectionBottomPadding
}
return self.showDivider
? UsageMenuCardLayout.sectionBottomPadding
: UsageMenuCardLayout.headerOnlyVerticalPadding
}
}
private struct UsageMenuCardUsageContentView: View {
let model: UsageMenuCardView.Model
let showBottomDivider: Bool
@Environment(\.menuItemHighlighted) private var isHighlighted
var body: some View {
VStack(alignment: .leading, spacing: 12) {
ForEach(self.model.metrics, id: \.id) { metric in
MetricRow(
metric: metric,
title: UsageMenuCardView.popupMetricTitle(provider: self.model.provider, metric: metric),
progressColor: self.model.progressColor)
}
if let resetCredits = self.model.codexResetCredits {
if !self.model.metrics.isEmpty {
Divider()
}
CodexResetCreditsContent(presentation: resetCredits)
}
if let dashboard = self.model.inlineUsageDashboard {
InlineUsageDashboardContent(model: dashboard)
} else if !self.model.usageNotes.isEmpty {
UsageNotesContent(notes: self.model.usageNotes)
} else if let placeholder = self.model.placeholder, self.model.metrics.isEmpty,
self.model.codexResetCredits == nil
{
Text(placeholder)
.foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
.font(.subheadline)
}
if self.showBottomDivider {
Divider()
}
}
}
}
struct UsageMenuCardUsageSectionView: View {
let model: UsageMenuCardView.Model
let showBottomDivider: Bool
let bottomPadding: CGFloat
let width: CGFloat
@Environment(\.menuCardRefreshMonitor) private var refreshMonitor
var body: some View {
let liveModel = self.liveModel
UsageMenuCardUsageContentView(model: liveModel, showBottomDivider: self.showBottomDivider)
.padding(.horizontal, UsageMenuCardLayout.horizontalPadding)
.padding(.top, UsageMenuCardLayout.usageSectionTopPadding)
.padding(.bottom, self.bottomPadding)
.frame(width: self.width, alignment: .leading)
}
private var liveModel: UsageMenuCardView.Model {
guard self.model.usesLiveSubtitle else { return self.model }
return self.refreshMonitor?.model(for: self.model.provider, fallback: self.model) ?? self.model
}
}
struct UsageMenuCardCreditsSectionView: View {
let model: UsageMenuCardView.Model
let showBottomDivider: Bool
let topPadding: CGFloat
let bottomPadding: CGFloat
let width: CGFloat
@Environment(\.menuCardRefreshMonitor) private var refreshMonitor
var body: some View {
let liveModel = self.liveModel
if let credits = liveModel.creditsText {
VStack(alignment: .leading, spacing: 6) {
CreditsBarContent(
creditsText: credits,
creditsRemaining: liveModel.creditsRemaining,
progressPercent: liveModel.creditsProgressPercent,
scaleText: liveModel.creditsScaleText,
hintText: liveModel.creditsHintText,
hintCopyText: liveModel.creditsHintCopyText,
progressColor: liveModel.progressColor)
if self.showBottomDivider {
Divider()
}
}
.padding(.horizontal, UsageMenuCardLayout.horizontalPadding)
.padding(.top, self.topPadding)
.padding(.bottom, self.bottomPadding)
.frame(width: self.width, alignment: .leading)
}
}
private var liveModel: UsageMenuCardView.Model {
guard self.model.usesLiveSubtitle else { return self.model }
return self.refreshMonitor?.model(for: self.model.provider, fallback: self.model) ?? self.model
}
}
private struct CreditsBarContent: View {
private static let fullScaleTokens: Double = 1000
let creditsText: String
let creditsRemaining: Double?
var progressPercent: Double?, scaleText: String?
let hintText: String?
let hintCopyText: String?
let progressColor: Color
@Environment(\.menuItemHighlighted) private var isHighlighted
private var percentLeft: Double? {
if let progressPercent {
return min(100, max(0, progressPercent))
}
guard let creditsRemaining else { return nil }
let percent = (creditsRemaining / Self.fullScaleTokens) * 100
return min(100, max(0, percent))
}
private var effectiveScaleText: String {
if let scaleText {
return scaleText
}
let scale = UsageFormatter.tokenCountString(Int(Self.fullScaleTokens))
return "\(scale) \(L("tokens"))"
}
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Text(L("Credits"))
.font(.body)
.fontWeight(.medium)
if let percentLeft {
UsageProgressBar(
percent: percentLeft,
tint: self.progressColor,
accessibilityLabel: L("Credits remaining"))
HStack(alignment: .firstTextBaseline) {
Text(self.creditsText)
.font(.caption)
.lineLimit(1)
Spacer()
Text(self.effectiveScaleText)
.font(.caption)
.foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
}
} else {
Text(self.creditsText)
.font(.caption)
}
if let hintText, !hintText.isEmpty {
Text(hintText)
.font(.footnote)
.foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
.lineLimit(4)
.fixedSize(horizontal: false, vertical: true)
.overlay {
ClickToCopyOverlay(copyText: self.hintCopyText ?? hintText)
}
}
}
}
}
struct UsageMenuCardCostSectionView: View {
let model: UsageMenuCardView.Model
let topPadding: CGFloat
let bottomPadding: CGFloat
let width: CGFloat
@Environment(\.menuItemHighlighted) private var isHighlighted
@Environment(\.menuCardRefreshMonitor) private var refreshMonitor
var body: some View {
let liveModel = self.liveModel
let hasTokenCost = liveModel.tokenUsage != nil
return Group {
if hasTokenCost {
VStack(alignment: .leading, spacing: 10) {
if let tokenUsage = liveModel.tokenUsage {
VStack(alignment: .leading, spacing: 6) {
Text(L("cost_header_estimated"))
.font(.body)
.fontWeight(.medium)
Text(tokenUsage.sessionLine)
.font(.caption)
.lineLimit(1)
Text(tokenUsage.monthLine)
.font(.caption)
.lineLimit(1)
ForEach(tokenUsage.comparisonLines, id: \.self) { line in
Text(line)
.font(.caption)
.lineLimit(1)
}
if let hint = tokenUsage.hintLine, !hint.isEmpty {
Text(hint)
.font(.footnote)
.foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
.lineLimit(4)
.fixedSize(horizontal: false, vertical: true)
}
if let error = tokenUsage.errorLine, !error.isEmpty {
Text(error)
.font(.footnote)
.foregroundStyle(MenuHighlightStyle.error(self.isHighlighted))
.lineLimit(4)
.fixedSize(horizontal: false, vertical: true)
.overlay {
ClickToCopyOverlay(copyText: tokenUsage.errorCopyText ?? error)
}
}
}
}
}
.padding(.horizontal, UsageMenuCardLayout.horizontalPadding)
.padding(.top, self.topPadding)
.padding(.bottom, self.bottomPadding)
.frame(width: self.width, alignment: .leading)
}
}
}
private var liveModel: UsageMenuCardView.Model {
guard self.model.usesLiveSubtitle else { return self.model }
return self.refreshMonitor?.model(for: self.model.provider, fallback: self.model) ?? self.model
}
}
struct UsageMenuCardExtraUsageSectionView: View {
let model: UsageMenuCardView.Model
let topPadding: CGFloat
let bottomPadding: CGFloat
let width: CGFloat
@Environment(\.menuCardRefreshMonitor) private var refreshMonitor
var body: some View {
let liveModel = self.liveModel
Group {
if let providerCost = liveModel.providerCost {
ProviderCostContent(
section: providerCost,
progressColor: liveModel.progressColor)
.padding(.horizontal, UsageMenuCardLayout.horizontalPadding)
.padding(.top, self.topPadding)
.padding(.bottom, self.bottomPadding)
.frame(width: self.width, alignment: .leading)
}
}
}
private var liveModel: UsageMenuCardView.Model {
guard self.model.usesLiveSubtitle else { return self.model }
return self.refreshMonitor?.model(for: self.model.provider, fallback: self.model) ?? self.model
}
}
// MARK: - Model factory
extension UsageMenuCardView.Model {
static func make(_ input: Input) -> UsageMenuCardView.Model {
let planText = Self.plan(
for: input.provider,
snapshot: input.snapshot,
account: input.account,
override: input.planOverride,
metadata: input.metadata)
let metrics = Self.redactedMetrics(
Self.metrics(input: input),
provider: input.provider,
hidePersonalInfo: input.hidePersonalInfo)
let openAIAPIUsage = input.snapshot?.openAIAPIUsage
let inlineUsageDashboard = Self.inlineUsageDashboard(input: input)
let usageNotes = Self.usageNotes(input: input)
let rawCreditsText: String? = if input.provider == .openrouter {
nil
} else if input.codexProjection != nil, !input.showOptionalCreditsAndExtraUsage {
nil
} else {
Self.creditsLine(
metadata: input.metadata,
snapshot: input.snapshot,
credits: input.credits,
error: input.creditsError)
}
let creditsText = PersonalInfoRedactor.redactEmails(in: rawCreditsText, isEnabled: input.hidePersonalInfo)
let creditsProgressPercent = Self.creditsProgressPercent(credits: input.credits)
let creditsScaleText = Self.creditsScaleText(credits: input.credits)
let codexCreditLimitDetail = Self.codexCreditLimitDetail(credits: input.credits, now: input.now)
let isClaudeAdminAPI = input.provider == .claude &&
input.snapshot?.identity?.loginMethod == "Admin API"
let isRequiredOpenCodeZenBalance = Self.isRequiredOpenCodeZenBalance(input.snapshot)
let hidesOptionalProviderCost = ((input.provider == .claude && !isClaudeAdminAPI) ||
input.provider == .factory ||
input.provider == .devin ||
(input.provider == .opencodego && !isRequiredOpenCodeZenBalance)) &&
!input.showOptionalCreditsAndExtraUsage
let providerCost: ProviderCostSection? = if input.provider == .sakana {
input.showOptionalCreditsAndExtraUsage
? Self.sakanaPayAsYouGoSection(input.snapshot?.sakanaPayAsYouGo)
: nil
} else if hidesOptionalProviderCost ||
(input.provider == .openai && openAIAPIUsage != nil)
{
nil
} else {
Self.providerCostSection(provider: input.provider, cost: input.snapshot?.providerCost)
}
let tokenUsageSnapshot = Self.tokenUsageSnapshot(input: input)
let tokenUsage = Self.tokenUsageSection(
provider: input.provider,
enabled: input.tokenCostMenuSectionEnabled,
comparisonPeriodsEnabled: input.costComparisonPeriodsEnabled,
snapshot: tokenUsageSnapshot,
error: input.tokenError)
let subtitle = Self.subtitle(
snapshot: input.snapshot,
isRefreshing: input.isRefreshing,
lastError: Self.lastError(input: input),
now: input.now)
let redacted = Self.redactedText(input: input, subtitle: subtitle)
let placeholder = Self.placeholder(input: input)
return UsageMenuCardView.Model(
provider: input.provider,
providerName: input.metadata.displayName,
email: redacted.email,
subtitleText: redacted.subtitleText,
subtitleStyle: subtitle.style,
usesLiveSubtitle: input.usesLiveSubtitle,
planText: planText,
metrics: metrics,
usageNotes: usageNotes,
openAIAPIUsage: openAIAPIUsage,
inlineUsageDashboard: inlineUsageDashboard,
creditsText: creditsText,
creditsRemaining: input.credits?.codexCreditLimit?.remaining ?? input.credits?.remaining,
creditsProgressPercent: creditsProgressPercent,
creditsScaleText: creditsScaleText,
creditsHintText: codexCreditLimitDetail ?? redacted.creditsHintText,
creditsHintCopyText: codexCreditLimitDetail ?? redacted.creditsHintCopyText,
codexResetCredits: Self.codexResetCredits(input: input),
providerCost: providerCost,
tokenUsage: tokenUsage,
placeholder: placeholder,
progressColor: Self.progressColor(for: input.provider))
}
static func openRouterSpendNotes(_ usage: OpenRouterUsageSnapshot) -> [String] {
var parts: [String] = []
if let daily = usage.keyUsageDaily {
parts.append("\(L("Today")): \(Self.openRouterCurrencyString(daily))")
}
if let weekly = usage.keyUsageWeekly {
parts.append("\(L("This week")): \(Self.openRouterCurrencyString(weekly))")
}
guard !parts.isEmpty else { return [] }
return [parts.joined(separator: " · ")]
}
private static func openRouterCurrencyString(_ value: Double) -> String {
String(format: "$%.2f", value)
}
private static func email(
for provider: UsageProvider,
snapshot: UsageSnapshot?,
account: AccountInfo,
metadata: ProviderMetadata,
accountIsAuthoritative: Bool) -> String
{
if let email = snapshot?.accountEmail(for: provider), !email.isEmpty {
return email
}
if metadata.usesAccountFallback || accountIsAuthoritative,
let email = account.email, !email.isEmpty
{
return email
}
return ""
}
private static func plan(
for provider: UsageProvider,
snapshot: UsageSnapshot?,
account: AccountInfo,
override: String?,
metadata: ProviderMetadata) -> String?
{
if let override, !override.isEmpty {
return override
}
if provider == .kiro,
let plan = kiroPlan(snapshot: snapshot)
{
return plan
}
if provider == .kilo {
guard let pass = self.kiloLoginPass(snapshot: snapshot) else {
return nil
}
return self.planDisplay(pass, for: provider)
}
if let plan = snapshot?.loginMethod(for: provider), !plan.isEmpty {
return self.planDisplay(plan, for: provider)
}
if metadata.usesAccountFallback,
let plan = account.plan, !plan.isEmpty
{
return Self.planDisplay(plan, for: provider)
}
return nil
}
private static func planDisplay(_ text: String, for provider: UsageProvider) -> String {
if provider == .minimax {
return self.miniMaxPlanDisplay(text)
}
let cleaned = if provider == .codex {
CodexPlanFormatting.displayName(text) ?? UsageFormatter.cleanPlanName(text)
} else {
UsageFormatter.cleanPlanName(text)
}
return cleaned.isEmpty ? text : cleaned
}
private static func miniMaxPlanDisplay(_ text: String) -> String {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
let normalized = trimmed.lowercased()
if normalized.contains("tokenplanplus") || normalized.contains("token plan plus") {
return "Plus"
}
if normalized.contains("tokenplanmax") || normalized.contains("token plan max") {
return "Max"
}
if normalized.contains("tokenplanultra") || normalized.contains("token plan ultra") {
return "Ultra"
}
return trimmed
}
private static func kiloLoginPass(snapshot: UsageSnapshot?) -> String? {
self.kiloLoginParts(snapshot: snapshot).pass
}
static func kiloLoginDetails(snapshot: UsageSnapshot?) -> [String] {
self.kiloLoginParts(snapshot: snapshot).details
}
private static func kiloLoginParts(snapshot: UsageSnapshot?) -> (pass: String?, details: [String]) {
guard let loginMethod = snapshot?.loginMethod(for: .kilo) else {
return (nil, [])
}
let parts = loginMethod
.components(separatedBy: "·")
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
guard !parts.isEmpty else {
return (nil, [])
}
let first = parts[0]
if self.isKiloActivitySegment(first) {
return (nil, parts)
}
return (first, Array(parts.dropFirst()))
}
private static func isKiloActivitySegment(_ text: String) -> Bool {
let normalized = text.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
return normalized.hasPrefix("auto top-up:")
}
private static func subtitle(
snapshot: UsageSnapshot?,
isRefreshing: Bool,
lastError: String?,
now: Date) -> (text: String, style: SubtitleStyle)
{
if let lastError, !lastError.isEmpty {
return (lastError.trimmingCharacters(in: .whitespacesAndNewlines), .error)
}
if isRefreshing {
return ("\(L("Refreshing"))…", .loading)
}
if let updated = snapshot?.updatedAt {
return (UsageFormatter.updatedString(from: updated, now: now), .info)
}
return (L("Not fetched yet"), .info)
}
private struct RedactedText {
let email: String
let subtitleText: String
let creditsHintText: String?
let creditsHintCopyText: String?
}
private static func redactedText(
input: Input,
subtitle: (text: String, style: SubtitleStyle)) -> RedactedText
{
let email = PersonalInfoRedactor.redactEmail(
Self.email(
for: input.provider,
snapshot: input.snapshot,
account: input.account,
metadata: input.metadata,
accountIsAuthoritative: input.accountIsAuthoritative),
isEnabled: input.hidePersonalInfo)
let subtitleText = PersonalInfoRedactor.redactEmails(in: subtitle.text, isEnabled: input.hidePersonalInfo)
?? subtitle.text
let creditsHintText = PersonalInfoRedactor.redactEmails(
in: Self.dashboardHint(error: input.dashboardError),
isEnabled: input.hidePersonalInfo)
let creditsHintCopyText = Self.creditsHintCopyText(
dashboardError: input.dashboardError,
hidePersonalInfo: input.hidePersonalInfo)
return RedactedText(
email: email,
subtitleText: subtitleText,
creditsHintText: creditsHintText,
creditsHintCopyText: creditsHintCopyText)
}
private static func creditsHintCopyText(dashboardError: String?, hidePersonalInfo: Bool) -> String? {
guard let dashboardError, !dashboardError.isEmpty else { return nil }
return hidePersonalInfo ? "" : dashboardError
}
private static func metrics(input: Input) -> [Metric] {
guard let snapshot = input.snapshot else { return [] }
if input.provider == .antigravity {
return Self.antigravityMetrics(input: input, snapshot: snapshot)
}
if input.provider == .minimax {
if let minimaxUsage = snapshot.minimaxUsage {
let services = minimaxUsage.orderedQuotaServices
if !services.isEmpty {
return Self.minimaxMetrics(services: services, input: input)
}
}
}
var metrics: [Metric] = []
let percentStyle: PercentStyle = input.usageBarsShowUsed ? .used : .left
let zaiUsage = input.provider == .zai ? snapshot.zaiUsage : nil
let zaiTokenDetail = Self.zaiLimitDetailText(limit: zaiUsage?.tokenLimit)
let zaiTimeDetail = Self.zaiLimitDetailText(limit: zaiUsage?.timeLimit)
let zaiSessionDetail = Self.zaiLimitDetailText(limit: zaiUsage?.sessionTokenLimit)
let openRouterQuotaDetail = Self.openRouterQuotaDetail(provider: input.provider, snapshot: snapshot)
let labels = Self.rateWindowLabels(input: input, snapshot: snapshot)
if input.provider == .mistral, let credits = snapshot.mistralUsage?.credits {
metrics.append(Metric(
id: "mistral-balance",
title: L("Balance"),
percent: 0,
percentStyle: percentStyle,
statusText: credits.formattedAvailableAmount,
resetText: nil,
detailText: nil,
detailLeftText: nil,
detailRightText: nil,
pacePercent: nil,
paceOnTop: true))
}
if input.provider == .codex, let codexProjection = input.codexProjection {
metrics.append(contentsOf: Self.codexRateMetrics(
input: input,
projection: codexProjection,
percentStyle: percentStyle))
} else if let primary = snapshot.primary {
metrics.append(Self.primaryMetric(
input: input,
primary: primary,
percentStyle: percentStyle,
title: labels.primary,
zaiTokenDetail: zaiTokenDetail,
openRouterQuotaDetail: openRouterQuotaDetail))
}
if input.provider != .codex, let weekly = snapshot.secondary {
metrics.append(Self.secondaryMetric(
input: input,
weekly: weekly,
percentStyle: percentStyle,
title: labels.secondary,
zaiTimeDetail: zaiTimeDetail))
}
if input.provider == .mimo, let mimoUsage = snapshot.mimoUsage {
metrics.append(Metric(
id: "mimo-balance",
title: L("Balance"),
percent: 0,
percentStyle: percentStyle,
statusText: mimoUsage.balanceDetail,
resetText: nil,
detailText: nil,
detailLeftText: nil,
detailRightText: nil,
pacePercent: nil,
paceOnTop: true))
}
if labels.showsTertiary, let opus = snapshot.tertiary {
var tertiaryDetailText: String?
if input.provider == .alibaba || input.provider == .alibabatokenplan,
let detail = opus.resetDescription,
!detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
{
tertiaryDetailText = detail
}
if input.provider == .zai, let detail = zaiSessionDetail {
tertiaryDetailText = detail
}
// Perplexity purchased credits don't reset; show balance without "Resets" prefix.
let opusResetText: String? = input.provider == .perplexity || input.provider == .sub2api
? opus.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines)
: Self.resetText(for: opus, style: input.resetTimeDisplayStyle, now: input.now)
let tertiaryPaceDetail = Self.resetWindowPaceDetail(window: opus, input: input)
metrics.append(Metric(
id: "tertiary",
title: labels.tertiary,
percent: Self.clamped(input.usageBarsShowUsed ? opus.usedPercent : opus.remainingPercent),
percentStyle: percentStyle,
resetText: opusResetText,
detailText: tertiaryDetailText,
detailLeftText: tertiaryPaceDetail?.leftLabel,
detailRightText: tertiaryPaceDetail?.rightLabel,
pacePercent: tertiaryPaceDetail?.pacePercent,
paceOnTop: tertiaryPaceDetail?.paceOnTop ?? true,
warningMarkerPercents: Self.warningMarkerPercents(
thresholds: input.quotaWarningThresholds[.weekly],
showUsed: input.usageBarsShowUsed)))
}
metrics.append(contentsOf: Self.extraRateWindowMetrics(
snapshot: snapshot,
input: input,
percentStyle: percentStyle))
if input.provider == .kilo || input.provider == .kimi,
metrics.contains(where: { $0.id == "primary" }),
metrics.contains(where: { $0.id == "secondary" })
{
metrics.sort { lhs, rhs in
let primarySecondaryOrder: [String: Int] = [
"secondary": 0,
"primary": 1,
]
return (primarySecondaryOrder[lhs.id] ?? Int.max) < (primarySecondaryOrder[rhs.id] ?? Int.max)
}
}
if let codexProjection = input.codexProjection,
codexProjection.supplementalMetrics.contains(.codeReview),
let remaining = codexProjection.remainingPercent(for: .codeReview)
{
let percent = input.usageBarsShowUsed ? (100 - remaining) : remaining
let resetText = codexProjection.limitWindow(for: .codeReview).flatMap {
Self.resetText(for: $0, style: input.resetTimeDisplayStyle, now: input.now)
}
metrics.append(Metric(
id: "code-review",
title: L("Code review"),
percent: Self.clamped(percent),
percentStyle: percentStyle,
resetText: resetText,
detailText: nil,
detailLeftText: nil,
detailRightText: nil,
pacePercent: nil,
paceOnTop: true))
}
return metrics
}
private static func primaryMetric(
input: Input,
primary: RateWindow,
percentStyle: PercentStyle,
title: String? = nil,
zaiTokenDetail: String?,
openRouterQuotaDetail: String?) -> Metric
{
var primaryDetailText: String? = input.provider == .zai ? zaiTokenDetail : nil
var primaryResetText = Self.resetText(for: primary, style: input.resetTimeDisplayStyle, now: input.now)
var primaryDetailLeft: String?
var primaryDetailRight: String?
if input.provider == .crof,
let detail = primary.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines),
!detail.isEmpty
{
primaryDetailRight = detail
}
if input.provider == .openrouter,
let openRouterQuotaDetail
{
primaryResetText = openRouterQuotaDetail
}
if input.provider == .copilot,
let detail = primary.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines),
!detail.isEmpty
{
primaryDetailLeft = detail
}
if [.warp, .kilo, .mimo, .deepseek, .qoder, .mistral, .litellm].contains(input.provider),
let detail = primary.resetDescription,
!detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
{
primaryDetailText = detail
}
if input.provider == .sub2api {
primaryResetText = primary.resetDescription
}
if let balance = Self.poeBalanceDetailText(input: input) {
primaryDetailText = balance
}
if input.provider == .kiro,
let kiroUsage = input.snapshot?.kiroUsage,
kiroUsage.creditsTotal > 0
{
let remaining = UsageFormatter.kiroCreditNumber(kiroUsage.creditsRemaining)
let total = UsageFormatter.kiroCreditNumber(kiroUsage.creditsTotal)
primaryDetailLeft = String(format: L("%@ of %@ credits left"), remaining, total)
}
if input.provider == .alibaba || input.provider == .alibabatokenplan || input.provider == .manus,
let detail = primary.resetDescription,
!detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
{
primaryDetailText = detail
if input.provider == .manus {
primaryResetText = nil
}
}
if [.warp, .kilo, .mimo, .deepseek, .qoder, .mistral, .litellm].contains(input.provider),
primary.resetsAt == nil
{
primaryResetText = nil
}
// Abacus: show credits as detail, compute pace on the primary monthly window
var primaryPacePercent: Double?
var primaryPaceOnTop = true
if let paceDetail = Self.sessionPaceDetail(
provider: input.provider,
window: primary,
now: input.now,
showUsed: input.usageBarsShowUsed)
{
primaryDetailLeft = paceDetail.leftLabel
primaryDetailRight = paceDetail.rightLabel
primaryPacePercent = paceDetail.pacePercent
primaryPaceOnTop = paceDetail.paceOnTop
}
if input.provider == .abacus {
if let detail = primary.resetDescription,
!detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
{
primaryDetailText = detail
}
if primary.resetsAt == nil {
primaryResetText = nil
}
if let pace = input.weeklyPace {
let paceDetail = Self.weeklyPaceDetail(
provider: input.provider,
window: primary,
now: input.now,
pace: pace,
showUsed: input.usageBarsShowUsed)
if let paceDetail {
primaryDetailLeft = paceDetail.leftLabel
primaryDetailRight = paceDetail.rightLabel
primaryPacePercent = paceDetail.pacePercent
primaryPaceOnTop = paceDetail.paceOnTop
}
}
} else if let paceDetail = Self.resetWindowPaceDetail(window: primary, input: input) {
primaryDetailLeft = paceDetail.leftLabel
primaryDetailRight = paceDetail.rightLabel
primaryPacePercent = paceDetail.pacePercent
primaryPaceOnTop = paceDetail.paceOnTop
}
// Legacy request-based Cursor plans: surface the raw used/limit quota on its own line,
// since the percentage bar and pace detail alone never spell out the request cap.
if input.provider == .cursor, let requests = input.snapshot?.cursorRequests {
primaryDetailText = String(
format: L("Request quota: %@ / %@"),
"\(requests.used)",
"\(requests.limit)")
}
if input.provider == .synthetic,
let regen = Self.syntheticRollingRegenDetail(
window: primary,
now: input.now,
showUsed: input.usageBarsShowUsed)
{
primaryResetText = regen.resetText
primaryDetailLeft = regen.pace.leftLabel
primaryDetailRight = regen.pace.rightLabel
primaryPacePercent = regen.pace.pacePercent
primaryPaceOnTop = regen.pace.paceOnTop
}
let usesBalanceStatusText = input.provider == .deepseek
let primaryStatusText = usesBalanceStatusText ? primaryDetailText : nil
if usesBalanceStatusText {
primaryDetailText = nil
}
return Metric(
id: "primary",
title: title ?? L(input.metadata.sessionLabel),
percent: Self.clamped(
input.usageBarsShowUsed ? primary.usedPercent : primary.remainingPercent),
percentStyle: percentStyle,
statusText: primaryStatusText,
resetText: primaryResetText,
detailText: primaryDetailText,
detailLeftText: primaryDetailLeft,
detailRightText: primaryDetailRight,
pacePercent: primaryPacePercent,
paceOnTop: primaryPaceOnTop,
warningMarkerPercents: Self.warningMarkerPercents(
thresholds: input.quotaWarningThresholds[.session],
showUsed: input.usageBarsShowUsed))
}
private static func secondaryMetric(
input: Input,
weekly: RateWindow,
percentStyle: PercentStyle,
title: String? = nil,
zaiTimeDetail: String?) -> Metric
{
var paceDetail = Self.weeklyPaceDetail(
provider: input.provider,
window: weekly,
now: input.now,
pace: input.weeklyPace,
showUsed: input.usageBarsShowUsed)
var weeklyResetText = Self.resetText(for: weekly, style: input.resetTimeDisplayStyle, now: input.now)
var weeklyDetailText: String? = input.provider == .zai ? zaiTimeDetail : nil
if input.provider == .warp,
let detail = weekly.resetDescription,
!detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
{
weeklyResetText = nil
weeklyDetailText = detail
}
if input.provider == .kilo || input.provider == .litellm,
let detail = weekly.resetDescription,
!detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
{
weeklyDetailText = detail
if weekly.resetsAt == nil {
weeklyResetText = nil
}
}
if input.provider == .sub2api {
weeklyResetText = weekly.resetDescription
}
if input.provider == .kiro,
let kiroUsage = input.snapshot?.kiroUsage,
let remaining = kiroUsage.bonusCreditsRemaining,
let total = kiroUsage.bonusCreditsTotal
{
let remainingText = UsageFormatter.kiroCreditNumber(remaining)
let totalText = UsageFormatter.kiroCreditNumber(total)
paceDetail = PaceDetail(
leftLabel: String(format: L("%@ of %@ bonus credits left"), remainingText, totalText),
rightLabel: nil,
pacePercent: nil,
paceOnTop: true)
}
if input.provider == .alibaba || input.provider == .alibabatokenplan,
let detail = weekly.resetDescription,
!detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
{
weeklyDetailText = detail
}
if input.provider == .manus,
let detail = weekly.resetDescription,
!detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
{
weeklyDetailText = detail
}
if input.provider == .crof,
let detail = weekly.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines),
!detail.isEmpty
{
weeklyResetText = detail
}
if input.provider == .copilot,
let detail = weekly.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines),
!detail.isEmpty
{
paceDetail = PaceDetail(leftLabel: detail, rightLabel: nil, pacePercent: nil, paceOnTop: true)
}
if let cursorPaceDetail = Self.resetWindowPaceDetail(
window: weekly,
input: input,
pace: input.weeklyPace)
{
paceDetail = cursorPaceDetail
}
// Perplexity bonus credits don't reset; show balance without "Resets" prefix.
if input.provider == .perplexity,
let detail = weekly.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines),
!detail.isEmpty
{
weeklyResetText = detail
}
if input.provider == .synthetic,
let regen = Self.syntheticRegenDetail(
weekly: weekly,
cost: input.snapshot?.providerCost,
now: input.now,
showUsed: input.usageBarsShowUsed)
{
weeklyResetText = regen.resetText
paceDetail = regen.pace
}
return Metric(
id: "secondary",
title: title ?? L(input.metadata.weeklyLabel),
percent: Self.clamped(input.usageBarsShowUsed ? weekly.usedPercent : weekly.remainingPercent),
percentStyle: percentStyle,
statusText: nil,
resetText: weeklyResetText,
detailText: weeklyDetailText,
detailLeftText: paceDetail?.leftLabel,
detailRightText: paceDetail?.rightLabel,
pacePercent: paceDetail?.pacePercent,
paceOnTop: paceDetail?.paceOnTop ?? true,
warningMarkerPercents: Self.warningMarkerPercents(
thresholds: input.quotaWarningThresholds[.weekly],
showUsed: input.usageBarsShowUsed),
workdayMarkerPercents: workDayMarkerPercents(
workDays: input.workDaysPerWeek,
windowMinutes: weekly.windowMinutes))
}
private static func codexRateMetrics(
input: Input,
projection: CodexConsumerProjection,
percentStyle: PercentStyle) -> [Metric]
{
projection.visibleRateLanes.compactMap { lane in
guard let window = projection.rateWindow(for: lane) else { return nil }
let title: String
let id: String
let paceDetail: PaceDetail?
switch lane {
case .session:
title = L(input.metadata.sessionLabel)
id = "primary"
paceDetail = Self.sessionPaceDetail(
provider: input.provider,
window: window,
now: input.now,
showUsed: input.usageBarsShowUsed)
case .weekly:
title = L(input.metadata.weeklyLabel)
id = "secondary"
paceDetail = Self.weeklyPaceDetail(
provider: input.provider,
window: window,
now: input.now,
pace: Self.standardWeeklyPace(input: input, window: window),
showUsed: input.usageBarsShowUsed)
}
return Metric(
id: id,
title: title,
percent: Self.clamped(input.usageBarsShowUsed ? window.usedPercent : window.remainingPercent),
percentStyle: percentStyle,
resetText: Self.resetText(for: window, style: input.resetTimeDisplayStyle, now: input.now),
detailText: nil,
detailLeftText: paceDetail?.leftLabel,
detailRightText: paceDetail?.rightLabel,
pacePercent: paceDetail?.pacePercent,
paceOnTop: paceDetail?.paceOnTop ?? true,
warningMarkerPercents: Self.warningMarkerPercents(
thresholds: input.quotaWarningThresholds[lane.quotaWarningWindow],
showUsed: input.usageBarsShowUsed),
workdayMarkerPercents: lane == .weekly
? workDayMarkerPercents(
workDays: input.workDaysPerWeek,
windowMinutes: window.windowMinutes)
: [])
}
}
}