diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 67d6c13da6..4b4e6663df 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -578,12 +578,35 @@ extension StatusItemController { // interaction closures must always reference the live menu they end up serving. let interactionMenu = captureMenu ?? menu let providerScopes = self.overviewProviderScopes(enabledProviders: enabledProviders) - let rows: [(provider: UsageProvider, model: UsageMenuCardView.Model)] = providerScopes.visible - .compactMap { provider in - guard let model = self.menuCardModel(for: provider) else { return nil } - guard !model.isOverviewErrorOnly else { return nil } - return (provider: provider, model: model) + var rows: [(provider: UsageProvider, identifier: String, model: UsageMenuCardView.Model)] = [] + for provider in providerScopes.visible { + if provider == .codex, + let display = self.codexAccountMenuDisplay(for: provider), + display.showAll + { + for account in display.accounts { + let accountSnapshot = display.snapshots.first(where: { $0.id == account.id }) + let model = self.menuCardModel( + for: .codex, + snapshotOverride: accountSnapshot?.snapshot, + errorOverride: accountSnapshot?.error, + forceOverrideCard: accountSnapshot == nil, + accountOverride: self.accountInfo(for: account), + historySelectionOverride: self.store.codexPlanUtilizationHistorySelection( + forVisibleAccount: account), + creditsOverride: accountSnapshot?.credits) + guard let model, !model.isOverviewErrorOnly + else { continue } + rows.append(( + provider: provider, + identifier: "\(Self.overviewRowIdentifierPrefix)codex-\(account.id)", + model: model)) + } + continue } + guard let model = self.menuCardModel(for: provider), !model.isOverviewErrorOnly else { continue } + rows.append((provider: provider, identifier: "\(Self.overviewRowIdentifierPrefix)\(provider.rawValue)", model: model)) + } guard !rows.isEmpty else { return false } let t0 = CACurrentMediaTime() @@ -622,17 +645,18 @@ extension StatusItemController { } for (index, row) in rows.enumerated() { - let identifier = "\(Self.overviewRowIdentifierPrefix)\(row.provider.rawValue)" let storageText = self.store.storageFootprintText(for: row.provider) - let submenu = self.makeOverviewRowSubmenu( - provider: row.provider, - model: row.model, - width: menuWidth) + let submenu = row.identifier.contains("codex-") + ? nil + : self.makeOverviewRowSubmenu( + provider: row.provider, + model: row.model, + width: menuWidth) let item = self.makeMenuCardItem( OverviewMenuCardRowView(model: row.model, storageText: storageText, width: menuWidth), - id: identifier, + id: row.identifier, width: menuWidth, - heightCacheScope: row.provider.rawValue, + heightCacheScope: row.identifier, heightCacheFingerprint: row.model.heightFingerprint( section: "overview", additional: [UsageMenuCardView.Model.heightFingerprintField("storage", storageText)]), diff --git a/Sources/CodexBar/StatusItemController+OverviewSubmenus.swift b/Sources/CodexBar/StatusItemController+OverviewSubmenus.swift index 1587d2113d..1a8d4b78f8 100644 --- a/Sources/CodexBar/StatusItemController+OverviewSubmenus.swift +++ b/Sources/CodexBar/StatusItemController+OverviewSubmenus.swift @@ -41,7 +41,8 @@ extension StatusItemController { return } let rawProvider = String(represented.dropFirst(Self.overviewRowIdentifierPrefix.count)) - guard let provider = UsageProvider(rawValue: rawProvider), + let providerRawValue = rawProvider.hasPrefix("codex-") ? UsageProvider.codex.rawValue : rawProvider + guard let provider = UsageProvider(rawValue: providerRawValue), let menu = sender.menu else { return diff --git a/Sources/CodexBarCLI/CLIPayloads.swift b/Sources/CodexBarCLI/CLIPayloads.swift index 2d39d5451c..2c4acac9c1 100644 --- a/Sources/CodexBarCLI/CLIPayloads.swift +++ b/Sources/CodexBarCLI/CLIPayloads.swift @@ -18,6 +18,7 @@ struct ProviderPayload: Encodable { let diagnostic: String? let error: ProviderErrorPayload? let pace: ProviderPacePayload? + let accountActive: Bool? private enum CodingKeys: String, CodingKey { case provider @@ -47,7 +48,8 @@ struct ProviderPayload: Encodable { openaiDashboard: OpenAIDashboardSnapshot?, error: ProviderErrorPayload?, diagnostic: String? = nil, - pace: ProviderPacePayload? = nil) + pace: ProviderPacePayload? = nil, + accountActive: Bool? = nil) { self.provider = provider.rawValue self.account = account @@ -62,6 +64,7 @@ struct ProviderPayload: Encodable { self.diagnostic = diagnostic self.error = error self.pace = pace + self.accountActive = accountActive } init( @@ -77,7 +80,8 @@ struct ProviderPayload: Encodable { openaiDashboard: OpenAIDashboardSnapshot?, error: ProviderErrorPayload?, diagnostic: String? = nil, - pace: ProviderPacePayload? = nil) + pace: ProviderPacePayload? = nil, + accountActive: Bool? = nil) { self.provider = providerID self.account = account @@ -92,6 +96,7 @@ struct ProviderPayload: Encodable { self.diagnostic = diagnostic self.error = error self.pace = pace + self.accountActive = accountActive } } diff --git a/Sources/CodexBarCLI/CLIServeCommand.swift b/Sources/CodexBarCLI/CLIServeCommand.swift index 9446591617..33c0a92cc9 100644 --- a/Sources/CodexBarCLI/CLIServeCommand.swift +++ b/Sources/CodexBarCLI/CLIServeCommand.swift @@ -1036,7 +1036,7 @@ extension CodexBarCLI { providerTimeout: providerTimeout, providerDeadline: providerDeadline, providerOperations: runtime.providerOperations, - includeAllCodexAccounts: false), + includeAllCodexAccounts: true), costCollection: ServeCostCollectionContext( configFingerprint: snapshot.cacheToken, providerTimeout: providerTimeout, diff --git a/Sources/CodexBarCLI/CLIServeWebUI+HTML.swift b/Sources/CodexBarCLI/CLIServeWebUI+HTML.swift index a4033e0dbd..ff5b5530c6 100644 --- a/Sources/CodexBarCLI/CLIServeWebUI+HTML.swift +++ b/Sources/CodexBarCLI/CLIServeWebUI+HTML.swift @@ -80,6 +80,42 @@ extension CLIServeWebUI { margin-bottom: 22px; } + .provider-group-heading { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + } + + .dashboard-settings { + position: relative; + } + + .dashboard-settings summary { + cursor: pointer; + } + + .dashboard-settings-panel { + position: absolute; + right: 0; + top: calc(100% + 8px); + z-index: 5; + width: 250px; + padding: 14px; + border: 1px solid var(--line); + border-radius: 12px; + background: var(--surface); + box-shadow: var(--shadow); + } + + .dashboard-settings-panel label { + display: flex; + align-items: center; + gap: 8px; + margin: 8px 0; + color: var(--text); + } + .brand { gap: 10px; min-width: 0; @@ -172,6 +208,13 @@ extension CLIServeWebUI { font-size: 12px; } + .settings-button { + display: inline-block; + padding: 3px 8px; + color: var(--muted); + font-size: 12px; + } + .sign-out.visible { display: inline-block; } @@ -609,6 +652,9 @@ extension CLIServeWebUI { const tokenKey = "codexbar.dashboardToken"; const snapshotKey = "codexbar.lastSnapshot"; + const preferenceKey = "codexbar.dashboardPreferences"; + const defaultPreferences = { hideSpark: false, showResetCredits: true }; + let preferences = loadPreferences(); const providerIconURLs = __PROVIDER_ICON_URLS__; const state = { snapshot: null, @@ -636,6 +682,52 @@ extension CLIServeWebUI { version: document.getElementById("version") }; + function loadPreferences() { + try { + const saved = JSON.parse(localStorage.getItem(preferenceKey) || "{}"); + // Earlier versions stored these Codex-only choices at the top level. + const codex = saved?.providers?.codex || saved || {}; + return { providers: { codex: { + hideSpark: typeof codex.hideSpark === "boolean" ? codex.hideSpark : defaultPreferences.hideSpark, + showResetCredits: typeof codex.showResetCredits === "boolean" + ? codex.showResetCredits : defaultPreferences.showResetCredits + } } }; + } catch (_) { + return { providers: { codex: { ...defaultPreferences } } }; + } + } + + function displayPreferences(providerID) { + return preferences.providers[providerID] || { hideSpark: false, showResetCredits: false }; + } + + function renderDisplaySettings(provider) { + // Only Codex currently has configurable display items. + if (provider.id !== "codex") return null; + const details = node("details", "dashboard-settings"); + const summary = node("summary", "settings-button", "Display"); + summary.setAttribute("aria-label", `${provider.name || provider.id} display settings`); + details.append(summary); + const panel = node("div", "dashboard-settings-panel"); + panel.append(node("strong", "", `${provider.name || provider.id} display`)); + for (const [key, text] of [["hideSpark", "Hide Spark usage"], + ["showResetCredits", "Show Limit Reset count"]]) { + const label = node("label"); + const input = node("input"); + input.type = "checkbox"; + input.checked = displayPreferences(provider.id)[key]; + input.addEventListener("change", () => { + preferences.providers[provider.id][key] = input.checked; + try { localStorage.setItem(preferenceKey, JSON.stringify(preferences)); } catch (_) {} + if (state.snapshot) renderSnapshot(state.snapshot, state.forceStale); + }); + label.append(input, node("span", "", text)); + panel.append(label); + } + details.append(panel); + return details; + } + function storedToken() { try { return localStorage.getItem(tokenKey) || ""; @@ -852,11 +944,12 @@ extension CLIServeWebUI { return dot; } - function visibleWindows(windows) { + function visibleWindows(windows, providerID) { // The producer marks a window idle when its whole model family reports no usage, which // the page cannot work out on its own: a zero percentage also stands for a lane the // provider never reported. Script clients still receive every window on the snapshot. - return (windows || []).filter(w => w.idle !== true); + return (windows || []).filter(w => w.idle !== true && + !(displayPreferences(providerID).hideSpark && (w.kind === "codex-spark" || w.kind === "codex-spark-weekly"))); } function worstWindowLevel(windows) { @@ -888,14 +981,15 @@ extension CLIServeWebUI { if (account.active) { head.append(pill("active", "active")); } else { - const level = worstWindowLevel(visibleWindows(account.windows)); + const level = worstWindowLevel(visibleWindows(account.windows, provider.id)); if (level) head.append(pill(level, level === "ok" ? "ok" : level === "warning" ? "high" : "critical")); } card.append(head); - if (account.updatedAt) { + if (account.updatedAt || account.identity?.plan) { const identity = node("div", "identity"); - identity.append(node("span", "", `updated ${relativeTime(account.updatedAt)}`)); + if (account.identity?.plan) identity.append(node("span", "", account.identity.plan)); + if (account.updatedAt) identity.append(node("span", "", `updated ${relativeTime(account.updatedAt)}`)); card.append(identity); } @@ -906,8 +1000,26 @@ extension CLIServeWebUI { } const windows = node("div", "windows"); - for (const window of visibleWindows(account.windows)) windows.append(renderWindow(window)); + for (const window of visibleWindows(account.windows, provider.id)) windows.append(renderWindow(window)); card.append(windows); + if (account.credits?.remaining !== null && account.credits?.remaining !== undefined) { + const credits = node("div", "metrics"); + const unit = account.credits.unit ? ` ${account.credits.unit}` : ""; + credits.append(metric("Remaining", `${amount(account.credits.remaining)}${unit}`)); + card.append(credits); + } + if (displayPreferences(provider.id).showResetCredits && account.resetCreditsAvailable !== null && + account.resetCreditsAvailable !== undefined) { + const metrics = node("div", "metrics"); + metrics.append(metric("Limit resets", amount(account.resetCreditsAvailable))); + card.append(metrics); + const nextReset = (account.resetCredits || []) + .filter(reset => reset && reset.expiresAt) + .sort((left, right) => dateValue(left.expiresAt) - dateValue(right.expiresAt))[0]; + if (nextReset) { + metrics.append(metric("Earliest expiry", relativeTime(nextReset.expiresAt))); + } + } return card; } @@ -937,6 +1049,8 @@ extension CLIServeWebUI { status.append(dot, node("span", "status-label", statusLabel)); head.append(status); } + const settings = renderDisplaySettings(provider); + if (settings) head.append(settings); card.append(head); if (provider._pending) { @@ -960,7 +1074,7 @@ extension CLIServeWebUI { } const windows = node("div", "windows"); - for (const window of visibleWindows(provider.windows)) windows.append(renderWindow(window)); + for (const window of visibleWindows(provider.windows, provider.id)) windows.append(renderWindow(window)); card.append(windows); if (provider.accountsError) { card.append(node("p", "error-message", provider.accountsError)); @@ -971,6 +1085,10 @@ extension CLIServeWebUI { const unit = provider.credits.unit ? ` ${provider.credits.unit}` : ""; metrics.append(metric("Remaining", `${amount(provider.credits.remaining)}${unit}`)); } + if (displayPreferences(provider.id).showResetCredits && provider.credits?.resetCreditsAvailable !== null && + provider.credits?.resetCreditsAvailable !== undefined) { + metrics.append(metric("Limit resets", amount(provider.credits.resetCreditsAvailable))); + } if (provider.cost?.todayUSD !== null && provider.cost?.todayUSD !== undefined) { metrics.append(metric("Today", dollars(provider.cost.todayUSD))); } @@ -1024,7 +1142,23 @@ extension CLIServeWebUI { const accounts = Array.isArray(provider.accounts) ? provider.accounts : []; if (accounts.length) { const group = node("section", "group"); - group.append(node("h2", "group-title", `${provider.name || provider.id} accounts`)); + const heading = node("div", "provider-group-heading"); + heading.append(node("h2", "group-title", `${provider.name || provider.id} accounts`)); + const settings = renderDisplaySettings(provider); + if (settings) heading.append(settings); + group.append(heading); + const summary = node("div", "metrics"); + if (provider.credits?.remaining !== null && provider.credits?.remaining !== undefined) { + const unit = provider.credits.unit ? ` ${provider.credits.unit}` : ""; + summary.append(metric("Remaining", `${amount(provider.credits.remaining)}${unit}`)); + } + if (provider.cost?.todayUSD !== null && provider.cost?.todayUSD !== undefined) { + summary.append(metric("Today", dollars(provider.cost.todayUSD))); + } + if (provider.cost?.last30DaysUSD !== null && provider.cost?.last30DaysUSD !== undefined) { + summary.append(metric("Last 30 days", dollars(provider.cost.last30DaysUSD))); + } + if (summary.childNodes.length) group.append(summary); const grid = node("div", "grid"); for (const account of accounts) grid.append(renderAccountCard(provider, account)); if (provider.accountsError) grid.append(node("p", "error-message", provider.accountsError)); diff --git a/Sources/CodexBarCLI/CLIUsageCommand.swift b/Sources/CodexBarCLI/CLIUsageCommand.swift index a5f9b464a3..d8caf81839 100644 --- a/Sources/CodexBarCLI/CLIUsageCommand.swift +++ b/Sources/CodexBarCLI/CLIUsageCommand.swift @@ -41,6 +41,7 @@ private struct UsageSuccessRenderInput { let provider: UsageProvider let accountLabel: String? let cacheAccountKey: String? + let accountActive: Bool? let version: String? let source: String let status: ProviderStatusPayload? @@ -378,6 +379,7 @@ extension CodexBarCLI { provider: UsageProvider, accountLabel: String?, cacheAccountKey: String?, + accountActive: Bool?, version: String?, source: String, status: ProviderStatusPayload?, @@ -401,7 +403,8 @@ extension CodexBarCLI { openaiDashboard: dashboard, error: nil, diagnostic: diagnostic, - pace: CLIRenderer.providerPacePayload(provider: provider, snapshot: usage, weeklyWorkDays: weeklyWorkDays)) + pace: CLIRenderer.providerPacePayload(provider: provider, snapshot: usage, weeklyWorkDays: weeklyWorkDays), + accountActive: accountActive) } private static func appendSuccessRenderOutput( @@ -448,6 +451,7 @@ extension CodexBarCLI { provider: input.provider, accountLabel: input.accountLabel, cacheAccountKey: input.cacheAccountKey, + accountActive: input.accountActive, version: input.version, source: input.source, status: input.status, @@ -576,6 +580,7 @@ extension CodexBarCLI { provider: provider, accountLabel: account?.label ?? codexVisibleAccount?.menuDisplayName, cacheAccountKey: cacheAccountKey, + accountActive: codexVisibleAccount?.isActive, version: version, source: source, status: status, diff --git a/Sources/CodexBarCLI/DashboardPayloads.swift b/Sources/CodexBarCLI/DashboardPayloads.swift index 8fd0eeb905..0c5d686fac 100644 --- a/Sources/CodexBarCLI/DashboardPayloads.swift +++ b/Sources/CodexBarCLI/DashboardPayloads.swift @@ -135,9 +135,12 @@ struct DashboardAccountPayload: Encodable { let active: Bool let identity: DashboardIdentityPayload? let windows: [DashboardWindowPayload] + let credits: DashboardCreditsPayload? let pace: ProviderPacePayload? let error: String? let updatedAt: Date? + let resetCreditsAvailable: Int? + let resetCredits: [DashboardResetCreditPayload]? private enum CodingKeys: String, CodingKey { case id @@ -145,9 +148,12 @@ struct DashboardAccountPayload: Encodable { case active case identity case windows + case credits case pace case error case updatedAt + case resetCreditsAvailable + case resetCredits } func encode(to encoder: Encoder) throws { @@ -157,12 +163,21 @@ struct DashboardAccountPayload: Encodable { try container.encode(self.active, forKey: .active) try container.encode(self.identity, forKey: .identity) try container.encode(self.windows, forKey: .windows) + try container.encodeIfPresent(self.credits, forKey: .credits) try container.encode(self.pace, forKey: .pace) try container.encode(self.error, forKey: .error) try container.encode(self.updatedAt, forKey: .updatedAt) + try container.encodeIfPresent(self.resetCreditsAvailable, forKey: .resetCreditsAvailable) + try container.encodeIfPresent(self.resetCredits, forKey: .resetCredits) } } +struct DashboardResetCreditPayload: Encodable { + let id: String + let title: String? + let expiresAt: Date? +} + struct DashboardStatusPayload: Encodable { let level: String let label: String @@ -252,6 +267,7 @@ struct DashboardWindowPayload: Encodable { struct DashboardCreditsPayload: Encodable { let remaining: Double let unit: String + let resetCreditsAvailable: Int? } struct DashboardCostPayload: Encodable { diff --git a/Sources/CodexBarCLI/DashboardSnapshotBuilder.swift b/Sources/CodexBarCLI/DashboardSnapshotBuilder.swift index ff51c6bd0e..428c11f701 100644 --- a/Sources/CodexBarCLI/DashboardSnapshotBuilder.swift +++ b/Sources/CodexBarCLI/DashboardSnapshotBuilder.swift @@ -33,7 +33,14 @@ enum DashboardSnapshotBuilder { costByProvider[cost.provider] = cost } var attachedClaudeSwap = false - let providers = usagePayloads.enumerated().map { index, payload in + let codexPayloads = usagePayloads.filter { $0.provider == UsageProvider.codex.rawValue } + var emittedCodexProvider = false + let providers = usagePayloads.enumerated().compactMap { index, payload -> DashboardProviderPayload? in + // Codex's all-account usage path emits one payload per visible account. Keep one + // provider row and project the remaining rows into the dashboard's account list. + let isCodex = payload.provider == UsageProvider.codex.rawValue + if isCodex, emittedCodexProvider { return nil } + if isCodex { emittedCodexProvider = true } var rowClaudeSwap: DashboardClaudeSwapInput? // Provider-specific by design: claude-swap account data belongs only on the first Claude row. if !attachedClaudeSwap, UsageProvider(rawValue: payload.provider) == .claude { @@ -44,13 +51,25 @@ enum DashboardSnapshotBuilder { id: payload.provider, config: config, fallbackSortKey: 10000 + index) + let providerPayload = isCodex + ? codexPayloads.first(where: { $0.accountActive == true }) ?? payload + : payload return self.makeProvider( - payload: payload, + payload: providerPayload, cost: costByProvider[payload.provider], presentation: presentation, identityMode: identityMode, generatedAt: generatedAt, - claudeSwap: rowClaudeSwap) + claudeSwap: rowClaudeSwap, + codexAccounts: payload.provider == UsageProvider.codex.rawValue && codexPayloads.count > 1 + ? codexPayloads.enumerated().map { accountIndex, accountPayload in + self.makeCodexAccount( + accountPayload, + active: accountPayload.accountActive ?? (accountIndex == 0), + identityMode: identityMode, + generatedAt: generatedAt) + } + : nil) } let refreshSeconds = self.dashboardRefreshSeconds(refreshInterval) @@ -113,14 +132,15 @@ enum DashboardSnapshotBuilder { presentation: ProviderPresentation, identityMode: DashboardIdentityMode, generatedAt: Date, - claudeSwap: DashboardClaudeSwapInput?) -> DashboardProviderPayload + claudeSwap: DashboardClaudeSwapInput?, + codexAccounts: [DashboardAccountPayload]? = nil) -> DashboardProviderPayload { let provider = UsageProvider(rawValue: payload.provider) let descriptor = provider.map { ProviderDescriptorRegistry.descriptor(for: $0) } let metadata = descriptor?.metadata let error = payload.error ?? cost?.error - let accounts = claudeSwap?.adapterError == nil + let accounts = codexAccounts ?? (claudeSwap?.adapterError == nil ? claudeSwap?.accounts?.map { account in self.makeClaudeSwapAccount( account, @@ -128,7 +148,7 @@ enum DashboardSnapshotBuilder { weeklyWorkDays: claudeSwap?.weeklyWorkDays, generatedAt: generatedAt) } - : nil + : nil) return DashboardProviderPayload( id: presentation.id, name: presentation.name, @@ -137,7 +157,7 @@ enum DashboardSnapshotBuilder { status: self.makeStatus(payload.status), identity: self.makeIdentity(provider: provider, usage: payload.usage, mode: identityMode), windows: self.makeWindows(provider: provider, metadata: metadata, usage: payload.usage), - credits: self.makeCredits(payload.credits), + credits: self.makeCredits(payload.credits, usage: payload.usage), cost: self.makeCost(cost, referenceDate: generatedAt), display: presentation.display, error: error, @@ -147,7 +167,41 @@ enum DashboardSnapshotBuilder { error: error, generatedAt: generatedAt), accounts: accounts, - accountsError: claudeSwap?.adapterError) + accountsError: codexAccounts == nil ? claudeSwap?.adapterError : nil) + } + + private static func makeCodexAccount( + _ payload: ProviderPayload, + active: Bool, + identityMode: DashboardIdentityMode, + generatedAt: Date) -> DashboardAccountPayload + { + let identity = self.makeIdentity( + provider: .codex, + usage: payload.usage, + mode: identityMode) + let rawLabel = payload.account ?? identity?.accountEmail ?? "Codex account" + let label = self.redactEmailShapedText(rawLabel, mode: identityMode) + let id = "codex-account-\(self.opaqueAccountID(payload.cacheAccountKey ?? rawLabel))" + return DashboardAccountPayload( + id: id, + label: label, + active: payload.accountActive ?? active, + identity: identity, + windows: self.makeWindows( + provider: .codex, + metadata: ProviderDescriptorRegistry.descriptor(for: .codex).metadata, + usage: payload.usage), + credits: self.makeCredits(payload.credits, usage: payload.usage), + pace: payload.pace, + error: payload.error?.message ?? payload.diagnostic, + updatedAt: payload.usage?.updatedAt ?? generatedAt, + resetCreditsAvailable: payload.usage?.codexResetCredits?.availableCount, + resetCredits: self.resetCredits(from: payload.usage, referenceDate: generatedAt)) + } + + private static func opaqueAccountID(_ value: String) -> String { + String(value.utf8.reduce(UInt64(14695981039346656037)) { ($0 ^ UInt64($1)) &* 1099511628211 }, radix: 16) } private static func providerPresentation( @@ -206,6 +260,7 @@ enum DashboardSnapshotBuilder { active: account.isActive, identity: identity, windows: self.makeWindows(provider: .claude, metadata: metadata, usage: account.snapshot), + credits: nil, pace: account.snapshot.flatMap { CLIRenderer.providerPacePayload( provider: .claude, @@ -214,7 +269,20 @@ enum DashboardSnapshotBuilder { now: generatedAt) }, error: account.error, - updatedAt: account.snapshot?.updatedAt) + updatedAt: account.snapshot?.updatedAt, + resetCreditsAvailable: nil, + resetCredits: nil) + } + + private static func resetCredits( + from usage: UsageSnapshot?, + referenceDate: Date) -> [DashboardResetCreditPayload]? { + guard let credits = usage?.codexResetCredits?.availableCredits(at: referenceDate), !credits.isEmpty else { + return nil + } + return credits.map { credit in + DashboardResetCreditPayload(id: credit.id, title: credit.title, expiresAt: credit.expiresAt) + } } private static func claudeSwapDashboardLabel( @@ -476,9 +544,15 @@ enum DashboardSnapshotBuilder { min(100, max(0, value)) } - private static func makeCredits(_ credits: CreditsSnapshot?) -> DashboardCreditsPayload? { + private static func makeCredits( + _ credits: CreditsSnapshot?, + usage: UsageSnapshot?) -> DashboardCreditsPayload? + { guard let credits else { return nil } - return DashboardCreditsPayload(remaining: credits.remaining, unit: "credits") + return DashboardCreditsPayload( + remaining: credits.remaining, + unit: "credits", + resetCreditsAvailable: usage?.codexResetCredits?.availableCount) } private static func makeCost(_ cost: CostPayload?, referenceDate: Date) -> DashboardCostPayload? { diff --git a/Tests/CodexBarTests/DashboardSnapshotBuilderTests.swift b/Tests/CodexBarTests/DashboardSnapshotBuilderTests.swift index 66a32f4d5b..4fea0cc2b2 100644 --- a/Tests/CodexBarTests/DashboardSnapshotBuilderTests.swift +++ b/Tests/CodexBarTests/DashboardSnapshotBuilderTests.swift @@ -214,6 +214,7 @@ struct DashboardSnapshotBuilderTests { let updatedAt = Date(timeIntervalSince1970: 1_800_000_010) let costUpdatedAt = Date(timeIntervalSince1970: 1_800_000_020) let resetAt = Date(timeIntervalSince1970: 1_800_003_600) + let resetCreditExpiry = Date(timeIntervalSince1970: 1_800_004_000) let generatedDay = self.gregorianDayKey(generatedAt) let usage = UsageSnapshot( primary: RateWindow( @@ -232,7 +233,20 @@ struct DashboardSnapshotBuilderTests { providerID: .codex, accountEmail: "user@example.com", accountOrganization: nil, - loginMethod: "pro")) + loginMethod: "pro"), + codexResetCredits: CodexRateLimitResetCreditsSnapshot( + credits: [CodexRateLimitResetCredit( + id: "reset-credit", + resetType: "codex_rate_limits", + status: .available, + grantedAt: generatedAt, + expiresAt: resetCreditExpiry, + redeemStartedAt: nil, + redeemedAt: nil, + title: "Weekly reset", + description: nil)], + availableCount: 1, + updatedAt: updatedAt)) let payload = ProviderPayload( provider: .codex, @@ -321,6 +335,7 @@ struct DashboardSnapshotBuilderTests { #expect(credits["remaining"] as? Double == 112.4) #expect(credits["unit"] as? String == "credits") + #expect(credits["resetCreditsAvailable"] as? Int == 1) #expect(costObject["todayUSD"] as? Double == 1.04) #expect(costObject["last30DaysUSD"] as? Double == 18.22) #expect(display["accentColor"] as? String == "#49A3B0") @@ -328,6 +343,68 @@ struct DashboardSnapshotBuilderTests { #expect(display["priority"] as? String == "normal") } + @Test + func `multi-account Codex dashboard preserves active account and reset details`() throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let expiry = Date(timeIntervalSince1970: 1_800_004_000) + let reset = CodexRateLimitResetCreditsSnapshot( + credits: [CodexRateLimitResetCredit( + id: "reset-credit", + resetType: "codex_rate_limits", + status: .available, + grantedAt: now, + expiresAt: expiry, + redeemStartedAt: nil, + redeemedAt: nil, + title: "Weekly reset", + description: nil)], + availableCount: 1, + updatedAt: now) + func payload(_ email: String, active: Bool) -> ProviderPayload { + ProviderPayload( + provider: .codex, + account: email, + cacheAccountKey: "cache-\(email)", + version: nil, + source: "oauth", + status: nil, + usage: UsageSnapshot( + primary: nil, + secondary: nil, + tertiary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: email, + accountOrganization: nil, + loginMethod: "pro"), + codexResetCredits: active ? reset : nil), + credits: CreditsSnapshot(remaining: 100, events: [], updatedAt: now), + antigravityPlanInfo: nil, + openaiDashboard: nil, + error: nil, + accountActive: active) + } + let snapshot = DashboardSnapshotBuilder.makeSnapshot( + usagePayloads: [payload("inactive@example.com", active: false), payload("active@example.com", active: true)], + costPayloads: [], + config: CodexBarConfig(providers: [ProviderConfig(id: .codex, enabled: true)]), + identityMode: .redacted, + generatedAt: now, + refreshInterval: 60, + codexBarVersion: nil) + let object = try self.jsonObject(snapshot) + let provider = try #require((object["providers"] as? [[String: Any]])?.first) + let accounts = try #require(provider["accounts"] as? [[String: Any]]) + #expect(accounts.count == 2) + #expect(accounts[0]["active"] as? Bool == false) + #expect(accounts[1]["active"] as? Bool == true) + #expect(accounts[1]["label"] as? String == "redacted@example.com") + #expect(accounts[1]["id"] as? String != "cache-active@example.com") + #expect(accounts[1]["resetCreditsAvailable"] as? Int == 1) + #expect((accounts[1]["credits"] as? [String: Any])?["remaining"] as? Double == 100) + } + @Test func `dashboard identity mode none emits null identity`() throws { let usage = UsageSnapshot(