d2ef26296a
* fix(codex): show Business monthly credit on stacked cards Stacked account fetches skipped the spend-controls credits path, so Enterprise/Business cards showed Limits not available instead of used vs cap. * docs: credit #3112 changelog entry * fix(codex): keep cached monthly limits after spend-controls failures Transient enrichment errors were persisted as missing credits, so stacked Business cards fell back to “Limits not available.” Restore the unreleased changelog heading as well. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(codex): clear published credits when the monthly cap is gone Successful spend-controls absence skipped publication and left the old cap on the selected account. Retarget gatekeeper fingerprints after that cluster split. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(codex): keep monthly-only usage out of reset-credit rescue A transient spend-controls failure still produced a success with no windows, and OAuth reset-credit rescue converted that into noRateLimitsFound before the cached monthly cap could be preserved. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(codex): treat unmappable monthly limits as enrichment failures A nested spend-controls limit that is present but not numeric is no longer treated as confirmed absence, so a cached Business cap is preserved. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(codex): honor disabled caps and unmappable monthly usage Inactive enforcement still confirms absence even if limit JSON is malformed, and a non-numeric current_month_usage is treated as enrichment failure instead of zero used. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(codex): let an absent monthly cap win over malformed usage Malformed current_month_usage only counts as enrichment failure when an active numeric limit actually needs that used amount. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(codex): hide monthly credit when optional credits are off Override-derived monthly lanes now follow Show credits + extra usage, so stacked Business cards no longer keep a credit meter the user asked to hide. * fix(codex): include monthly credits in compact account planning Inactive compact rows now see per-account monthly caps so an exhausted Business limit cannot hide behind a window-less usage snapshot. * fix(codex): preserve only the monthly cap after enrichment failure When spend-controls fails and usage confirmed there is no generic credit balance, keep the cached monthly limit without republishing stale remaining/events. * fix(codex): fail monthly enrichment when account id or enforcement is unknown A gated spend-controls fetch without an account id, or a non-string enforcement_mode, is now inconclusive so a cached Business cap is not cleared or published as active. * fix(codex): hide menu-bar monthly credit when optional credits are off Menu-bar projections now omit the optional monthly cap when the credits toggle is off, and a mappable nonpositive limit confirms cap removal even if enforcement is unreadable. * fix(codex): reject non-finite monthly credit values NaN and Infinity usage or limit strings are treated as mapping failures so they cannot zero out cached usage or persist invalid credit snapshots. * fix(codex): keep monthly cap through standalone credits refresh When spend-controls enrichment fails, the follow-up credits refresh now merges the prior monthly limit instead of publishing a generic-only snapshot. * fix(codex): clear generic credits when monthly enrichment fails A standalone credits refresh that confirms the generic balance is gone now publishes nil instead of throwing, so stale credits are not restored while a monthly cap is still preserved when one exists. * fix(codex): retarget snapshot-store architecture suppressions The monthly-cap helper shifted the Codex identity anchors, so the provider-architecture allowlist now points at the new lines. * fix(codex): fetch monthly credits when plan type is unknown Spend-control accounts with a missing or unmapped plan type now query the monthly endpoint, so a cached Business cap is not treated as confirmed removal. * fix(codex): hydrate cached monthly credits and clear stale generic balance Offline startup left the menu-bar projection without the disk-cached cap, and enrichment failure kept a generic balance when the monthly cap was already absent. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(codex): persist standalone credits onto account snapshots Usage refresh stored the selected account snapshot before the credits pass, so a later cap add or removal never reached disk and offline hydration replayed the stale value. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(codex): persist credits using workspace identity Same-email workspace accounts made the snapshot write a no-op; match the published cap with the scoped refresh identity so only the selected workspace row is stored. Co-authored-by: Cursor <cursoragent@cursor.com> * chore: retrigger CI Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Peter Steinberger <steipete@gmail.com>
337 lines
15 KiB
Swift
337 lines
15 KiB
Swift
import AppKit
|
|
import CodexBarCore
|
|
|
|
/// Shared renderer for the compact multi-account menu layout: full cards for the
|
|
/// active and explicitly expanded accounts, one-line rows for the rest, and a
|
|
/// summary row standing in for the collapsed healthy tail. Used by every
|
|
/// multi-account presentation (claude-swap, token accounts, Codex accounts).
|
|
extension StatusItemController {
|
|
func compactAccountPlan(
|
|
for provider: UsageProvider,
|
|
accounts: [ProviderAccountUsageSnapshot]) -> AccountMenuLayoutPlanner.Plan
|
|
{
|
|
AccountMenuLayoutPlanner.plan(
|
|
accounts: accounts,
|
|
expandedAccountIDs: self.compactAccountExpandedIDs,
|
|
healthyTailExpanded: self.compactAccountExpandedHealthyTailProviders.contains(provider.instanceID))
|
|
}
|
|
|
|
struct CompactAccountMenuRendering {
|
|
let plan: AccountMenuLayoutPlanner.Plan
|
|
let accounts: [ProviderAccountUsageSnapshot]
|
|
let idPrefix: String
|
|
let cardModel: (ProviderAccountUsageSnapshot) -> UsageMenuCardView.Model?
|
|
var planAction: ((ProviderAccountUsageSnapshot) -> (() -> Void)?)?
|
|
}
|
|
|
|
/// Renders the token-account list with the compact plan when it applies.
|
|
/// Returns false when the caller should fall back to the stacked cards.
|
|
func addCompactTokenAccountMenuIfPlanned(
|
|
display: TokenAccountMenuDisplay,
|
|
to menu: NSMenu,
|
|
captureMenu: NSMenu,
|
|
context: MenuCardContext) -> Bool
|
|
{
|
|
let provider = context.currentProvider
|
|
let projected = Self.projectedTokenAccounts(
|
|
provider: provider,
|
|
snapshots: display.snapshots,
|
|
selectedAccountID: self.settings.effectiveSelectedTokenAccount(for: provider)?.id)
|
|
let plan = self.compactAccountPlan(for: provider, accounts: projected)
|
|
guard plan.usesCompactLayout else { return false }
|
|
let snapshotsByID = Dictionary(
|
|
uniqueKeysWithValues: display.snapshots.map { ($0.account.id.uuidString, $0) })
|
|
self.addCompactAccountMenuRows(
|
|
CompactAccountMenuRendering(
|
|
plan: plan,
|
|
accounts: projected,
|
|
idPrefix: "tokenAccount",
|
|
cardModel: { [weak self] projectedAccount in
|
|
guard let self,
|
|
let accountSnapshot = snapshotsByID[projectedAccount.id.opaqueID] else { return nil }
|
|
return self.tokenAccountMenuCardModel(for: provider, accountSnapshot: accountSnapshot)
|
|
},
|
|
planAction: nil),
|
|
to: menu,
|
|
captureMenu: captureMenu,
|
|
context: context)
|
|
return true
|
|
}
|
|
|
|
/// Renders the Codex account list with the compact plan when it applies.
|
|
/// Workspace-grouped lists keep their sectioned stacked layout; the flat
|
|
/// compact plan would lose the grouping headers.
|
|
func addCompactCodexAccountMenuIfPlanned(
|
|
display: CodexAccountMenuDisplay,
|
|
to menu: NSMenu,
|
|
captureMenu: NSMenu,
|
|
context: MenuCardContext) -> Bool
|
|
{
|
|
guard !display.showsWorkspaceGroups else { return false }
|
|
let projected = Self.projectedCodexAccounts(
|
|
display: display,
|
|
includeOptionalCredits: self.settings.showOptionalCreditsAndExtraUsage)
|
|
let plan = self.compactAccountPlan(for: .codex, accounts: projected)
|
|
guard plan.usesCompactLayout else { return false }
|
|
let snapshotsByAccountID = Dictionary(
|
|
uniqueKeysWithValues: display.snapshots.map { ($0.account.id, $0) })
|
|
let accountsByID = Dictionary(
|
|
uniqueKeysWithValues: display.accounts.map { ($0.id, $0) })
|
|
self.addCompactAccountMenuRows(
|
|
CompactAccountMenuRendering(
|
|
plan: plan,
|
|
accounts: projected,
|
|
idPrefix: "codexAccount",
|
|
cardModel: { [weak self] projectedAccount in
|
|
guard let self,
|
|
let account = accountsByID[projectedAccount.id.opaqueID] else { return nil }
|
|
let accountSnapshot = snapshotsByAccountID[account.id]
|
|
let health = CodexAccountHealth.status(for: account, error: accountSnapshot?.error)
|
|
return self.menuCardModel(
|
|
for: .codex,
|
|
snapshotOverride: accountSnapshot?.snapshot,
|
|
errorOverride: health.label,
|
|
forceOverrideCard: accountSnapshot == nil,
|
|
accountOverride: self.accountInfo(for: account),
|
|
historySelectionOverride: self.store.codexPlanUtilizationHistorySelection(
|
|
forVisibleAccount: account),
|
|
creditsOverride: accountSnapshot?.credits)
|
|
},
|
|
planAction: nil),
|
|
to: menu,
|
|
captureMenu: captureMenu,
|
|
context: context)
|
|
return true
|
|
}
|
|
|
|
func addCompactAccountMenuRows(
|
|
_ rendering: CompactAccountMenuRendering,
|
|
to menu: NSMenu,
|
|
captureMenu: NSMenu,
|
|
context: MenuCardContext)
|
|
{
|
|
let plan = rendering.plan
|
|
let idPrefix = rendering.idPrefix
|
|
let cardModel = rendering.cardModel
|
|
let planAction = rendering.planAction
|
|
let provider = context.currentProvider
|
|
let accountsByID = Dictionary(uniqueKeysWithValues: rendering.accounts.map { ($0.id, $0) })
|
|
let progressColor = UsageMenuCardView.Model.progressColor(for: provider)
|
|
var previousRowWasCard = false
|
|
for (index, row) in plan.rows.enumerated() {
|
|
switch row {
|
|
case let .card(accountID):
|
|
guard let account = accountsByID[accountID],
|
|
let model = cardModel(account) else { continue }
|
|
if index > 0 {
|
|
menu.addItem(.separator())
|
|
}
|
|
let collapseClick: (() -> Void)? = account.isActive ? nil : { [weak self, weak captureMenu] in
|
|
self?.toggleCompactAccountExpansion(accountID, menu: captureMenu)
|
|
}
|
|
menu.addItem(self.makeMenuCardItem(
|
|
UsageMenuCardView(
|
|
model: model,
|
|
width: context.menuWidth,
|
|
planAction: planAction?(account)),
|
|
id: "\(idPrefix)Card-\(accountID.opaqueID)",
|
|
width: context.menuWidth,
|
|
heightCacheScope: "\(idPrefix)-card-\(accountID.opaqueID)",
|
|
heightCacheFingerprint: model.heightFingerprint(section: "card"),
|
|
containsInteractiveControls: true,
|
|
onClick: collapseClick))
|
|
previousRowWasCard = true
|
|
case let .compact(compactRow):
|
|
if previousRowWasCard {
|
|
menu.addItem(.separator())
|
|
}
|
|
let rowModel = MenuCardCompactAccountRowView.Model(
|
|
label: PersonalInfoRedactor.redactEmail(
|
|
compactRow.label,
|
|
isEnabled: self.settings.hidePersonalInfo),
|
|
headroomPercent: compactRow.headroomPercent,
|
|
severity: compactRow.severity,
|
|
constraintDetail: compactRow.constraintDetail,
|
|
hasError: compactRow.hasError,
|
|
showsBestBadge: compactRow.isBestCandidate)
|
|
let accountID = compactRow.accountID
|
|
menu.addItem(self.makeMenuCardItem(
|
|
MenuCardCompactAccountRowView(
|
|
model: rowModel,
|
|
progressColor: progressColor,
|
|
width: context.menuWidth),
|
|
id: "\(idPrefix)Compact-\(accountID.opaqueID)",
|
|
width: context.menuWidth,
|
|
heightCacheScope: "\(idPrefix)-compact-\(accountID.opaqueID)",
|
|
heightCacheFingerprint: rowModel.heightFingerprint,
|
|
onClick: { [weak self, weak captureMenu] in
|
|
self?.toggleCompactAccountExpansion(accountID, menu: captureMenu)
|
|
}))
|
|
previousRowWasCard = false
|
|
case let .collapsedHealthy(count):
|
|
let view = MenuCardCollapsedAccountsRowView(count: count, width: context.menuWidth)
|
|
menu.addItem(self.makeMenuCardItem(
|
|
view,
|
|
id: "\(idPrefix)Collapsed",
|
|
width: context.menuWidth,
|
|
heightCacheScope: "\(idPrefix)-collapsed",
|
|
heightCacheFingerprint: "collapsed-\(count)",
|
|
onClick: { [weak self, weak captureMenu] in
|
|
self?.expandCompactAccountHealthyTail(for: provider, menu: captureMenu)
|
|
}))
|
|
previousRowWasCard = false
|
|
}
|
|
}
|
|
if !plan.rows.isEmpty {
|
|
menu.addItem(.separator())
|
|
}
|
|
if self.addStorageMenuCardSection(to: menu, provider: provider, width: context.menuWidth) {
|
|
menu.addItem(.separator())
|
|
}
|
|
}
|
|
|
|
/// Classic stacked layout: one full card per account. Shared fallback for
|
|
/// every multi-account list below the compact-layout threshold.
|
|
func addStackedMenuCards(
|
|
_ cards: [UsageMenuCardView.Model],
|
|
to menu: NSMenu,
|
|
context: MenuCardContext,
|
|
planAction: ((Int) -> (() -> Void)?)? = nil)
|
|
{
|
|
if cards.isEmpty, let model = self.menuCardModel(for: context.selectedProvider) {
|
|
let renderedModel = self.menuCardRefreshMonitor.model(for: model.provider, fallback: model)
|
|
menu.addItem(self.makeMenuCardItem(
|
|
UsageMenuCardView(model: model, layoutModel: renderedModel, width: context.menuWidth),
|
|
id: "menuCard",
|
|
width: context.menuWidth,
|
|
heightCacheScope: context.currentProvider.rawValue,
|
|
heightCacheFingerprint: renderedModel.heightFingerprint(section: "card"),
|
|
containsInteractiveControls: true))
|
|
menu.addItem(.separator())
|
|
} else {
|
|
for (index, model) in cards.enumerated() {
|
|
menu.addItem(self.makeMenuCardItem(
|
|
UsageMenuCardView(
|
|
model: model,
|
|
width: context.menuWidth,
|
|
planAction: planAction?(index)),
|
|
id: "menuCard-\(index)",
|
|
width: context.menuWidth,
|
|
heightCacheScope: "\(context.currentProvider.rawValue)-\(index)",
|
|
heightCacheFingerprint: model.heightFingerprint(section: "card"),
|
|
containsInteractiveControls: true))
|
|
if index < cards.count - 1 {
|
|
menu.addItem(.separator())
|
|
}
|
|
}
|
|
if !cards.isEmpty {
|
|
menu.addItem(.separator())
|
|
}
|
|
}
|
|
if self.addStorageMenuCardSection(to: menu, provider: context.currentProvider, width: context.menuWidth) {
|
|
menu.addItem(.separator())
|
|
}
|
|
}
|
|
|
|
// MARK: - Projections
|
|
|
|
static func projectedTokenAccounts(
|
|
provider: UsageProvider,
|
|
snapshots: [TokenAccountUsageSnapshot],
|
|
selectedAccountID: UUID?) -> [ProviderAccountUsageSnapshot]
|
|
{
|
|
snapshots.map { accountSnapshot in
|
|
let isActive = accountSnapshot.account.id == selectedAccountID
|
|
return ProviderAccountUsageSnapshot(
|
|
id: ProviderAccountIdentity(
|
|
source: "token-account",
|
|
opaqueID: accountSnapshot.account.id.uuidString),
|
|
provider: provider,
|
|
displayLabel: accountSnapshot.account.displayName,
|
|
isActive: isActive,
|
|
canActivate: !isActive,
|
|
snapshot: accountSnapshot.snapshot,
|
|
error: accountSnapshot.error,
|
|
sourceLabel: accountSnapshot.sourceLabel)
|
|
}
|
|
}
|
|
|
|
static func projectedCodexAccounts(
|
|
display: CodexAccountMenuDisplay,
|
|
includeOptionalCredits: Bool = true) -> [ProviderAccountUsageSnapshot]
|
|
{
|
|
let snapshotsByAccountID = Dictionary(uniqueKeysWithValues: display.snapshots.map { ($0.account.id, $0) })
|
|
return display.accounts.map { account in
|
|
let accountSnapshot = snapshotsByAccountID[account.id]
|
|
let health = CodexAccountHealth.status(for: account, error: accountSnapshot?.error)
|
|
let isActive = account.id == display.activeVisibleAccountID || account.isActive
|
|
let credits = includeOptionalCredits ? accountSnapshot?.credits : nil
|
|
return ProviderAccountUsageSnapshot(
|
|
id: ProviderAccountIdentity(source: "codex-account", opaqueID: account.id),
|
|
provider: .codex,
|
|
displayLabel: account.menuDisplayName,
|
|
isActive: isActive,
|
|
canActivate: !isActive,
|
|
snapshot: Self.snapshotIncludingMonthlyCredit(
|
|
snapshot: accountSnapshot?.snapshot,
|
|
credits: credits),
|
|
error: health.label,
|
|
sourceLabel: accountSnapshot?.sourceLabel)
|
|
}
|
|
}
|
|
|
|
static func snapshotIncludingMonthlyCredit(
|
|
snapshot: UsageSnapshot?,
|
|
credits: CreditsSnapshot?) -> UsageSnapshot?
|
|
{
|
|
guard let limit = credits?.codexCreditLimit else { return snapshot }
|
|
let monthly = RateWindow(
|
|
usedPercent: limit.usedPercent,
|
|
windowMinutes: nil,
|
|
resetsAt: limit.resetsAt,
|
|
resetDescription: nil)
|
|
guard let snapshot else {
|
|
return UsageSnapshot(
|
|
primary: nil,
|
|
secondary: nil,
|
|
tertiary: monthly,
|
|
updatedAt: limit.updatedAt)
|
|
}
|
|
if snapshot.tertiary == nil {
|
|
return snapshot.with(tertiary: monthly)
|
|
}
|
|
let extras = (snapshot.extraRateWindows ?? []) + [
|
|
NamedRateWindow(
|
|
id: "codex-monthly-credit",
|
|
title: limit.title,
|
|
window: monthly),
|
|
]
|
|
return snapshot.with(extraRateWindows: extras)
|
|
}
|
|
|
|
// MARK: - Expansion state
|
|
|
|
private func toggleCompactAccountExpansion(_ accountID: ProviderAccountIdentity, menu: NSMenu?) {
|
|
self.advanceMenuInteraction(for: menu)
|
|
if self.compactAccountExpandedIDs.contains(accountID) {
|
|
self.compactAccountExpandedIDs.remove(accountID)
|
|
} else {
|
|
self.compactAccountExpandedIDs.insert(accountID)
|
|
}
|
|
self.invalidateMenus(refreshOpenMenus: true)
|
|
}
|
|
|
|
private func expandCompactAccountHealthyTail(for provider: UsageProvider, menu: NSMenu?) {
|
|
self.advanceMenuInteraction(for: menu)
|
|
self.compactAccountExpandedHealthyTailProviders.insert(provider.instanceID)
|
|
self.invalidateMenus(refreshOpenMenus: true)
|
|
}
|
|
|
|
/// Compact-layout expansion is per-open transient UI state; reset when the last menu closes.
|
|
func resetCompactAccountMenuExpansionStateIfIdle() {
|
|
guard self.openMenus.isEmpty else { return }
|
|
self.compactAccountExpandedIDs.removeAll()
|
|
self.compactAccountExpandedHealthyTailProviders.removeAll()
|
|
}
|
|
}
|