1571 lines
64 KiB
Swift
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)
|
|
: [])
|
|
}
|
|
}
|
|
}
|