63bf039cc0
* fix(claude): disambiguate claude-swap accounts that share an email Show organization name, slot, or a user alias on cards that share an email, while keeping identity on the numeric slot and the raw address. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(claude): keep swap display labels off token-account identity Prefer Claude swap aliases and org suffixes on Claude cards only, and keep the raw email as a separate account field so failed dashboard rows still identify without colliding stacked token accounts. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(claude): sync claude-swap cards by raw email, not alias iCloud labels were taking the UI display string, which can be an alias or email · org. Persist the source address instead. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(claude): redact email-shaped aliases and omit org from swap identity Dashboard redaction leaked aliases that looked like emails, and CloudKit sync persisted display-only organization names on the usage identity. Co-authored-by: Cursor <cursoragent@cursor.com> * test(claude): assert swap org names stay out of sync payloads CloudKit serializes the usage snapshot, so keep a regression that organization suffixes never enter the Codable identity. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(claude): keep swap labels and org emails off persisted identity Scope the display-label override to claude-swap cards, omit aliases when a slot has no email, and redact email-shaped org suffixes. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(claude): key duplicate swap slots by source identity in cloud sync Usage identity now carries claude-swap:<slot> so shared emails no longer collapse into one CloudKit record, and the menu-card regression test passes the swap source label. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(claude): keep non-email alias text when redacting dashboard labels Token-wise redaction now applies to mixed aliases such as Work (owner@example.com) instead of collapsing the whole label to redacted@domain. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(claude): disambiguate colliding orgs and redact emails inside aliases Append Account N when duplicate-email rows share an organization name, replace email ranges in dashboard aliases instead of whole tokens, and fix MenuCardView brace formatting that failed CI lint. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(claude): keep menu email helper within lint limits Pass the card input into the email helper so SwiftLint stays under the parameter cap, retarget gatekeeper anchors, and move the changelog note to 0.54.1 Unreleased. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(ci): correct cursor-email indent and expect localized API key details SwiftFormat rejected the optional-chain wrap, and OpenRouter detail titles now follow the #3084 localization pass. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(ci): rewrite the cursor email fallback without wrapped conditions SwiftFormat on CI still rejected the comma-separated if wrap around the Cursor subject ID. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(claude): redact internal dashboard emails and shorten cursor fallback Hide Personal Info missed owner@corp and domain-literal addresses, and the cursor subject-ID line tripped CI line length. * fix(claude): redact apostrophe emails and split alias tests Hide Personal Info was matching past apostrophes in local parts, and the merged projection tests exceeded SwiftLint's type body warning. * fix(claude): redact quoted local parts in dashboard identity Quoted addresses such as "owner"@example.com were left intact because quotes were treated as email boundaries. * fix(claude): redact quoted local parts that contain spaces Whitespace inside `"owner smith"@example.com` was treated as a token boundary and leaked the unquoted prefix. * fix(claude): treat same-mailbox emails as duplicates ignoring case Disambiguation counted Shared@example.com and shared@example.com as distinct, so neither card got an org suffix. * fix(claude): redact slash-separated dashboard emails independently A slash was not an address boundary, so owner@example.com/backup@example.net collapsed to a single redacted domain. * fix(claude): count colliding labels case-insensitively Same-mailbox cards that differ only by email casing still need Account N when they share an organization name. * fix(claude): fingerprint retained usage from the mailbox not the alias Display-only aliases were hashed into claude-swap-retained-usage.json when the slot had no email, which could not match later rows. --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Peter Steinberger <steipete@gmail.com>
1573 lines
64 KiB
Swift
1573 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 {
|
|
struct LinePresentation: Equatable {
|
|
let titleText: String
|
|
let resetText: String?
|
|
let metaText: String?
|
|
}
|
|
|
|
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?
|
|
/// True when detailLeftText/detailRightText came from a pace forecast.
|
|
let detailIsPaceDerived: Bool
|
|
let paceOnTop: Bool
|
|
let warningMarkerPercents: [Double]
|
|
let workdayMarkerPercents: [Double]
|
|
let workdayTickAppearance: WorkdayTickAppearance
|
|
let cardStyle: Bool
|
|
let sessionEquivalentDetail: UsagePaceText.SessionEquivalentDetail?
|
|
|
|
init(
|
|
id: String,
|
|
title: String,
|
|
percent: Double,
|
|
percentStyle: PercentStyle,
|
|
statusText: String? = nil,
|
|
resetText: String?,
|
|
detailText: String?,
|
|
detailLeftText: String?,
|
|
detailRightText: String?,
|
|
pacePercent: Double?,
|
|
detailIsPaceDerived: Bool = false,
|
|
paceOnTop: Bool,
|
|
warningMarkerPercents: [Double] = [],
|
|
workdayMarkerPercents: [Double] = [],
|
|
workdayTickAppearance: WorkdayTickAppearance = .subtle,
|
|
cardStyle: Bool = false,
|
|
sessionEquivalentDetail: UsagePaceText.SessionEquivalentDetail? = nil)
|
|
{
|
|
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.detailIsPaceDerived = detailIsPaceDerived
|
|
self.paceOnTop = paceOnTop
|
|
self.warningMarkerPercents = warningMarkerPercents
|
|
self.workdayMarkerPercents = workdayMarkerPercents
|
|
self.workdayTickAppearance = workdayTickAppearance
|
|
self.cardStyle = cardStyle
|
|
self.sessionEquivalentDetail = sessionEquivalentDetail
|
|
}
|
|
|
|
var percentLabel: String {
|
|
UsageFormatter.percentText(self.percent, suffix: self.percentStyle.labelSuffix)
|
|
}
|
|
|
|
func linePresentation(title: String) -> LinePresentation {
|
|
// Keep the title aligned with the configured used/remaining label semantics.
|
|
let metaParts = [
|
|
self.detailLeftText,
|
|
self.detailRightText,
|
|
self.sessionEquivalentDetail?.leftText,
|
|
self.sessionEquivalentDetail?.rightText,
|
|
].compactMap { text -> String? in
|
|
guard let text = text?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty else {
|
|
return nil
|
|
}
|
|
return text
|
|
}
|
|
return LinePresentation(
|
|
titleText: "\(title) \(self.percentLabel)",
|
|
resetText: self.resetText,
|
|
metaText: metaParts.isEmpty ? nil : metaParts.joined(separator: " · "))
|
|
}
|
|
}
|
|
|
|
enum SubtitleStyle {
|
|
case info
|
|
case loading
|
|
case error
|
|
}
|
|
|
|
struct TokenUsageSection {
|
|
let isRefreshing: Bool
|
|
let sessionLine: String
|
|
let monthLine: String
|
|
let meteredLine: String?
|
|
let comparisonLines: [String]
|
|
let hintLine: String?
|
|
let errorLine: String?
|
|
let errorCopyText: String?
|
|
|
|
/// Explicit initializer so `meteredLine`/`comparisonLines` default to empty: callers
|
|
/// that predate them (and providers that never report them) keep their call sites.
|
|
init(
|
|
isRefreshing: Bool = false,
|
|
sessionLine: String,
|
|
monthLine: String,
|
|
meteredLine: String? = nil,
|
|
comparisonLines: [String] = [],
|
|
hintLine: String?,
|
|
errorLine: String?,
|
|
errorCopyText: String?)
|
|
{
|
|
self.isRefreshing = isRefreshing
|
|
self.sessionLine = sessionLine
|
|
self.monthLine = monthLine
|
|
self.meteredLine = meteredLine
|
|
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 balanceLine: String?
|
|
var personalSpendLine: String?
|
|
var presentation: Presentation = .detail
|
|
var showsInProviderDetails = true
|
|
}
|
|
|
|
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]
|
|
var subscriptionNotes: [String] = []
|
|
var providerDetails: [ProviderDetailSection] = []
|
|
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)
|
|
}
|
|
if !liveModel.providerDetails.isEmpty {
|
|
ProviderDetailSectionsContent(
|
|
sections: liveModel.providerDetails,
|
|
chartColor: liveModel.progressColor)
|
|
}
|
|
} 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, !liveModel.creditsOnlyInlineUsageDashboard {
|
|
UsageMenuCardUsageContentView(model: liveModel, showBottomDivider: false)
|
|
}
|
|
if hasUsage, !liveModel.creditsOnlyInlineUsageDashboard, 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 liveModel.creditsOnlyInlineUsageDashboard, let dashboard = liveModel.inlineUsageDashboard {
|
|
InlineUsageDashboardContent(model: dashboard)
|
|
}
|
|
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 {
|
|
TokenUsageSectionContent(
|
|
provider: liveModel.provider,
|
|
tokenUsage: tokenUsage,
|
|
showsCodexHint: liveModel.inlineUsageDashboard == nil,
|
|
lineFont: .footnote)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.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
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Shared token-cost block (header, Today/window/metered/comparison lines, hint, error) used by
|
|
/// both the inline card body and the standalone cost section; only the value-line font differs.
|
|
private struct TokenUsageSectionContent: View {
|
|
let provider: UsageProvider
|
|
let tokenUsage: UsageMenuCardView.Model.TokenUsageSection
|
|
let showsCodexHint: Bool
|
|
let lineFont: Font
|
|
@Environment(\.menuItemHighlighted) private var isHighlighted
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
HStack(spacing: 6) {
|
|
Text(UsageMenuCardView.Model.tokenUsageHeader(provider: self.provider))
|
|
.font(.body)
|
|
.fontWeight(.medium)
|
|
if self.tokenUsage.isRefreshing {
|
|
ProgressView()
|
|
.controlSize(.mini)
|
|
.accessibilityLabel(L("Refreshing"))
|
|
}
|
|
}
|
|
Text(self.tokenUsage.sessionLine)
|
|
.font(self.lineFont)
|
|
.lineLimit(1)
|
|
Text(self.tokenUsage.monthLine)
|
|
.font(self.lineFont)
|
|
.lineLimit(1)
|
|
if let metered = self.tokenUsage.meteredLine, !metered.isEmpty {
|
|
Text(metered)
|
|
.font(self.lineFont)
|
|
.lineLimit(1)
|
|
}
|
|
ForEach(self.tokenUsage.comparisonLines, id: \.self) { line in
|
|
Text(line)
|
|
.font(self.lineFont)
|
|
.lineLimit(1)
|
|
}
|
|
if self.provider != .codex || self.showsCodexHint,
|
|
let hint = self.tokenUsage.hintLine,
|
|
!hint.isEmpty
|
|
{
|
|
Text(hint)
|
|
.font(.footnote)
|
|
.foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
|
|
.lineLimit(4)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
if let error = self.tokenUsage.errorLine, !error.isEmpty {
|
|
Text(error)
|
|
.font(.footnote)
|
|
.foregroundStyle(MenuHighlightStyle.error(self.isHighlighted))
|
|
.lineLimit(4)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
.overlay {
|
|
ClickToCopyOverlay(copyText: self.tokenUsage.errorCopyText ?? error)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct MetricRow: View {
|
|
let metric: UsageMenuCardView.Model.Metric
|
|
let title: String
|
|
let progressColor: Color
|
|
@Environment(\.menuItemHighlighted) private var isHighlighted
|
|
|
|
var body: some View {
|
|
let presentation = self.metric.linePresentation(title: self.title)
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
if let statusText = self.metric.statusText {
|
|
Text(self.title)
|
|
.font(.body)
|
|
.fontWeight(.medium)
|
|
Text(statusText)
|
|
.font(.footnote)
|
|
.foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
|
|
.lineLimit(1)
|
|
} else {
|
|
MetricRowHeader(
|
|
title: presentation.titleText,
|
|
resetText: presentation.resetText,
|
|
isHighlighted: self.isHighlighted)
|
|
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,
|
|
workdayTickAppearance: self.metric.workdayTickAppearance)
|
|
if let metaText = presentation.metaText {
|
|
Text(metaText)
|
|
.font(.footnote)
|
|
.foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
|
|
.lineLimit(2)
|
|
.truncationMode(.tail)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
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 MetricRowHeader: View {
|
|
let title: String
|
|
let resetText: String?
|
|
let isHighlighted: Bool
|
|
|
|
var body: some View {
|
|
if let resetText {
|
|
ViewThatFits(in: .horizontal) {
|
|
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
|
self.titleLabel
|
|
.fixedSize(horizontal: true, vertical: false)
|
|
Spacer(minLength: 8)
|
|
self.resetLabel(resetText)
|
|
.fixedSize(horizontal: true, vertical: false)
|
|
}
|
|
VStack(alignment: .trailing, spacing: 2) {
|
|
self.titleLabel
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
self.resetLabel(resetText)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
.frame(maxWidth: .infinity, alignment: .trailing)
|
|
}
|
|
}
|
|
} else {
|
|
self.titleLabel
|
|
}
|
|
}
|
|
|
|
private var titleLabel: some View {
|
|
Text(self.title)
|
|
.font(.body)
|
|
.fontWeight(.medium)
|
|
.lineLimit(1)
|
|
}
|
|
|
|
private func resetLabel(_ resetText: String) -> some View {
|
|
Text(resetText)
|
|
.font(.footnote)
|
|
.foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
|
|
.lineLimit(2)
|
|
.multilineTextAlignment(.trailing)
|
|
}
|
|
}
|
|
|
|
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
|
|
var showsSectionDividers = true
|
|
@Environment(\.menuItemHighlighted) private var isHighlighted
|
|
|
|
/// Doubao ships Coding Plan and Agent Plan subscriptions, each with personal
|
|
/// and team editions whose windows share period labels. Split the two plan
|
|
/// families here; team rows keep distinct ids and disclose their edition.
|
|
private var doubaoSplitMetrics: (
|
|
coding: [UsageMenuCardView.Model.Metric],
|
|
agent: [UsageMenuCardView.Model.Metric])?
|
|
{
|
|
guard self.model.provider == .doubao else { return nil }
|
|
let agent = self.model.metrics.filter { $0.id.hasPrefix("doubao-agent-") }
|
|
guard !agent.isEmpty else { return nil }
|
|
let coding = self.model.metrics.filter { !$0.id.hasPrefix("doubao-agent-") }
|
|
return (coding, agent)
|
|
}
|
|
|
|
private func groupHeader(_ title: String) -> some View {
|
|
Text(L(title))
|
|
.font(.caption.weight(.semibold))
|
|
.foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted))
|
|
.textCase(.uppercase)
|
|
}
|
|
|
|
private func metricRows(_ metrics: [UsageMenuCardView.Model.Metric]) -> some View {
|
|
ForEach(metrics, id: \.id) { metric in
|
|
MetricRow(
|
|
metric: metric,
|
|
title: UsageMenuCardView.popupMetricTitle(provider: self.model.provider, metric: metric),
|
|
progressColor: self.model.progressColor)
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
if let split = self.doubaoSplitMetrics {
|
|
if !split.coding.isEmpty {
|
|
self.groupHeader("Coding Plan")
|
|
self.metricRows(split.coding)
|
|
}
|
|
if !split.coding.isEmpty, self.showsSectionDividers {
|
|
Divider()
|
|
}
|
|
self.groupHeader("Agent Plan")
|
|
self.metricRows(split.agent)
|
|
} else {
|
|
self.metricRows(self.model.metrics)
|
|
}
|
|
if let resetCredits = self.model.codexResetCredits {
|
|
if !self.model.metrics.isEmpty, self.showsSectionDividers {
|
|
Divider()
|
|
}
|
|
CodexResetCreditsContent(presentation: resetCredits)
|
|
}
|
|
if let dashboard = self.model.inlineUsageDashboard {
|
|
InlineUsageDashboardContent(model: dashboard)
|
|
if !self.model.subscriptionNotes.isEmpty {
|
|
UsageNotesContent(notes: self.model.subscriptionNotes)
|
|
}
|
|
} 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.model.providerDetails.isEmpty {
|
|
ProviderDetailSectionsContent(
|
|
sections: self.model.providerDetails,
|
|
chartColor: self.model.progressColor)
|
|
}
|
|
if self.showBottomDivider {
|
|
Divider()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
struct UsageMenuCardUsageSectionView: View {
|
|
let model: UsageMenuCardView.Model
|
|
let showBottomDivider: Bool
|
|
let bottomPadding: CGFloat
|
|
let width: CGFloat
|
|
var showsSectionDividers = true
|
|
@Environment(\.menuCardRefreshMonitor) private var refreshMonitor
|
|
|
|
var body: some View {
|
|
let liveModel = self.liveModel
|
|
UsageMenuCardUsageContentView(
|
|
model: liveModel,
|
|
showBottomDivider: self.showBottomDivider,
|
|
showsSectionDividers: self.showsSectionDividers)
|
|
.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 {
|
|
TokenUsageSectionContent(
|
|
provider: liveModel.provider,
|
|
tokenUsage: tokenUsage,
|
|
showsCodexHint: true,
|
|
lineFont: .caption)
|
|
}
|
|
}
|
|
.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.paceGatedMetrics(Self.metrics(input: input), paceVisible: input.paceVisible),
|
|
provider: input.provider,
|
|
hidePersonalInfo: input.hidePersonalInfo)
|
|
let openAIAPIUsage = input.snapshot?.openAIAPIUsage
|
|
let inlineUsageDashboard = Self.inlineUsageDashboard(input: input)
|
|
let usageNotes = Self.usageNotes(input: input)
|
|
let presentation = ProviderDescriptorRegistry.descriptor(for: input.provider).presentation
|
|
let menuCard = presentation.menuCard
|
|
let rawCreditsText: String? = if !menuCard.showsCreditsSection ||
|
|
!input.showOptionalCreditsAndExtraUsage
|
|
{
|
|
nil
|
|
} else {
|
|
Self.creditsLine(
|
|
metadata: input.metadata,
|
|
snapshot: input.snapshot,
|
|
credits: input.credits,
|
|
error: input.creditsError,
|
|
preferredCurrencyCode: input.preferredCurrencyCode)
|
|
}
|
|
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.snapshot?.loginMethod(for: input.provider) == "Admin API"
|
|
let showsProviderCost = menuCard.showsProviderCost(context: ProviderCostVisibilityContext(
|
|
snapshot: input.snapshot,
|
|
showOptionalUsage: input.showOptionalCreditsAndExtraUsage))
|
|
let providerCostStyle = input.snapshot.map {
|
|
presentation.cost(snapshot: $0).menuCardStyle
|
|
} ?? .generic
|
|
let providerCostFollowsSummaryStyle = Self.providerCostFollowsSummaryStyle(
|
|
cost: input.snapshot?.providerCost,
|
|
style: providerCostStyle,
|
|
isClaudeAdminAPI: isClaudeAdminAPI)
|
|
let providerCost: ProviderCostSection? = if !showsProviderCost ||
|
|
(providerCostFollowsSummaryStyle && !input.costSummaryInlineEnabled)
|
|
{
|
|
nil
|
|
} else {
|
|
Self.providerCostSection(
|
|
cost: input.snapshot?.providerCost,
|
|
style: providerCostStyle,
|
|
isClaudeAdminAPI: isClaudeAdminAPI,
|
|
preferredCurrencyCode: input.preferredCurrencyCode)
|
|
}
|
|
let tokenUsageSnapshot = Self.tokenUsageSnapshot(input: input)
|
|
let tokenUsage = Self.tokenUsageSection(
|
|
provider: input.provider,
|
|
enabled: input.tokenCostMenuSectionEnabled,
|
|
isRefreshing: input.tokenCostIsRefreshing,
|
|
comparisonPeriodsEnabled: input.costComparisonPeriodsEnabled,
|
|
snapshot: tokenUsageSnapshot,
|
|
error: input.tokenError,
|
|
preferredCurrencyCode: input.preferredCurrencyCode)
|
|
let subtitle = input.subtitleOverride.map { (text: $0, style: SubtitleStyle.info) }
|
|
?? 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,
|
|
subscriptionNotes: Self.subscriptionMetadataNotes(snapshot: input.snapshot, provider: input.provider),
|
|
providerDetails: Self.visibleProviderDetails(input: input),
|
|
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))
|
|
}
|
|
|
|
private static func visibleProviderDetails(input: Input) -> [ProviderDetailSection] {
|
|
var details = input.snapshot?.details ?? []
|
|
let policy = ProviderDescriptorRegistry.descriptor(for: input.provider).presentation.optionalDetails
|
|
if !input.costSummaryInlineEnabled, !policy.costSummaryTitles.isEmpty {
|
|
details.removeAll { section in
|
|
section.title.map(policy.costSummaryTitles.contains) == true
|
|
}
|
|
}
|
|
if !input.showOptionalCreditsAndExtraUsage {
|
|
if policy.hidesAllWithoutOptionalUsage {
|
|
details = []
|
|
} else if !policy.hiddenTitlesWithoutOptionalUsage.isEmpty {
|
|
details.removeAll { section in
|
|
section.title.map(policy.hiddenTitlesWithoutOptionalUsage.contains) == true
|
|
}
|
|
}
|
|
}
|
|
if input.provider == .sub2api {
|
|
details = Self.sub2APILocalizedDetails(details)
|
|
}
|
|
details = Self.localizedProviderDetails(details, provider: input.provider)
|
|
guard input.hidePersonalInfo else { return details }
|
|
return details.compactMap { section in
|
|
let rows = section.rows.compactMap { row in
|
|
try? ProviderDetailSection.Row(
|
|
label: PersonalInfoRedactor.redactEmails(in: row.label, isEnabled: true) ?? row.label,
|
|
value: PersonalInfoRedactor.redactEmails(in: row.value, isEnabled: true) ?? row.value,
|
|
secondaryValue: PersonalInfoRedactor.redactEmails(
|
|
in: row.secondaryValue,
|
|
isEnabled: true))
|
|
}
|
|
let chart = section.chart.flatMap { chart in
|
|
let points = chart.points.compactMap { point in
|
|
try? ProviderDetailSection.Chart.Point(
|
|
label: PersonalInfoRedactor.redactEmails(in: point.label, isEnabled: true) ?? point.label,
|
|
value: point.value)
|
|
}
|
|
return try? ProviderDetailSection.Chart(
|
|
kind: chart.kind,
|
|
title: PersonalInfoRedactor.redactEmails(in: chart.title, isEnabled: true),
|
|
unit: chart.unit,
|
|
points: points)
|
|
}
|
|
return try? ProviderDetailSection(
|
|
title: PersonalInfoRedactor.redactEmails(in: section.title, isEnabled: true),
|
|
rows: rows,
|
|
chart: chart)
|
|
}
|
|
}
|
|
|
|
private static func email(from input: Input) -> String {
|
|
// Provider-specific by design: claude-swap accountOverride is the display label
|
|
// (alias or email · org). Other stacked sources keep fetched identity first.
|
|
if input.accountIsAuthoritative,
|
|
input.provider == .claude,
|
|
input.sourceLabel == ClaudeSwapAccountProjection.sourceLabel,
|
|
let email = input.account.email, !email.isEmpty
|
|
{
|
|
return email
|
|
}
|
|
if let email = input.snapshot?.accountEmail(for: input.provider), !email.isEmpty {
|
|
return email
|
|
}
|
|
// Provider-specific by design: Cursor app auth can expose only a subject ID, so its card needs this fallback.
|
|
if input.provider == .cursor {
|
|
let raw = input.snapshot?.identity(for: .cursor)?.accountID
|
|
let accountID = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
if !accountID.isEmpty {
|
|
return accountID.split(separator: "|").last.map(String.init) ?? accountID
|
|
}
|
|
}
|
|
if input.metadata.usesAccountFallback || input.accountIsAuthoritative,
|
|
let email = input.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(from: input),
|
|
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)
|
|
}
|
|
var metrics: [Metric] = []
|
|
let percentStyle: PercentStyle = input.usageBarsShowUsed ? .used : .left
|
|
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,
|
|
bindingProjection: Self.bindingQuotaProjection(
|
|
input: input,
|
|
primary: primary,
|
|
snapshot: snapshot),
|
|
percentStyle: percentStyle,
|
|
title: labels.primary))
|
|
}
|
|
if input.provider != .codex, let weekly = snapshot.secondary {
|
|
metrics.append(Self.secondaryMetric(
|
|
input: input,
|
|
weekly: weekly,
|
|
percentStyle: percentStyle,
|
|
title: labels.secondary))
|
|
}
|
|
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
|
|
}
|
|
// 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,
|
|
detailIsPaceDerived: tertiaryPaceDetail?.isPaceDerived ?? false,
|
|
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,
|
|
bindingProjection: RateWindowBindingQuotaProjection? = nil,
|
|
percentStyle: PercentStyle,
|
|
title: String? = nil) -> Metric
|
|
{
|
|
var presentation = PrimaryMetricPresentation(
|
|
resetText: Self.resetText(for: primary, style: input.resetTimeDisplayStyle, now: input.now),
|
|
detailText: nil)
|
|
Self.applyPrimaryQuotaPresentation(
|
|
&presentation,
|
|
input: input,
|
|
primary: primary)
|
|
Self.applyPrimaryBalancePresentation(&presentation, input: input, primary: primary)
|
|
Self.applyPrimaryResetPresentation(&presentation, input: input, primary: primary)
|
|
if bindingProjection == nil {
|
|
Self.applyPrimaryPacePresentation(&presentation, input: input, primary: primary)
|
|
}
|
|
Self.applyPrimaryFinalOverrides(&presentation, input: input, primary: primary)
|
|
// Provider-specific by design: z.ai's textual 5-hour reset phrase is provider-owned copy localized here.
|
|
if input.provider == .zai, let resetText = Self.localizedZaiPeriodicResetText(primary) {
|
|
presentation.resetText = resetText
|
|
}
|
|
if let bindingProjection {
|
|
let resetWindow = RateWindow(
|
|
usedPercent: bindingProjection.usedPercent,
|
|
windowMinutes: primary.windowMinutes,
|
|
resetsAt: bindingProjection.resetsAt,
|
|
resetDescription: bindingProjection.resetDescription)
|
|
presentation.resetText = Self.resetText(
|
|
for: resetWindow,
|
|
style: input.resetTimeDisplayStyle,
|
|
now: input.now)
|
|
}
|
|
let displayedUsedPercent = bindingProjection?.usedPercent ?? primary.usedPercent
|
|
return Metric(
|
|
id: "primary",
|
|
title: title ?? L(input.metadata.sessionLabel),
|
|
percent: Self.clamped(input.usageBarsShowUsed ? displayedUsedPercent : 100 - displayedUsedPercent),
|
|
percentStyle: percentStyle,
|
|
statusText: presentation.statusText,
|
|
resetText: presentation.resetText,
|
|
detailText: presentation.detailText,
|
|
detailLeftText: presentation.detailLeft,
|
|
detailRightText: presentation.detailRight,
|
|
pacePercent: presentation.pacePercent,
|
|
detailIsPaceDerived: presentation.detailIsPaceDerived,
|
|
paceOnTop: presentation.paceOnTop,
|
|
warningMarkerPercents: Self.warningMarkerPercents(
|
|
thresholds: input.quotaWarningThresholds[.session],
|
|
showUsed: input.usageBarsShowUsed),
|
|
sessionEquivalentDetail: Self.sessionEquivalentDetail(
|
|
input: input,
|
|
weeklyWindow: primary,
|
|
weeklyWindowID: nil))
|
|
}
|
|
|
|
private static func secondaryMetric(
|
|
input: Input,
|
|
weekly: RateWindow,
|
|
percentStyle: PercentStyle,
|
|
title: String? = nil) -> Metric
|
|
{
|
|
// Kimi's secondary slot is its 5-hour rate limit rather than a weekly window.
|
|
var paceDetail = if input.provider == .kimi {
|
|
Self.sessionPaceDetail(
|
|
provider: input.provider,
|
|
window: weekly,
|
|
now: input.now,
|
|
showUsed: input.usageBarsShowUsed)
|
|
} else {
|
|
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?
|
|
if input.provider == .warp,
|
|
let detail = weekly.resetDescription,
|
|
!detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
{
|
|
weeklyResetText = nil
|
|
weeklyDetailText = detail
|
|
}
|
|
if [.kilo, .litellm, .chutes].contains(input.provider),
|
|
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 remainingText = input.snapshot?.detailRow(label: "Bonus credits left")?.value,
|
|
let totalText = input.snapshot?.detailRow(label: "Bonus credits left")?.secondaryValue?
|
|
.split(separator: "·", maxSplits: 1)
|
|
.first?
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
.replacingOccurrences(of: "of ", with: "")
|
|
{
|
|
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 [.copilot, .zenmux].contains(input.provider),
|
|
let detail = weekly.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
!detail.isEmpty
|
|
{
|
|
paceDetail = PaceDetail(leftLabel: detail, rightLabel: nil, pacePercent: nil, paceOnTop: true)
|
|
}
|
|
if input.provider == .zenmux, weekly.resetsAt == nil {
|
|
weeklyResetText = nil
|
|
}
|
|
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,
|
|
detailIsPaceDerived: paceDetail?.isPaceDerived ?? false,
|
|
paceOnTop: paceDetail?.paceOnTop ?? true,
|
|
warningMarkerPercents: Self.warningMarkerPercents(
|
|
thresholds: input.quotaWarningThresholds[.weekly],
|
|
showUsed: input.usageBarsShowUsed),
|
|
workdayMarkerPercents: input.workdayTickAppearance == .hidden
|
|
? []
|
|
: workDayMarkerPercents(
|
|
workDays: input.workDaysPerWeek,
|
|
windowMinutes: weekly.windowMinutes),
|
|
workdayTickAppearance: input.workdayTickAppearance,
|
|
sessionEquivalentDetail: Self.sessionEquivalentDetail(
|
|
input: input,
|
|
weeklyWindow: weekly,
|
|
weeklyWindowID: nil))
|
|
}
|
|
}
|