Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ show an incident indicator.
- Optional Codex web dashboard enrichments (code review remaining, usage breakdown, credits history).
- Inline spend and usage charts for API-backed providers such as OpenAI, Claude Admin API, OpenRouter, LiteLLM, z.ai, MiniMax, Mistral, and AWS Bedrock.
- Configurable cost-usage scans for Codex + Claude, plus reused chart UI for supported provider histories.
- A persistent Settings → Usage & Spend view for local 7/30-day estimates, grouped by native currency and limited to providers that expose cost history.
- A persistent Settings → Usage & Spend view for local 7/30/365-day estimates, grouped by native currency, with every tracked subscription/key visible and unsupported cost sources excluded from totals.
- Provider status polling with incident badges in the menu and icon overlay.
- Merge Icons mode to combine providers into one status item + switcher.
- Display controls for provider icons, labels, bars, reset-time style, and highest-usage auto-selection.
Expand Down
240 changes: 210 additions & 30 deletions Sources/CodexBar/PreferencesSpendDashboardPane.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,18 @@ func spendDashboardDayRangeText(_ days: Int) -> String {
switch days {
case 7: template = L("7d")
case 30: template = L("30d")
case 365: template = L("365d")
default: return codexBarLocalizedInteger(days)
}
return template.replacingOccurrences(
of: String(days),
with: codexBarLocalizedInteger(days))
}

func spendDashboardRequiredHistoryDays(selectedDays: Int, configuredDays: Int) -> Int {
max(1, min(365, max(selectedDays, configuredDays)))
}

func spendDashboardRankText(_ rank: Int) -> String {
"#\(codexBarLocalizedInteger(rank))"
}
Expand All @@ -27,6 +32,17 @@ func spendDashboardCoverageText(covered: Int, requested: Int) -> String {
"\(L("Coverage")): \(codexBarLocalizedInteger(covered)) / \(codexBarLocalizedInteger(requested))"
}

func spendDashboardTrackedSourceStatusText(_ source: SpendDashboardTrackedSource) -> String {
if source.contributesCostHistory {
return source.state == .connected
? L("Cost history connected")
: L("Cost history pending")
}
return source.state == .connected
? L("Usage connected · not in cost total")
: L("Configured · not in cost total")
}

enum SpendDashboardModelHistoryPresentation: Equatable {
case unavailable
case empty
Expand Down Expand Up @@ -62,20 +78,23 @@ struct SpendDashboardPane: View {
VStack(alignment: .leading, spacing: 18) {
self.header
self.content
self.trackedAccess
self.provenance
self.shareAction
}
.padding(24)
}
.background(FocusResigningBackground())
.onAppear {
self.applySelectedHistoryCoverage()
self.controller.refreshDateWindow()
self.controller.update(configuration: self.configuration)
}
.onChange(of: self.configuration) { _, configuration in
self.controller.update(configuration: configuration)
}
.onDisappear {
self.settings.setSpendDashboardHistoryDaysOverride(nil)
self.controller.stop()
}
.onReceive(NotificationCenter.default.publisher(for: .NSCalendarDayChanged)) { _ in
Expand All @@ -94,34 +113,12 @@ struct SpendDashboardPane: View {
}

private var header: some View {
HStack(alignment: .top, spacing: 16) {
VStack(alignment: .leading, spacing: 4) {
Text(L("Usage & Spend"))
.font(.title2.weight(.semibold))
Text(L("Local estimated cost history across supported providers."))
.font(.subheadline)
.foregroundStyle(.secondary)
}
Spacer()
Picker(L("Time range"), selection: self.daysBinding) {
Text(spendDashboardDayRangeText(7)).tag(7)
Text(spendDashboardDayRangeText(30)).tag(30)
}
.labelsHidden()
.pickerStyle(.segmented)
.frame(width: 116)

Button {
self.controller.refresh()
} label: {
if self.controller.isRefreshing {
ProgressView().controlSize(.small)
} else {
Label(L("Refresh"), systemImage: "arrow.clockwise")
}
}
.disabled(self.controller.isRefreshing || !self.settings.costUsageEnabled)
}
SpendDashboardHeader(
selectedDays: self.controller.selectedDays,
isRefreshing: self.controller.isRefreshing,
isCostTrackingEnabled: self.settings.costUsageEnabled,
selectDays: { self.daysBinding.wrappedValue = $0 },
refresh: { self.controller.refresh() })
}

@ViewBuilder
Expand Down Expand Up @@ -173,6 +170,20 @@ struct SpendDashboardPane: View {
}
}

@ViewBuilder
private var trackedAccess: some View {
let sources = self.configuration.trackedSources
if !sources.isEmpty {
SpendTrackedAccessPanel(
sources: sources,
description: self.trackedAccessDescription)
}
}

private var trackedAccessDescription: String {
L("Every configured subscription or key stays visible. Only compatible sources enter cost totals.")
}

private var shareAction: some View {
HStack {
Spacer()
Expand All @@ -189,7 +200,8 @@ struct SpendDashboardPane: View {
private var sharePayload: ShareStatsPayload? {
ShareStatsBuilder.make(
model: self.controller.model,
subscriptionNames: self.subscriptionNames)
subscriptionNames: self.subscriptionNames,
trackedSources: self.configuration.trackedSources)
}

private var subscriptionNames: [String: ShareStatsSubscriptionName] {
Expand Down Expand Up @@ -222,7 +234,175 @@ struct SpendDashboardPane: View {
private var daysBinding: Binding<Int> {
Binding(
get: { self.controller.selectedDays },
set: { self.controller.selectDays($0) })
set: {
self.controller.selectDays($0)
self.applySelectedHistoryCoverage()
self.controller.refreshDateWindow()
})
}

private func applySelectedHistoryCoverage() {
let requiredDays = spendDashboardRequiredHistoryDays(
selectedDays: self.controller.selectedDays,
configuredDays: self.settings.costUsageHistoryDays)
self.settings.setSpendDashboardHistoryDaysOverride(
requiredDays == self.settings.costUsageHistoryDays ? nil : requiredDays)
}
}

struct SpendDashboardHeader: View {
let selectedDays: Int
let isRefreshing: Bool
let isCostTrackingEnabled: Bool
let selectDays: (Int) -> Void
let refresh: () -> Void

var body: some View {
ViewThatFits(in: .horizontal) {
HStack(alignment: .top, spacing: 16) {
self.title
Spacer(minLength: 16)
self.controls
}
VStack(alignment: .leading, spacing: 12) {
self.title
self.controls
}
}
}

private var title: some View {
VStack(alignment: .leading, spacing: 4) {
Text(L("Usage & Spend"))
.font(.title2.weight(.semibold))
Text(L("Local estimated cost history across supported providers."))
.font(.subheadline)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}

private var controls: some View {
HStack(spacing: 12) {
Picker(L("Time range"), selection: self.daysBinding) {
Text(spendDashboardDayRangeText(7)).tag(7)
Text(spendDashboardDayRangeText(30)).tag(30)
Text(spendDashboardDayRangeText(365)).tag(365)
}
.labelsHidden()
.pickerStyle(.segmented)
.frame(width: 174)

Button(action: self.refresh) {
if self.isRefreshing {
ProgressView().controlSize(.small)
} else {
Label(L("Refresh"), systemImage: "arrow.clockwise")
}
}
.disabled(self.isRefreshing || !self.isCostTrackingEnabled)
}
}

private var daysBinding: Binding<Int> {
Binding(get: { self.selectedDays }, set: { self.selectDays($0) })
}
}

struct SpendTrackedAccessPanel: View {
let sources: [SpendDashboardTrackedSource]
let description: String

private let columns = [
GridItem(.adaptive(minimum: 245, maximum: 420), spacing: 12),
]

var body: some View {
SpendDashboardPanel {
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 4) {
HStack(alignment: .firstTextBaseline, spacing: 12) {
Text(L("Tracked access"))
.font(.headline)
Spacer(minLength: 12)
Text(
"\(codexBarLocalizedInteger(self.sources.count)) " +
L("tracked sources"))
.font(.caption.monospacedDigit().weight(.semibold))
.foregroundStyle(.secondary)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(.quaternary.opacity(0.7), in: Capsule())
.accessibilityLabel(
"\(codexBarLocalizedInteger(self.sources.count)) \(L("tracked sources"))")
}
Text(self.description)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}

LazyVGrid(columns: self.columns, alignment: .leading, spacing: 12) {
ForEach(self.sources) { source in
SpendTrackedSourceRow(source: source)
}
}
}
}
}
}

private struct SpendTrackedSourceRow: View {
let source: SpendDashboardTrackedSource

var body: some View {
VStack(alignment: .leading, spacing: 9) {
HStack(spacing: 10) {
SpendProviderIcon(provider: self.source.provider)

VStack(alignment: .leading, spacing: 2) {
Text(self.source.providerName)
.font(.subheadline.weight(.medium))
.lineLimit(1)
if let accountName = self.source.accountName {
Text(accountName)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
Spacer(minLength: 4)
}

Label(
spendDashboardTrackedSourceStatusText(self.source),
systemImage: self.statusSymbol)
.font(.caption.weight(.medium))
.foregroundStyle(self.statusColor)
.fixedSize(horizontal: false, vertical: true)
}
.padding(12)
.frame(maxWidth: .infinity, minHeight: 72, alignment: .leading)
.background(.background.opacity(0.72), in: RoundedRectangle(cornerRadius: 10, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.strokeBorder(Color(nsColor: .separatorColor).opacity(0.25))
}
.accessibilityElement(children: .combine)
}

private var statusSymbol: String {
if self.source.contributesCostHistory {
return self.source.state == .connected ? "checkmark.circle.fill" : "clock.fill"
}
return self.source.state == .connected ? "minus.circle.fill" : "minus.circle"
}

private var statusColor: Color {
if self.source.contributesCostHistory {
return self.source.state == .connected ? .green : .orange
}
return .secondary
}
}

Expand Down
2 changes: 1 addition & 1 deletion Sources/CodexBar/ProviderRegistry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ struct ProviderRegistry {
}
}
},
costUsageHistoryDays: settings.costUsageHistoryDays,
costUsageHistoryDays: settings.effectiveCostUsageHistoryDays,
persistsCLISessions: true,
persistentCLISessionIdleWindow: Self.persistentCLISessionIdleWindow(
refreshInterval: Self.nominalRefreshInterval(for: settings.refreshFrequency)))
Expand Down
8 changes: 8 additions & 0 deletions Sources/CodexBar/Resources/ar.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
" providers" = " providers";
"(System)" = "(النظام)";
"30d" = "30 يومًا";
"365d" = "365 يومًا";
"7d" = "7 أيام";
"A managed Codex login is already running. Wait for it to finish before adding " = "تسجيل دخول Codex المدار يعمل بالفعل. انتظر حتى ينتهي قبل إضافة ";
"API key" = "مفتاح API";
Expand Down Expand Up @@ -1268,6 +1269,13 @@
"Model breakdown unavailable" = "تفصيل الإنفاق حسب النموذج غير متاح";
"Local estimated history" = "السجل التقديري المحلي";
"Coverage" = "التغطية";
"Tracked access" = "الوصول المُتتبَّع";
"Every configured subscription or key stays visible. Only compatible sources enter cost totals." = "يبقى كل اشتراك أو مفتاح مُهيأ مرئيًا. المصادر المتوافقة فقط تدخل في إجماليات التكلفة.";
"tracked sources" = "المصادر المُتتبَّعة";
"Cost history connected" = "سجل التكلفة متصل";
"Cost history pending" = "سجل التكلفة قيد الانتظار";
"Usage connected · not in cost total" = "الاستخدام متصل · غير مشمول في إجمالي التكلفة";
"Configured · not in cost total" = "مُهيأ · غير مشمول في إجمالي التكلفة";
"Estimated spend" = "الإنفاق التقديري";
"Tracked tokens" = "الرموز المتتبعة";
"Subscriptions" = "الاشتراكات";
Expand Down
8 changes: 8 additions & 0 deletions Sources/CodexBar/Resources/ca.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
" providers" = " proveïdors";
"(System)" = "(Sistema)";
"30d" = "30 d";
"365d" = "365 d";
"7d" = "7 d";
"A managed Codex login is already running. Wait for it to finish before adding " = "Ja hi ha un inici de sessió gestionat de Codex en curs. Espereu que acabi abans d'afegir ";
"API key" = "Clau d'API";
Expand Down Expand Up @@ -1267,6 +1268,13 @@
"Model breakdown unavailable" = "Desglossament per model no disponible";
"Local estimated history" = "Historial local estimat";
"Coverage" = "Cobertura";
"Tracked access" = "Accés fet un seguiment";
"Every configured subscription or key stays visible. Only compatible sources enter cost totals." = "Totes les subscripcions o claus configurades resten visibles. Només les fonts compatibles entren als totals de cost.";
"tracked sources" = "fonts amb seguiment";
"Cost history connected" = "Historial de costos connectat";
"Cost history pending" = "Historial de costos pendent";
"Usage connected · not in cost total" = "Ús connectat · no inclòs al total de cost";
"Configured · not in cost total" = "Configurat · no inclòs al total de cost";
"Estimated spend" = "Despesa estimada";
"Tracked tokens" = "Tokens registrats";
"Subscriptions" = "Subscripcions";
Expand Down
8 changes: 8 additions & 0 deletions Sources/CodexBar/Resources/de.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
" providers" = "Anbieter";
"(System)" = "(System)";
"30d" = "30d";
"365d" = "365d";
"7d" = "7d";
"A managed Codex login is already running. Wait for it to finish before adding " = "Eine verwaltete Codex-Anmeldung läuft bereits. Warten Sie, bis der Vorgang abgeschlossen ist, bevor Sie ihn hinzufügen";
"API key" = "API-Schlüssel";
Expand Down Expand Up @@ -1265,6 +1266,13 @@
"Model breakdown unavailable" = "Modellaufschlüsselung nicht verfügbar";
"Local estimated history" = "Lokaler Schätzverlauf";
"Coverage" = "Abdeckung";
"Tracked access" = "Verfolgter Zugriff";
"Every configured subscription or key stays visible. Only compatible sources enter cost totals." = "Jedes konfigurierte Abonnement oder jeder Schlüssel bleibt sichtbar. Nur kompatible Quellen fließen in die Kostensummen ein.";
"tracked sources" = "verfolgte Quellen";
"Cost history connected" = "Kostenverlauf verbunden";
"Cost history pending" = "Kostenverlauf ausstehend";
"Usage connected · not in cost total" = "Nutzung verbunden · nicht in der Kostensumme";
"Configured · not in cost total" = "Konfiguriert · nicht in der Kostensumme";
"Estimated spend" = "Geschätzte Ausgaben";
"Tracked tokens" = "Erfasste Token";
"Subscriptions" = "Abonnements";
Expand Down
Loading