From d2087228c6f4c2d96fc09cc68f1cbd240da12bd3 Mon Sep 17 00:00:00 2001 From: Yuxin-Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Sun, 2 Aug 2026 21:35:27 +0800 Subject: [PATCH] feat(overview): unified all-providers card with cross-provider totals Replace the merged Overview's per-provider card list with a single unified card: a cross-provider totals header (cost grouped by currency, tokens, coverage), a two-column chip grid with every enabled provider's live quota, and an incremental reset-info line. Each provider appears exactly once: errored providers render as inline error chips, exhausted providers show a red empty state. Click opens Preferences > Usage & Spend; the row submenu lists contributors and jumps to provider detail. --- Sources/CodexBar/OverviewTotalsModel.swift | 177 +++++++++++ .../CodexBar/OverviewUnifiedCardView.swift | 241 +++++++++++++++ .../Resources/ar.lproj/Localizable.strings | 3 + .../Resources/ca.lproj/Localizable.strings | 3 + .../Resources/de.lproj/Localizable.strings | 3 + .../Resources/en.lproj/Localizable.strings | 2 + .../Resources/es.lproj/Localizable.strings | 3 + .../Resources/fa.lproj/Localizable.strings | 3 + .../Resources/fr.lproj/Localizable.strings | 3 + .../Resources/gl.lproj/Localizable.strings | 3 + .../Resources/id.lproj/Localizable.strings | 3 + .../Resources/it.lproj/Localizable.strings | 3 + .../Resources/ja.lproj/Localizable.strings | 2 + .../Resources/ko.lproj/Localizable.strings | 2 + .../Resources/nl.lproj/Localizable.strings | 3 + .../Resources/pl.lproj/Localizable.strings | 3 + .../Resources/pt-BR.lproj/Localizable.strings | 3 + .../Resources/ru.lproj/Localizable.strings | 3 + .../Resources/sv.lproj/Localizable.strings | 3 + .../Resources/th.lproj/Localizable.strings | 3 + .../Resources/tr.lproj/Localizable.strings | 3 + .../Resources/uk.lproj/Localizable.strings | 3 + .../Resources/vi.lproj/Localizable.strings | 3 + .../zh-Hans.lproj/Localizable.strings | 2 + .../zh-Hant.lproj/Localizable.strings | 2 + .../StatusItemController+Actions.swift | 5 + .../CodexBar/StatusItemController+Menu.swift | 73 +---- .../StatusItemController+OverviewMenu.swift | 24 ++ .../StatusItemController+OverviewTotals.swift | 143 +++++++++ .../OverviewTotalsModelTests.swift | 125 ++++++++ .../StatusMenuOverviewUnifiedTests.swift | 284 ++++++++++++++++++ 31 files changed, 1068 insertions(+), 68 deletions(-) create mode 100644 Sources/CodexBar/OverviewTotalsModel.swift create mode 100644 Sources/CodexBar/OverviewUnifiedCardView.swift create mode 100644 Sources/CodexBar/StatusItemController+OverviewMenu.swift create mode 100644 Sources/CodexBar/StatusItemController+OverviewTotals.swift create mode 100644 Tests/CodexBarTests/OverviewTotalsModelTests.swift create mode 100644 Tests/CodexBarTests/StatusMenuOverviewUnifiedTests.swift diff --git a/Sources/CodexBar/OverviewTotalsModel.swift b/Sources/CodexBar/OverviewTotalsModel.swift new file mode 100644 index 0000000000..cf2d22f267 --- /dev/null +++ b/Sources/CodexBar/OverviewTotalsModel.swift @@ -0,0 +1,177 @@ +import CodexBarCore +import Foundation + +/// Cross-provider aggregate shown at the top of the merged Overview menu. Inputs are the +/// currently published, current-config token snapshots of enabled providers; the model only +/// sums compatible units (tokens are provider-agnostic, cost is grouped by effective currency). +struct OverviewTotalsModel: Equatable, Sendable { + struct ProviderInput: Sendable { + let provider: UsageProvider + let displayName: String + let snapshot: CostUsageTokenSnapshot + } + + struct ProviderContribution: Equatable, Sendable, Identifiable { + let provider: UsageProvider + let providerName: String + let totalTokens: Int? + let totalCost: Double? + let currencyCode: String + let coveredDayCount: Int + + var id: String { + self.provider.rawValue + } + } + + struct CurrencyGroup: Equatable, Sendable, Identifiable { + let currencyCode: String + let totalCost: Double? + let totalTokens: Int? + let coveredDayCount: Int + let contributions: [ProviderContribution] + + var id: String { + self.currencyCode + } + } + + let requestedDays: Int + let providerCount: Int + let groups: [CurrencyGroup] + + var totalTokens: Int? { + let tokenTotals = self.groups.map(\.totalTokens) + guard tokenTotals.allSatisfy({ $0 != nil }) else { return nil } + return tokenTotals.reduce(into: 0) { partial, total in + partial += total ?? 0 + } + } + + var coveredDayCount: Int { + self.groups.map(\.coveredDayCount).max() ?? self.requestedDays + } + + var heightFingerprint: String { + let groupFingerprints = self.groups.map { group in + [ + group.currencyCode, + group.totalCost.map { String($0) } ?? "-", + group.totalTokens.map { String($0) } ?? "-", + String(group.coveredDayCount), + group.contributions.map(\.provider.rawValue).joined(separator: ","), + ].joined(separator: "|") + } + return [ + "overviewTotals", + String(self.providerCount), + String(self.requestedDays), + groupFingerprints.joined(separator: ";"), + ].joined(separator: ":") + } + + static func build( + inputs: [ProviderInput], + requestedDays: Int = 30, + preferredCurrencyCode: String = "auto") -> OverviewTotalsModel? + { + guard inputs.count >= 2 else { return nil } + let classified = inputs.compactMap { input -> ClassifiedInput? in + guard let sourceCurrency = Self.currencyCode(input.snapshot.currencyCode) else { return nil } + let converted = UsageFormatter.convertedCost( + 1, + preferredCurrency: preferredCurrencyCode, + providerCurrency: sourceCurrency) + return ClassifiedInput( + currencyCode: converted.currencyCode, + input: input, + costMultiplier: converted.value) + } + guard !classified.isEmpty else { return nil } + + let groups = Dictionary(grouping: classified, by: { $0.currencyCode }) + .map { currencyCode, inputs in + Self.buildGroup(currencyCode: currencyCode, inputs: inputs) + } + .sorted { $0.currencyCode < $1.currencyCode } + return OverviewTotalsModel( + requestedDays: requestedDays, + providerCount: classified.count, + groups: groups) + } + + private struct ClassifiedInput { + let currencyCode: String + let input: ProviderInput + let costMultiplier: Double + } + + private static func buildGroup( + currencyCode: String, + inputs: [ClassifiedInput]) -> CurrencyGroup + { + var contributions: [ProviderContribution] = [] + var totalTokens: Int? + var tokenOverflowed = false + var tokenIncomplete = false + var totalCost: Double? + var costOverflowed = false + var maxCoveredDayCount = 0 + + for classified in inputs.sorted(by: { $0.input.provider.rawValue < $1.input.provider.rawValue }) { + let snapshot = classified.input.snapshot + maxCoveredDayCount = max(maxCoveredDayCount, snapshot.historyDays) + + let tokens = snapshot.last30DaysTokens + tokenIncomplete = tokenIncomplete || tokens == nil + if !tokenOverflowed, let tokens { + if let existingTokens = totalTokens { + let (partial, overflow) = existingTokens.addingReportingOverflow(tokens) + if overflow { + tokenOverflowed = true + totalTokens = nil + } else { + totalTokens = partial + } + } else { + totalTokens = tokens + } + } + + let cost = snapshot.last30DaysCostUSD.map { $0 * classified.costMultiplier } + if !costOverflowed, let cost, cost.isFinite { + if let existingCost = totalCost { + let sum = existingCost + cost + if sum.isFinite { + totalCost = sum + } else { + costOverflowed = true + totalCost = nil + } + } else { + totalCost = cost + } + } + + contributions.append(ProviderContribution( + provider: classified.input.provider, + providerName: classified.input.displayName, + totalTokens: tokens, + totalCost: cost, + currencyCode: currencyCode, + coveredDayCount: snapshot.historyDays)) + } + + return CurrencyGroup( + currencyCode: currencyCode, + totalCost: totalCost, + totalTokens: tokenIncomplete || tokenOverflowed ? nil : totalTokens, + coveredDayCount: maxCoveredDayCount, + contributions: contributions) + } + + private static func currencyCode(_ raw: String) -> String? { + let normalized = raw.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() + return normalized.isEmpty || normalized == "XXX" ? nil : normalized + } +} diff --git a/Sources/CodexBar/OverviewUnifiedCardView.swift b/Sources/CodexBar/OverviewUnifiedCardView.swift new file mode 100644 index 0000000000..6a98dac59c --- /dev/null +++ b/Sources/CodexBar/OverviewUnifiedCardView.swift @@ -0,0 +1,241 @@ +import CodexBarCore +import SwiftUI + +struct OverviewQuotaRow: Equatable, Identifiable { + let provider: UsageProvider + let displayName: String + let remainingPercent: Double? + let usedPercent: Double? + let percentLabel: String + let hasError: Bool + let errorText: String? + let resetText: String? + let progressColor: Color + + var id: String { + self.provider.rawValue + } + + var isExhausted: Bool { + !self.hasError && self.remainingPercent == 0 + } +} + +struct OverviewUnifiedModel: Equatable { + let totals: OverviewTotalsModel? + let quotaRows: [OverviewQuotaRow] + let resetLines: [String] + + var heightFingerprint: String { + let rows = self.quotaRows.map { row in + [ + row.provider.rawValue, + row.remainingPercent.map { String($0) } ?? "-", + row.hasError ? (row.errorText ?? "error") : "ok", + ].joined(separator: "|") + } + return [ + "overviewUnified", + self.totals?.heightFingerprint ?? "-", + rows.joined(separator: ";"), + self.resetLines.joined(separator: ";"), + ].joined(separator: ":") + } +} + +/// One-card Overview: totals plus every enabled provider's live quota in a two-column +/// chip grid. Each provider appears exactly once; the only extra line is upcoming reset +/// info. Per-provider details stay one drill-in away (submenu or provider switcher). +struct OverviewUnifiedCardView: View { + let model: OverviewUnifiedModel + let width: CGFloat + @Environment(\.menuItemHighlighted) private var isHighlighted + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + if let totals = self.model.totals { + self.totalsHeader(totals) + } + if !self.model.quotaRows.isEmpty { + self.quotaGrid + } + if !self.model.resetLines.isEmpty { + Text(self.model.resetLines.joined(separator: " · ")) + .font(.footnote) + .foregroundStyle(self.resetLineColor) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + } + } + .padding(.horizontal, UsageMenuCardLayout.horizontalPadding) + .padding(.vertical, 8) + .frame(width: self.width, alignment: .leading) + .accessibilityElement(children: .combine) + .accessibilityLabel(self.accessibilityText) + } + + private func totalsHeader(_ totals: OverviewTotalsModel) -> some View { + VStack(alignment: .leading, spacing: 3) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(L("overview_totals_title")) + .font(.headline.weight(.semibold)) + .foregroundStyle(MenuHighlightStyle.primary(self.isHighlighted)) + .lineLimit(1) + .truncationMode(.tail) + .layoutPriority(1) + Spacer(minLength: 8) + Text(self.costSummary(totals)) + .font(.subheadline.monospacedDigit()) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + .lineLimit(1) + .truncationMode(.tail) + } + if let detail = self.totalsDetail(totals) { + Text(detail) + .font(.footnote) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + .lineLimit(1) + .truncationMode(.tail) + } + } + } + + private var quotaGrid: some View { + let columns = [ + GridItem(.flexible(), spacing: 8, alignment: .leading), + GridItem(.flexible(), spacing: 8, alignment: .leading), + ] + return LazyVGrid(columns: columns, alignment: .leading, spacing: 6) { + ForEach(self.model.quotaRows) { row in + self.chip(row) + } + } + } + + private func costSummary(_ totals: OverviewTotalsModel) -> String { + let priced = totals.groups.compactMap { group -> String? in + guard let cost = group.totalCost else { return nil } + return UsageFormatter.currencyString(cost, currencyCode: group.currencyCode) + } + return priced.isEmpty ? L("overview_totals_unpriced") : priced.joined(separator: " · ") + } + + private func totalsDetail(_ totals: OverviewTotalsModel) -> String? { + var parts: [String] = [] + if let tokens = totals.totalTokens { + parts.append("\(UsageFormatter.tokenCountString(tokens)) \(L("tokens"))") + } + parts.append(spendDashboardDayRangeText(totals.coveredDayCount)) + return parts.joined(separator: " · ") + } + + @ViewBuilder + private func chip(_ row: OverviewQuotaRow) -> some View { + if row.hasError, row.remainingPercent == nil { + self.errorChip(row) + } else { + self.quotaChip(row) + } + } + + private func quotaChip(_ row: OverviewQuotaRow) -> some View { + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 6) { + Circle() + .fill(self.dotColor(row)) + .frame(width: 6, height: 6) + Text(row.displayName) + .font(.footnote.weight(.medium)) + .foregroundStyle(MenuHighlightStyle.primary(self.isHighlighted)) + .lineLimit(1) + .truncationMode(.tail) + Spacer(minLength: 4) + Text(row.percentLabel) + .font(.footnote.monospacedDigit()) + .foregroundStyle(self.percentColor(row)) + .lineLimit(1) + .layoutPriority(1) + } + self.miniBar(row) + } + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityElement(children: .combine) + .accessibilityLabel("\(row.displayName) \(row.percentLabel)") + } + + private func errorChip(_ row: OverviewQuotaRow) -> some View { + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 6) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.caption2) + .foregroundStyle(self.errorColor) + Text(row.displayName) + .font(.footnote.weight(.medium)) + .foregroundStyle(self.errorColor) + .lineLimit(1) + Spacer(minLength: 4) + } + Text(row.errorText ?? "") + .font(.footnote) + .foregroundStyle(self.errorColor) + .lineLimit(1) + .truncationMode(.tail) + } + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityElement(children: .combine) + .accessibilityLabel("\(row.displayName) \(row.errorText ?? "")") + } + + private func miniBar(_ row: OverviewQuotaRow) -> some View { + let fraction = (row.remainingPercent ?? 0) / 100 + return GeometryReader { proxy in + ZStack(alignment: .leading) { + Capsule() + .fill(MenuHighlightStyle.progressTrack(self.isHighlighted)) + Capsule() + .fill(self.dotColor(row)) + .frame(width: proxy.size.width * min(1, max(0, fraction))) + } + } + .frame(height: 4) + .frame(maxWidth: .infinity) + } + + private var errorColor: Color { + guard !self.isHighlighted else { return MenuHighlightStyle.selectionText } + return MenuHighlightStyle.error(false) + } + + private var resetLineColor: Color { + guard !self.isHighlighted else { return MenuHighlightStyle.selectionText } + return Color(nsColor: .systemOrange) + } + + private func dotColor(_ row: OverviewQuotaRow) -> Color { + guard !self.isHighlighted else { return MenuHighlightStyle.selectionText } + if row.hasError { + return MenuHighlightStyle.error(false) + } + if row.isExhausted { + return Color(nsColor: .systemRed) + } + return row.progressColor + } + + private func percentColor(_ row: OverviewQuotaRow) -> Color { + guard !self.isHighlighted else { return MenuHighlightStyle.selectionText } + if row.hasError || row.isExhausted { + return Color(nsColor: .systemRed) + } + return MenuHighlightStyle.secondary(false) + } + + private var accessibilityText: String { + var parts = [L("overview_totals_title")] + parts.append(contentsOf: self.model.quotaRows.map { + $0.hasError ? "\($0.displayName) \($0.errorText ?? "")" : "\($0.displayName) \($0.percentLabel)" + }) + parts.append(contentsOf: self.model.resetLines) + return parts.joined(separator: ", ") + } +} diff --git a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings index 656e84539a..b195300671 100644 --- a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings @@ -603,6 +603,9 @@ "overview_enable_merge_icons_hint" = "تفعيل أيقونات الدمج لتكوين مزودي تبويب النظرة العامة."; "overview_no_providers_hint" = "لا يوجد مزودون مفعلون متاحون للنظرة العامة."; "overview_rows_follow_order" = "الصفوف العامة دائما تتبع ترتيب مقدم الخدمة."; + +"overview_totals_title" = "All providers"; +"overview_totals_unpriced" = "Unpriced"; "overview_no_providers_selected" = "لم يتم اختيار أي مقدمي خدمة"; "agent_sessions_title" = "جلسات الوكلاء"; "agent_sessions_subtitle" = "إظهار جلسات Codex وClaude Code المحلية والمكتشفة عبر SSH في القائمة."; diff --git a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings index 1921f5a94d..3fd1ce6b09 100644 --- a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings @@ -581,6 +581,9 @@ "overview_enable_merge_icons_hint" = "Activeu Combina les icones per configurar els proveïdors de la pestanya Resum."; "overview_no_providers_hint" = "No hi ha proveïdors activats disponibles per al Resum."; "overview_rows_follow_order" = "Les files del Resum sempre segueixen l'ordre dels proveïdors."; + +"overview_totals_title" = "All providers"; +"overview_totals_unpriced" = "Unpriced"; "overview_no_providers_selected" = "No hi ha cap proveïdor seleccionat"; "agent_sessions_title" = "Sessions d'agents"; "agent_sessions_subtitle" = "Mostreu al menú les sessions locals i descobertes per SSH de Codex i Claude Code."; diff --git a/Sources/CodexBar/Resources/de.lproj/Localizable.strings b/Sources/CodexBar/Resources/de.lproj/Localizable.strings index 2986c311d4..6c51dec860 100644 --- a/Sources/CodexBar/Resources/de.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/de.lproj/Localizable.strings @@ -595,6 +595,9 @@ "overview_enable_merge_icons_hint" = "Aktivieren Sie \"Symbole zusammenführen\", um Anbieter für die Registerkarte \"Übersicht\" zu konfigurieren."; "overview_no_providers_hint" = "Für die Übersicht sind keine aktivierten Anbieter verfügbar."; "overview_rows_follow_order" = "Übersichtszeilen folgen immer der Anbieterreihenfolge."; + +"overview_totals_title" = "All providers"; +"overview_totals_unpriced" = "Unpriced"; "overview_no_providers_selected" = "Keine Anbieter ausgewählt"; "agent_sessions_title" = "Agenten-Sitzungen"; "agent_sessions_subtitle" = "Lokale und über SSH erkannte Codex- und Claude-Code-Sitzungen im Menü anzeigen."; diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index 18b42e8801..f02cf5032f 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -604,6 +604,8 @@ "overview_no_providers_hint" = "No enabled providers available for Overview."; "overview_rows_follow_order" = "Overview rows always follow provider order."; "overview_no_providers_selected" = "No providers selected"; +"overview_totals_title" = "All providers"; +"overview_totals_unpriced" = "Unpriced"; "agent_sessions_title" = "Agent sessions"; "agent_sessions_subtitle" = "Show local and SSH-discovered Codex and Claude Code sessions in the menu."; "agent_sessions_hosts_title" = "Additional SSH hosts"; diff --git a/Sources/CodexBar/Resources/es.lproj/Localizable.strings b/Sources/CodexBar/Resources/es.lproj/Localizable.strings index 656ac7975c..7bf73424c6 100644 --- a/Sources/CodexBar/Resources/es.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/es.lproj/Localizable.strings @@ -589,6 +589,9 @@ "overview_enable_merge_icons_hint" = "Activa Combinar iconos para configurar los proveedores de la pestaña Resumen."; "overview_no_providers_hint" = "No hay proveedores activados disponibles para Resumen."; "overview_rows_follow_order" = "Las filas de Resumen siempre siguen el orden de los proveedores."; + +"overview_totals_title" = "All providers"; +"overview_totals_unpriced" = "Unpriced"; "overview_no_providers_selected" = "No hay proveedores seleccionados"; "agent_sessions_title" = "Sesiones de agentes"; "agent_sessions_subtitle" = "Muestra en el menú las sesiones de Codex y Claude Code locales y detectadas mediante SSH."; diff --git a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings index cdea6d030b..2aa871aee1 100644 --- a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings @@ -603,6 +603,9 @@ "overview_enable_merge_icons_hint" = "فعال سازی Merge Icons برای پیکربندی ارائه دهندگان تب نمای کلی."; "overview_no_providers_hint" = "هیچ ارائه دهنده فعالی برای مرور کلی در دسترس نیست."; "overview_rows_follow_order" = "ردیف های نمای کلی همیشه مطابق با ترتیب ارائه دهنده انجام می شوند."; + +"overview_totals_title" = "All providers"; +"overview_totals_unpriced" = "Unpriced"; "overview_no_providers_selected" = "هیچ ارائه دهنده ای انتخاب نشده است"; "agent_sessions_title" = "جلسات عامل‌ها"; "agent_sessions_subtitle" = "جلسات محلی و جلسات Codex و Claude Code یافته‌شده از طریق SSH را در منو نمایش دهید."; diff --git a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings index ff583450d9..902f0e8195 100644 --- a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings @@ -597,6 +597,9 @@ "overview_enable_merge_icons_hint" = "Activez Fusionner les icônes pour configurer les fournisseurs d'onglets Présentation."; "overview_no_providers_hint" = "Aucun fournisseur activé disponible pour la présentation."; "overview_rows_follow_order" = "Les lignes de présentation suivent toujours l’ordre des fournisseurs."; + +"overview_totals_title" = "All providers"; +"overview_totals_unpriced" = "Unpriced"; "overview_no_providers_selected" = "Aucun fournisseur sélectionné"; "agent_sessions_title" = "Sessions d’agents"; "agent_sessions_subtitle" = "Afficher dans le menu les sessions Codex et Claude Code locales et découvertes via SSH."; diff --git a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings index 43bd695894..31d11471c3 100644 --- a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings @@ -576,6 +576,9 @@ "overview_enable_merge_icons_hint" = "Activa Combinar as iconas para configurar os provedores da lapela Resumo."; "overview_no_providers_hint" = "Non hai provedores activados dispoñibles para o Resumo."; "overview_rows_follow_order" = "As filas de Resumo sempre seguen a orde dos provedores."; + +"overview_totals_title" = "All providers"; +"overview_totals_unpriced" = "Unpriced"; "overview_no_providers_selected" = "Non se seleccionou ningún provedor"; "agent_sessions_title" = "Sesións de axentes"; "agent_sessions_subtitle" = "Amosa no menú as sesións de Codex e Claude Code locais e descubertas mediante SSH."; diff --git a/Sources/CodexBar/Resources/id.lproj/Localizable.strings b/Sources/CodexBar/Resources/id.lproj/Localizable.strings index 275d7b769e..6beb4d1300 100644 --- a/Sources/CodexBar/Resources/id.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/id.lproj/Localizable.strings @@ -605,6 +605,9 @@ "overview_enable_merge_icons_hint" = "Aktifkan Gabung Ikon untuk mengonfigurasi penyedia tab Ikhtisar."; "overview_no_providers_hint" = "Tidak ada penyedia aktif untuk Ikhtisar."; "overview_rows_follow_order" = "Baris ikhtisar selalu mengikuti urutan penyedia."; + +"overview_totals_title" = "All providers"; +"overview_totals_unpriced" = "Unpriced"; "overview_no_providers_selected" = "Tidak ada penyedia dipilih"; "agent_sessions_title" = "Sesi agen"; "agent_sessions_subtitle" = "Tampilkan sesi Codex dan Claude Code lokal serta yang ditemukan melalui SSH di menu."; diff --git a/Sources/CodexBar/Resources/it.lproj/Localizable.strings b/Sources/CodexBar/Resources/it.lproj/Localizable.strings index 6ce92c2a94..a3bc50f858 100644 --- a/Sources/CodexBar/Resources/it.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/it.lproj/Localizable.strings @@ -605,6 +605,9 @@ "overview_enable_merge_icons_hint" = "Abilita Unisci icone per configurare i provider della scheda Panoramica."; "overview_no_providers_hint" = "Nessun provider attivo disponibile per Panoramica."; "overview_rows_follow_order" = "Le righe Panoramica seguono sempre l'ordine dei provider."; + +"overview_totals_title" = "All providers"; +"overview_totals_unpriced" = "Unpriced"; "overview_no_providers_selected" = "Nessun provider selezionato"; "agent_sessions_title" = "Sessioni degli agenti"; "agent_sessions_subtitle" = "Mostra nel menu le sessioni Codex e Claude Code locali e rilevate tramite SSH."; diff --git a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings index 7b6911423d..75fb3bdd71 100644 --- a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings @@ -595,6 +595,8 @@ "overview_no_providers_hint" = "概要に使用できる有効なプロバイダがありません。"; "overview_rows_follow_order" = "概要の行は常にプロバイダの並び順に従います。"; "overview_no_providers_selected" = "プロバイダが選択されていません"; +"overview_totals_title" = "すべてのプロバイダ"; +"overview_totals_unpriced" = "価格情報なし"; "agent_sessions_title" = "エージェントセッション"; "agent_sessions_subtitle" = "ローカルおよび SSH で検出された Codex と Claude Code のセッションをメニューに表示します。"; "agent_sessions_hosts_title" = "追加の SSH ホスト"; diff --git a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings index 7458d95078..3b8085a0d6 100644 --- a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings @@ -587,6 +587,8 @@ "overview_no_providers_hint" = "개요에 사용할 수 있는 활성화된 공급자가 없습니다."; "overview_rows_follow_order" = "개요 행은 항상 공급자 순서를 따릅니다."; "overview_no_providers_selected" = "선택된 공급자 없음"; +"overview_totals_title" = "모든 공급자"; +"overview_totals_unpriced" = "가격 정보 없음"; "agent_sessions_title" = "에이전트 세션"; "agent_sessions_subtitle" = "메뉴에 로컬 및 SSH로 검색된 Codex 및 Claude Code 세션을 표시합니다."; "agent_sessions_hosts_title" = "추가 SSH 호스트"; diff --git a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings index cbe4af8954..63e62482a8 100644 --- a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings @@ -597,6 +597,9 @@ "overview_enable_merge_icons_hint" = "Schakel Pictogrammen samenvoegen in om de providers van tabbladen Overzicht te configureren."; "overview_no_providers_hint" = "Er zijn geen ingeschakelde providers beschikbaar voor Overzicht."; "overview_rows_follow_order" = "Overzichtsrijen volgen altijd de volgorde van de provider."; + +"overview_totals_title" = "All providers"; +"overview_totals_unpriced" = "Unpriced"; "overview_no_providers_selected" = "Geen aanbieders geselecteerd"; "agent_sessions_title" = "Agentsessies"; "agent_sessions_subtitle" = "Toon lokale en via SSH gevonden Codex- en Claude Code-sessies in het menu."; diff --git a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings index 9958e32f9e..c615a98f1c 100644 --- a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings @@ -605,6 +605,9 @@ "overview_enable_merge_icons_hint" = "Włącz Scal ikony, aby skonfigurować dostawców zakładki Przegląd."; "overview_no_providers_hint" = "Brak włączonych dostawców dla Przeglądu."; "overview_rows_follow_order" = "Wiersze Przeglądu zawsze podążają za kolejnością dostawców."; + +"overview_totals_title" = "All providers"; +"overview_totals_unpriced" = "Unpriced"; "overview_no_providers_selected" = "Nie wybrano dostawców"; "agent_sessions_title" = "Sesje agentów"; "agent_sessions_subtitle" = "Pokazuj w menu lokalne oraz wykryte przez SSH sesje Codex i Claude Code."; diff --git a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings index 434ba9148c..74674b569f 100644 --- a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings @@ -594,6 +594,9 @@ "overview_enable_merge_icons_hint" = "Ative Mesclar Ícones para configurar provedores da aba Visão geral."; "overview_no_providers_hint" = "Nenhum provedor ativado disponível para Visão geral."; "overview_rows_follow_order" = "As linhas da Visão geral sempre seguem a ordem dos provedores."; + +"overview_totals_title" = "All providers"; +"overview_totals_unpriced" = "Unpriced"; "overview_no_providers_selected" = "Nenhum provedor selecionado"; "agent_sessions_title" = "Sessões de agentes"; "agent_sessions_subtitle" = "Mostra no menu sessões locais e descobertas via SSH do Codex e Claude Code."; diff --git a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings index a1015d75f2..528c730d8a 100644 --- a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings @@ -598,6 +598,9 @@ "overview_enable_merge_icons_hint" = "Включите «Объединять значки», чтобы настроить провайдеров вкладки «Обзор»."; "overview_no_providers_hint" = "Нет включённых провайдеров для обзора."; "overview_rows_follow_order" = "Строки обзора всегда следуют порядку провайдеров."; + +"overview_totals_title" = "All providers"; +"overview_totals_unpriced" = "Unpriced"; "overview_no_providers_selected" = "Провайдеры не выбраны"; "agent_sessions_title" = "Сеансы агентов"; "agent_sessions_subtitle" = "Показывать в меню локальные и обнаруженные по SSH сеансы Codex и Claude Code."; diff --git a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings index 541451b974..59ff9ab50b 100644 --- a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings @@ -596,6 +596,9 @@ "overview_enable_merge_icons_hint" = "Aktivera Slå ihop ikoner för att konfigurera leverantörer på översiktsfliken."; "overview_no_providers_hint" = "Inga aktiverade leverantörer är tillgängliga för översikten."; "overview_rows_follow_order" = "Översiktsrader följer alltid leverantörsordningen."; + +"overview_totals_title" = "All providers"; +"overview_totals_unpriced" = "Unpriced"; "overview_no_providers_selected" = "Inga leverantörer valda"; "agent_sessions_title" = "Agentsessioner"; "agent_sessions_subtitle" = "Visa lokala och via SSH upptäckta Codex- och Claude Code-sessioner i menyn."; diff --git a/Sources/CodexBar/Resources/th.lproj/Localizable.strings b/Sources/CodexBar/Resources/th.lproj/Localizable.strings index 4e0f2ff782..e5643629af 100644 --- a/Sources/CodexBar/Resources/th.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/th.lproj/Localizable.strings @@ -603,6 +603,9 @@ "overview_enable_merge_icons_hint" = "เปิดใช้งานไอคอนผสานเพื่อกําหนดค่าผู้ให้บริการแท็บภาพรวม"; "overview_no_providers_hint" = "ไม่มีผู้ให้บริการที่เปิดใช้งานสําหรับภาพรวม"; "overview_rows_follow_order" = "แถวภาพรวมจะเป็นไปตามลําดับของผู้ให้บริการเสมอ"; + +"overview_totals_title" = "All providers"; +"overview_totals_unpriced" = "Unpriced"; "overview_no_providers_selected" = "ไม่มีผู้ให้บริการที่เลือก"; "agent_sessions_title" = "เซสชันเอเจนต์"; "agent_sessions_subtitle" = "แสดงเซสชัน Codex และ Claude Code ที่ค้นพบในเครื่องและผ่าน SSH ในเมนู"; diff --git a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings index 48a5096e45..054695019b 100644 --- a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings @@ -603,6 +603,9 @@ "overview_enable_merge_icons_hint" = "Genel Bakış sekmesi sağlayıcılarını yapılandırmak için Simgeleri Birleştir'i etkinleştirin."; "overview_no_providers_hint" = "Genel Bakış için kullanılabilir etkin sağlayıcı yok."; "overview_rows_follow_order" = "Genel Bakış satırları her zaman sağlayıcı sırasını takip eder."; + +"overview_totals_title" = "All providers"; +"overview_totals_unpriced" = "Unpriced"; "overview_no_providers_selected" = "Sağlayıcı seçilmedi"; "agent_sessions_title" = "Ajan oturumları"; "agent_sessions_subtitle" = "Menüde yerel ve SSH ile keşfedilen Codex ve Claude Code oturumlarını göster."; diff --git a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings index 1915ea7bf9..1d715b1002 100644 --- a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings @@ -597,6 +597,9 @@ "overview_enable_merge_icons_hint" = "Увімкніть Merge Icons, щоб налаштувати постачальників вкладок «Огляд»."; "overview_no_providers_hint" = "Немає активованих постачальників, доступних для огляду."; "overview_rows_follow_order" = "Оглядові рядки завжди відповідають порядку постачальника."; + +"overview_totals_title" = "All providers"; +"overview_totals_unpriced" = "Unpriced"; "overview_no_providers_selected" = "Не вибрано жодного постачальника"; "agent_sessions_title" = "Сеанси агентів"; "agent_sessions_subtitle" = "Показувати в меню локальні й виявлені через SSH сеанси Codex і Claude Code."; diff --git a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings index 02c9b8fdf3..500af357eb 100644 --- a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings @@ -593,6 +593,9 @@ "overview_enable_merge_icons_hint" = "Bật Hợp nhất Biểu tượng để định cấu hình nhà cung cấp tab Tổng quan."; "overview_no_providers_hint" = "Không có nhà cung cấp nào được bật cho phần Tổng quan."; "overview_rows_follow_order" = "Các hàng tổng quan luôn tuân theo thứ tự Nhà cung cấp."; + +"overview_totals_title" = "All providers"; +"overview_totals_unpriced" = "Unpriced"; "overview_no_providers_selected" = "Không có nhà cung cấp nào được chọn"; "agent_sessions_title" = "Phiên tác nhân"; "agent_sessions_subtitle" = "Hiển thị các phiên Codex và Claude Code cục bộ cùng các phiên được phát hiện qua SSH trong menu."; diff --git a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings index 06fc0d4755..347c05a620 100644 --- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -574,6 +574,8 @@ "overview_no_providers_hint" = "“概览”中没有可用的已启用提供商。"; "overview_rows_follow_order" = "概览行始终遵循提供商顺序。"; "overview_no_providers_selected" = "未选择提供商"; +"overview_totals_title" = "所有提供商"; +"overview_totals_unpriced" = "未定价"; "agent_sessions_title" = "智能体会话"; "agent_sessions_subtitle" = "在菜单中显示本地及通过 SSH 发现的 Codex 和 Claude Code 会话。"; "agent_sessions_hosts_title" = "其他 SSH 主机"; diff --git a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings index ec01ed18f3..4fe3ba5c80 100644 --- a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings @@ -597,6 +597,8 @@ "overview_no_providers_hint" = "「概覽」中沒有可用的已啟用提供者。"; "overview_rows_follow_order" = "概覽列一律依提供者順序排列。"; "overview_no_providers_selected" = "未選擇提供者"; +"overview_totals_title" = "所有提供者"; +"overview_totals_unpriced" = "未定價"; "agent_sessions_title" = "Agent 工作階段"; "agent_sessions_subtitle" = "在選單中顯示本機及透過 SSH 發現的 Codex 和 Claude Code 工作階段。"; "agent_sessions_hosts_title" = "其他 SSH 主機"; diff --git a/Sources/CodexBar/StatusItemController+Actions.swift b/Sources/CodexBar/StatusItemController+Actions.swift index b7a74b7370..63a2ce5808 100644 --- a/Sources/CodexBar/StatusItemController+Actions.swift +++ b/Sources/CodexBar/StatusItemController+Actions.swift @@ -614,6 +614,11 @@ extension StatusItemController: StatusItemMenuPersistentActionDelegate { } } + func openUsageSpendPane() { + self.preferencesSelection.pane = .usageSpend + self.openSettings(pane: nil) + } + @objc func quit() { let openMenus = Array(self.openMenus.values) for menu in openMenus { diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 7f18e31a42..e3f329a2fc 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -8,7 +8,7 @@ import SwiftUI extension StatusItemController { static let menuCardBaseWidth: CGFloat = 310 - private static let maxOverviewProviders = SettingsStore.mergedOverviewProviderLimit + static let overviewUnifiedRowIdentifier = "overviewUnifiedRow" static let overviewRowIdentifierPrefix = "overviewRow-" static let persistentRefreshMenuItemID = "persistentRefreshAction" private static let defaultMenuOpenRefreshDelay: Duration = .seconds(1.2) @@ -557,70 +557,13 @@ extension StatusItemController { menuWidth: CGFloat, captureMenu: NSMenu? = nil) -> Bool { - // Rows may be built into a detached scratch menu for in-place reconciliation; - // interaction closures must always reference the live menu they end up serving. - let interactionMenu = captureMenu ?? menu - let overviewProviders = self.settings.reconcileMergedOverviewSelectedProviders( - activeProviders: enabledProviders) - let rows: [(provider: UsageProvider, model: UsageMenuCardView.Model)] = overviewProviders - .compactMap { provider in - guard let model = self.menuCardModel(for: provider) else { return nil } - guard !model.isOverviewErrorOnly else { return nil } - return (provider: provider, model: model) - } - guard !rows.isEmpty else { return false } - - let t0 = CACurrentMediaTime() - defer { self.logChartRenderDurationIfSlow("addOverviewRows(\(rows.count))", startedAt: t0) } - - 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 item = self.makeMenuCardItem( - OverviewMenuCardRowView(model: row.model, storageText: storageText, width: menuWidth), - id: identifier, - width: menuWidth, - heightCacheScope: row.provider.rawValue, - heightCacheFingerprint: row.model.heightFingerprint( - section: "overview", - additional: [UsageMenuCardView.Model.heightFingerprintField("storage", storageText)]), - submenu: submenu, - containsInteractiveControls: row.model.subtitleStyle == .error || row.model.usesLiveSubtitle, - usesGPUSelection: true, - onClick: { [weak self, weak interactionMenu] in - guard let self, let interactionMenu else { return } - self.selectOverviewProvider(row.provider, menu: interactionMenu) - }) - if submenu == nil { - // Keep plain rows wired for keyboard activation and accessibility action paths. - item.target = self - item.action = #selector(self.selectOverviewProvider(_:)) - } - menu.addItem(item) - if index < rows.count - 1 { - menu.addItem(.separator()) - } + guard let unifiedModel = self.makeOverviewUnifiedModel(enabledProviders: enabledProviders) else { + return false } + self.addOverviewUnifiedCard(unifiedModel, to: menu, menuWidth: menuWidth) return true } - private func addOverviewEmptyState(to menu: NSMenu, enabledProviders: [UsageProvider]) { - let resolvedProviders = self.settings.resolvedMergedOverviewProviders( - activeProviders: enabledProviders, - maxVisibleProviders: Self.maxOverviewProviders) - let message = resolvedProviders.isEmpty - ? L("No providers selected for Overview.") - : L("No overview data available.") - let item = NSMenuItem(title: message, action: nil, keyEquivalent: "") - item.isEnabled = false - item.representedObject = "overviewEmptyState" - menu.addItem(item) - } - private func addMenuCards(to menu: NSMenu, context: MenuCardContext, captureMenu: NSMenu? = nil) -> Bool { if let codexAccountDisplay = context.codexAccountDisplay, codexAccountDisplay.showAll { if !self.addCompactCodexAccountMenuIfPlanned( @@ -1094,12 +1037,6 @@ extension StatusItemController { return enabled.first(where: { self.store.isProviderAvailable($0) }) ?? enabled.first } - func includesOverviewTab(enabledProviders: [UsageProvider]) -> Bool { - !self.settings.resolvedMergedOverviewProviders( - activeProviders: enabledProviders, - maxVisibleProviders: Self.maxOverviewProviders).isEmpty - } - func resolvedSwitcherSelection( enabledProviders: [UsageProvider], includesOverview: Bool) -> ProviderSwitcherSelection @@ -1235,7 +1172,7 @@ extension StatusItemController { { return self.settings.resolvedMergedOverviewProviders( activeProviders: enabledProviders, - maxVisibleProviders: Self.maxOverviewProviders) + maxVisibleProviders: SettingsStore.mergedOverviewProviderLimit) } if let provider = self.menuProvider(for: menu) diff --git a/Sources/CodexBar/StatusItemController+OverviewMenu.swift b/Sources/CodexBar/StatusItemController+OverviewMenu.swift new file mode 100644 index 0000000000..8264e5f2db --- /dev/null +++ b/Sources/CodexBar/StatusItemController+OverviewMenu.swift @@ -0,0 +1,24 @@ +import AppKit +import CodexBarCore +import SwiftUI + +extension StatusItemController { + func includesOverviewTab(enabledProviders: [UsageProvider]) -> Bool { + !self.settings.resolvedMergedOverviewProviders( + activeProviders: enabledProviders, + maxVisibleProviders: SettingsStore.mergedOverviewProviderLimit).isEmpty + } + + func addOverviewEmptyState(to menu: NSMenu, enabledProviders: [UsageProvider]) { + let resolvedProviders = self.settings.resolvedMergedOverviewProviders( + activeProviders: enabledProviders, + maxVisibleProviders: SettingsStore.mergedOverviewProviderLimit) + let message = resolvedProviders.isEmpty + ? L("No providers selected for Overview.") + : L("No overview data available.") + let item = NSMenuItem(title: message, action: nil, keyEquivalent: "") + item.isEnabled = false + item.representedObject = "overviewEmptyState" + menu.addItem(item) + } +} diff --git a/Sources/CodexBar/StatusItemController+OverviewTotals.swift b/Sources/CodexBar/StatusItemController+OverviewTotals.swift new file mode 100644 index 0000000000..8febd3f5d7 --- /dev/null +++ b/Sources/CodexBar/StatusItemController+OverviewTotals.swift @@ -0,0 +1,143 @@ +import AppKit +import CodexBarCore +import SwiftUI + +extension StatusItemController { + func makeOverviewTotalsModel(enabledProviders: [UsageProvider]) -> OverviewTotalsModel? { + guard self.settings.costUsageEnabled else { return nil } + let inputs = enabledProviders.compactMap { provider -> OverviewTotalsModel.ProviderInput? in + guard let publication = self.store.tokenSnapshotPublicationForCurrentProviderConfig(for: provider), + let snapshot = publication.snapshot + else { + return nil + } + let displayName = ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName + return OverviewTotalsModel.ProviderInput( + provider: provider, + displayName: displayName, + snapshot: snapshot) + } + return OverviewTotalsModel.build( + inputs: inputs, + preferredCurrencyCode: self.settings.preferredCurrencyCode) + } + + func makeOverviewUnifiedModel( + enabledProviders: [UsageProvider]) -> OverviewUnifiedModel? + { + let quotaRows = enabledProviders.compactMap { provider -> OverviewQuotaRow? in + guard let model = self.menuCardModel(for: provider) else { return nil } + let displayName = ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName + let primary = model.metrics.first(where: { $0.id == "primary" }) ?? model.metrics.first + guard !model.isOverviewErrorOnly, let primary else { + // Error-only providers still get a chip so every enabled provider appears once. + guard model.subtitleStyle == .error, !model.subtitleText.isEmpty else { return nil } + return OverviewQuotaRow( + provider: provider, + displayName: displayName, + remainingPercent: nil, + usedPercent: nil, + percentLabel: "", + hasError: true, + errorText: model.subtitleText, + resetText: nil, + progressColor: model.progressColor) + } + let usedPercent = primary.percentStyle == .used + ? primary.percent + : 100 - primary.percent + let hasError = model.subtitleStyle == .error + return OverviewQuotaRow( + provider: provider, + displayName: displayName, + remainingPercent: primary.percent, + usedPercent: min(100, max(0, usedPercent)), + percentLabel: primary.percentLabel, + hasError: hasError, + errorText: hasError && !model.subtitleText.isEmpty ? model.subtitleText : nil, + resetText: primary.resetText, + progressColor: model.progressColor) + } + guard !quotaRows.isEmpty else { return nil } + + return OverviewUnifiedModel( + totals: self.makeOverviewTotalsModel(enabledProviders: enabledProviders), + quotaRows: quotaRows, + resetLines: Self.makeOverviewResetLines(quotaRows: quotaRows)) + } + + static func makeOverviewResetLines(quotaRows: [OverviewQuotaRow]) -> [String] { + let lines = quotaRows.compactMap { row -> String? in + guard !row.hasError, let reset = row.resetText, !reset.isEmpty else { return nil } + return "\(row.displayName) · \(reset)" + } + return Array(lines.prefix(2)) + } + + func addOverviewUnifiedCard( + _ model: OverviewUnifiedModel, + to menu: NSMenu, + menuWidth: CGFloat) + { + let submenu = self.makeOverviewUnifiedSubmenu(model: model) + let item = self.makeMenuCardItem( + OverviewUnifiedCardView(model: model, width: menuWidth), + id: Self.overviewUnifiedRowIdentifier, + width: menuWidth, + heightCacheScope: "overviewUnified", + heightCacheFingerprint: model.heightFingerprint, + submenu: submenu, + containsInteractiveControls: false, + usesGPUSelection: true, + onClick: { [weak self] in + self?.openUsageSpendPane() + }) + menu.addItem(item) + } + + private func makeOverviewUnifiedSubmenu( + model: OverviewUnifiedModel) -> NSMenu + { + let submenu = NSMenu() + let spendItem = NSMenuItem( + title: L("tab_usage_spend"), + action: #selector(self.openUsageSpendFromMenu(_:)), + keyEquivalent: "") + spendItem.target = self + submenu.addItem(spendItem) + submenu.addItem(.separator()) + for row in model.quotaRows { + let detail = row.hasError + ? (row.errorText ?? "") + : row.percentLabel + let item = NSMenuItem( + title: "\(row.displayName) · \(detail)", + action: #selector(self.selectOverviewContributionProvider(_:)), + keyEquivalent: "") + item.target = self + item.representedObject = + "\(Self.overviewRowIdentifierPrefix)\(row.provider.rawValue)" + submenu.addItem(item) + } + return submenu + } + + @objc func openUsageSpendFromMenu(_ sender: NSMenuItem) { + self.openUsageSpendPane() + } + + @objc func selectOverviewContributionProvider(_ sender: NSMenuItem) { + guard let represented = sender.representedObject as? String, + represented.hasPrefix(Self.overviewRowIdentifierPrefix) + else { + return + } + let rawProvider = String(represented.dropFirst(Self.overviewRowIdentifierPrefix.count)) + guard let provider = UsageProvider(rawValue: rawProvider) else { return } + // Contribution items live inside the totals row submenu; route the switch back to the + // root merged menu so the provider detail card replaces the whole Overview. + let menu = self.mergedMenu ?? sender.menu + guard let menu else { return } + self.selectOverviewProvider(provider, menu: menu) + } +} diff --git a/Tests/CodexBarTests/OverviewTotalsModelTests.swift b/Tests/CodexBarTests/OverviewTotalsModelTests.swift new file mode 100644 index 0000000000..e4fcf0b942 --- /dev/null +++ b/Tests/CodexBarTests/OverviewTotalsModelTests.swift @@ -0,0 +1,125 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct OverviewTotalsModelTests { + private func input( + _ provider: UsageProvider, + tokens: Int?, + cost: Double?, + currency: String = "USD", + historyDays: Int = 30) -> OverviewTotalsModel.ProviderInput + { + OverviewTotalsModel.ProviderInput( + provider: provider, + displayName: provider.rawValue, + snapshot: CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: tokens, + last30DaysCostUSD: cost, + currencyCode: currency, + historyDays: historyDays, + historyCoverageIsEstablished: true, + daily: [], + updatedAt: Date(timeIntervalSince1970: 0))) + } + + @Test + func `single currency groups sum tokens and cost`() throws { + let model = OverviewTotalsModel.build(inputs: [ + self.input(.claude, tokens: 1_000_000, cost: 1.5), + self.input(.codex, tokens: 2_000_000, cost: 2.5), + ]) + + let group = try #require(model?.groups.first) + #expect(model?.providerCount == 2) + #expect(model?.totalTokens == 3_000_000) + #expect(group.totalTokens == 3_000_000) + #expect(group.totalCost == 4.0) + #expect(group.currencyCode == "USD") + #expect(group.coveredDayCount == 30) + } + + @Test + func `multi currency groups are sorted by currency code`() throws { + let model = OverviewTotalsModel.build(inputs: [ + self.input(.claude, tokens: 100, cost: 1, currency: "EUR"), + self.input(.codex, tokens: 200, cost: 2, currency: "USD"), + ]) + + #expect(model?.groups.map(\.currencyCode) == ["EUR", "USD"]) + #expect(model?.totalTokens == 300) + let eurGroup = try #require(model?.groups.first) + #expect(eurGroup.totalCost == 1) + } + + @Test + func `preferred currency converts priced groups`() throws { + let eurRate = try #require(CurrencyExchange.shared.rate(for: "EUR")) + let model = OverviewTotalsModel.build( + inputs: [ + self.input(.claude, tokens: 100, cost: 10, currency: "EUR"), + self.input(.codex, tokens: 200, cost: 2, currency: "USD"), + ], + preferredCurrencyCode: "USD") + + let group = try #require(model?.groups.first) + #expect(group.currencyCode == "USD") + #expect(group.totalCost == 2 + 10 / eurRate) + } + + @Test + func `unpriced provider still contributes tokens`() throws { + let model = OverviewTotalsModel.build(inputs: [ + self.input(.claude, tokens: 500, cost: nil), + self.input(.codex, tokens: 300, cost: 4), + ]) + + let group = try #require(model?.groups.first) + #expect(group.totalTokens == 800) + #expect(group.totalCost == 4) + } + + @Test + func `token overflow blanks the group token total`() { + let model = OverviewTotalsModel.build(inputs: [ + self.input(.claude, tokens: Int.max, cost: 1), + self.input(.codex, tokens: Int.max, cost: 1), + ]) + + #expect(model?.groups.first?.totalTokens == nil) + #expect(model?.groups.first?.totalCost == 2) + } + + @Test + func `non finite cost is excluded from the group total`() { + let model = OverviewTotalsModel.build(inputs: [ + self.input(.claude, tokens: 100, cost: .infinity), + self.input(.codex, tokens: 200, cost: 3), + ]) + + #expect(model?.groups.first?.totalCost == 3) + } + + @Test + func `invalid currency inputs are dropped`() throws { + let model = OverviewTotalsModel.build(inputs: [ + self.input(.claude, tokens: 100, cost: 1, currency: "XXX"), + self.input(.codex, tokens: 200, cost: 2), + ]) + + let group = try #require(model?.groups.first) + #expect(group.contributions.map(\.provider) == [.codex]) + #expect(group.totalTokens == 200) + } + + @Test + func `empty or single provider inputs build no model`() { + #expect(OverviewTotalsModel.build(inputs: []) == nil) + #expect(OverviewTotalsModel.build(inputs: [ + self.input(.codex, tokens: 1, cost: 1), + ]) == nil) + } +} diff --git a/Tests/CodexBarTests/StatusMenuOverviewUnifiedTests.swift b/Tests/CodexBarTests/StatusMenuOverviewUnifiedTests.swift new file mode 100644 index 0000000000..defff38b77 --- /dev/null +++ b/Tests/CodexBarTests/StatusMenuOverviewUnifiedTests.swift @@ -0,0 +1,284 @@ +import AppKit +import CodexBarCore +import Testing +@testable import CodexBar + +@MainActor +@Suite(.serialized) +struct StatusMenuOverviewUnifiedTests { + @Test + func `unified card appears and lists every enabled provider`() throws { + let settings = self.makeSettings() + settings.mergeIcons = true + settings.costUsageEnabled = true + settings.mergedMenuLastSelectedWasOverview = true + self.enableOnly([.codex, .claude, .cursor, .opencode], settings: settings) + + let store = self.makeStore(settings: settings) + let now = Date() + for provider in [.codex, .claude, .cursor, .opencode] as [UsageProvider] { + store._setSnapshotForTesting(self.usageSnapshot(used: 22, now: now), provider: provider) + } + store._setTokenSnapshotForTesting( + self.tokenSnapshot(tokens: 1_000_000, cost: 1.5), + provider: .codex) + store._setTokenSnapshotForTesting( + self.tokenSnapshot(tokens: 2_000_000, cost: 2.5), + provider: .claude) + + let controller = self.makeController(settings: settings, store: store) + let menu = try #require(controller.makeMenu() as? StatusItemMenu) + controller.mergedMenu = menu + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + let card = try #require(menu.items.first { + $0.representedObject as? String == StatusItemController.overviewUnifiedRowIdentifier + }) + let submenu = try #require(card.submenu) + let providerItems = submenu.items.filter { + ($0.representedObject as? String)?.hasPrefix(StatusItemController.overviewRowIdentifierPrefix) == true + } + #expect(providerItems.map(\.title).count == 4) + #expect(submenu.items.first?.title == L("tab_usage_spend")) + + let model = try #require(controller.makeOverviewUnifiedModel( + enabledProviders: [.codex, .claude, .cursor, .opencode])) + #expect(model.quotaRows.count == 4) + #expect(model.totals?.totalTokens == 3_000_000) + } + + @Test + func `unified card hidden when no provider has quota data`() throws { + let settings = self.makeSettings() + settings.mergeIcons = true + settings.costUsageEnabled = true + settings.mergedMenuLastSelectedWasOverview = true + self.enableOnly([.codex, .claude], settings: settings) + + let store = self.makeStore(settings: settings) + let controller = self.makeController(settings: settings, store: store) + let menu = try #require(controller.makeMenu() as? StatusItemMenu) + controller.mergedMenu = menu + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + #expect(controller.makeOverviewUnifiedModel(enabledProviders: [.codex, .claude]) == nil) + #expect(menu.items.contains { + $0.representedObject as? String == StatusItemController.overviewUnifiedRowIdentifier + } == false) + } + + @Test + func `submenu contribution click switches to that provider`() throws { + let settings = self.makeSettings() + settings.mergeIcons = true + settings.costUsageEnabled = true + settings.mergedMenuLastSelectedWasOverview = true + self.enableOnly([.codex, .claude], settings: settings) + + let store = self.makeStore(settings: settings) + let now = Date() + store._setSnapshotForTesting(self.usageSnapshot(used: 22, now: now), provider: .codex) + store._setSnapshotForTesting(self.usageSnapshot(used: 55, now: now), provider: .claude) + + let controller = self.makeController(settings: settings, store: store) + let menu = try #require(controller.makeMenu() as? StatusItemMenu) + controller.mergedMenu = menu + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + let card = try #require(menu.items.first { + $0.representedObject as? String == StatusItemController.overviewUnifiedRowIdentifier + }) + let submenu = try #require(card.submenu) + let claudeItem = try #require(submenu.items.first { + $0.representedObject as? String == + "\(StatusItemController.overviewRowIdentifierPrefix)claude" + }) + + controller.selectOverviewContributionProvider(claudeItem) + #expect(settings.mergedMenuLastSelectedWasOverview == false) + #expect(settings.selectedMenuProvider == .claude) + } + + @Test + func `unified card refreshes when provider usage changes`() throws { + let settings = self.makeSettings() + settings.mergeIcons = true + settings.costUsageEnabled = true + settings.mergedMenuLastSelectedWasOverview = true + self.enableOnly([.codex, .claude], settings: settings) + + let store = self.makeStore(settings: settings) + let now = Date() + store._setSnapshotForTesting(self.usageSnapshot(used: 22, now: now), provider: .codex) + store._setSnapshotForTesting(self.usageSnapshot(used: 55, now: now), provider: .claude) + + let controller = self.makeController(settings: settings, store: store) + let menu = try #require(controller.makeMenu() as? StatusItemMenu) + controller.mergedMenu = menu + controller.menuWillOpen(menu) + + store._setSnapshotForTesting(self.usageSnapshot(used: 88, now: now), provider: .claude) + controller.menuDidClose(menu) + let refreshedMenu = try #require(controller.makeMenu() as? StatusItemMenu) + controller.mergedMenu = refreshedMenu + controller.menuWillOpen(refreshedMenu) + defer { controller.menuDidClose(refreshedMenu) } + + let card = try #require(refreshedMenu.items.first { + $0.representedObject as? String == StatusItemController.overviewUnifiedRowIdentifier + }) + let submenu = try #require(card.submenu) + let claudeItem = try #require(submenu.items.first { + $0.representedObject as? String == + "\(StatusItemController.overviewRowIdentifierPrefix)claude" + }) + #expect(claudeItem.title.contains("12%")) + } + + @Test + func `error provider appears exactly once as an error chip`() throws { + let settings = self.makeSettings() + settings.mergeIcons = true + settings.costUsageEnabled = false + self.enableOnly([.codex, .claude, .cursor], settings: settings) + + let store = self.makeStore(settings: settings) + let now = Date() + store._setSnapshotForTesting(self.usageSnapshot(used: 22, now: now), provider: .codex) + store._setSnapshotForTesting(self.usageSnapshot(used: 88, now: now), provider: .claude) + store._setErrorForTesting("Needs sign-in", provider: .cursor) + + let controller = self.makeController(settings: settings, store: store) + let model = try #require(controller.makeOverviewUnifiedModel( + enabledProviders: [.codex, .claude, .cursor])) + + let cursorRows = model.quotaRows.filter { $0.provider == .cursor } + #expect(cursorRows.count == 1) + let cursorRow = try #require(cursorRows.first) + #expect(cursorRow.hasError) + #expect(cursorRow.errorText?.contains("Needs sign-in") == true) + #expect(cursorRow.remainingPercent == nil) + // Errors render inside the chip grid; no separate attention lines exist. + #expect(model.resetLines.allSatisfy { !$0.contains("Cursor") }) + } + + @Test + func `reset lines list at most two providers with reset info`() throws { + let settings = self.makeSettings() + settings.mergeIcons = true + settings.costUsageEnabled = false + self.enableOnly([.codex, .claude, .cursor], settings: settings) + + let store = self.makeStore(settings: settings) + let now = Date() + store._setSnapshotForTesting(self.usageSnapshot(used: 22, now: now), provider: .codex) + store._setSnapshotForTesting(self.usageSnapshot(used: 55, now: now), provider: .claude) + store._setSnapshotForTesting(self.usageSnapshot(used: 88, now: now), provider: .cursor) + + let controller = self.makeController(settings: settings, store: store) + let model = try #require(controller.makeOverviewUnifiedModel( + enabledProviders: [.codex, .claude, .cursor])) + + #expect(model.resetLines.count == 2) + #expect(model.resetLines.first?.contains("Codex") == true) + #expect(model.resetLines.last?.contains("Claude") == true) + } + + @Test + func `reset lines empty when no provider reports reset info`() throws { + let settings = self.makeSettings() + settings.mergeIcons = true + settings.costUsageEnabled = false + self.enableOnly([.codex, .claude], settings: settings) + + let store = self.makeStore(settings: settings) + let now = Date() + store._setSnapshotForTesting(self.usageSnapshotWithoutReset(used: 22, now: now), provider: .codex) + store._setSnapshotForTesting(self.usageSnapshotWithoutReset(used: 55, now: now), provider: .claude) + + let controller = self.makeController(settings: settings, store: store) + let model = try #require(controller.makeOverviewUnifiedModel( + enabledProviders: [.codex, .claude])) + + #expect(model.resetLines.isEmpty) + #expect(model.quotaRows.count == 2) + } + + private func makeSettings() -> SettingsStore { + let suite = "StatusMenuOverviewUnifiedTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + return SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + } + + private func makeStore(settings: SettingsStore) -> UsageStore { + UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + } + + private func makeController( + settings: SettingsStore, + store: UsageStore) -> StatusItemController + { + StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + } + + private func enableOnly(_ providers: Set, settings: SettingsStore) { + for provider in UsageProvider.allCases { + guard let metadata = ProviderRegistry.shared.metadata[provider] else { continue } + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: providers.contains(provider)) + } + } + + private func usageSnapshot(used: Double, now: Date) -> UsageSnapshot { + UsageSnapshot( + primary: RateWindow( + usedPercent: used, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(1800), + resetDescription: nil), + secondary: nil, + tertiary: nil, + updatedAt: now) + } + + private func usageSnapshotWithoutReset(used: Double, now: Date) -> UsageSnapshot { + UsageSnapshot( + primary: RateWindow( + usedPercent: used, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + tertiary: nil, + updatedAt: now) + } + + private func tokenSnapshot(tokens: Int, cost: Double) -> CostUsageTokenSnapshot { + CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: tokens, + last30DaysCostUSD: cost, + currencyCode: "USD", + historyDays: 30, + historyCoverageIsEstablished: true, + daily: [], + updatedAt: Date()) + } +}