Skip to content

Commit 9e0ee80

Browse files
steipeteYuxin-Qiao
andcommitted
fix(spend): preserve recorded zero sources and scoped dates
Keep date inspection within plotted buckets, retain recorded zero-dollar source chips and inspector rows, and cover hourly inspection across DST. Reuse source records during stacking and sync the reviewed main baseline without rewriting contributor history. Co-authored-by: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com>
2 parents 1e9c06b + c3c6ce0 commit 9e0ee80

70 files changed

Lines changed: 1371 additions & 394 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎CHANGELOG.md‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,22 @@
22

33
## 0.73.1 — Unreleased
44

5+
### Added
6+
7+
- X API: track prepaid and free developer-console credits with a bundled plugin, Chrome/manual cookies, and negative balances in Balance layouts (#4127). Thanks @marklights54-byte!
8+
- Notion AI: import signed-in Microsoft Edge sessions after Chrome on macOS, retaining prompt-free background cookie access (#4323). Thanks @jiehua!
9+
10+
### Fixed
11+
12+
- Usage & Spend: keep date inspection inside recorded chart buckets, wrap scoped source legends, and retain recorded zero-dollar sources and amounts (#4329). Thanks @Yuxin-Qiao!
13+
14+
- Menu bar: keep the empty Settings placeholder from creating a persistent Dock icon at launch on macOS 27, and share its guarded dismissal path. (#4101)
15+
- OpenCode Go: show the most constrained five-hour, weekly, or monthly quota in the automatic menu-bar percentage and switcher before it runs out. (#3349)
16+
- Claude: preserve Plan Usage history across external OAuth token rotations and reunite saved fragments with verified account/profile bindings, while keeping other accounts and unverified history separate (#4322). Thanks @urda!
17+
- Claude: keep Enterprise monthly Extra usage visible in Compact Overview when quota limits are unavailable, respecting the optional-usage preference (#4320). Thanks @wrick17!
18+
- Claude: distinguish insights-only CLI reports from subscription-only notices so failed direct fallbacks preserve the original PTY error, and log that error before fallback (#4083). Thanks @sczhui!
19+
- Docs: correct Codex Auto source order and explain credential renewal, local cost coverage, and the distinct Pi, OpenCodex, OpenCode, Amp, and dots paths (#3635, #3273, #3556, #4300).
20+
521
## 0.73.0 — 2026-10-07
622

723
### Highlights

‎README.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
[![License: MIT](https://img.shields.io/badge/license-MIT-6e5aff?style=flat-square)](LICENSE)
1111
[![Site](https://img.shields.io/badge/site-codexbar.app-16d3b4?style=flat-square)](https://codexbar.app)
1212

13-
<a href="https://codexbar.app"><img src="docs/social.png?v=3182891f443d237c" alt="CodexBar — every AI coding limit in your menu bar. 91 providers." width="100%" /></a>
13+
<a href="https://codexbar.app"><img src="docs/social.png?v=82fb5aa4eda4b681" alt="CodexBar — every AI coding limit in your menu bar. 92 providers." width="100%" /></a>
1414

1515
Tiny macOS 14+ menu bar app that keeps **AI coding-provider limits visible** and shows when each window resets. See the [supported providers](#providers) below. One status item per provider, or Merge Icons mode with a provider switcher. No Dock icon, minimal UI, dynamic bar icons.
1616

@@ -184,6 +184,7 @@ See [CLI configuration](docs/cli-configuration.md) for the full flow.
184184

185185
<!-- Generated provider additions: Scripts/regenerate-provider-docs.mjs -->
186186
- [Langdock](docs/langdock.md) — Selected Microsoft Edge profile → personal included session and weekly limits (`web`, macOS).
187+
- [X API](docs/xapi.md) — Chrome or manual console.x.com cookies for prepaid and free credits, including negative balances.
187188
- [LithosAI](docs/lithosai.md) — Chrome or manual console cookies for prepaid USD balance and optional UTC spend.
188189
- [WorkBuddy](docs/workbuddy.md) — Chrome or manual www.workbuddy.cn cookies for the monthly credits allowance, plan name, and cycle reset.
189190
<!-- End generated provider additions -->

‎Sources/CodexBar/CodexbarApp.swift‎

Lines changed: 3 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -463,7 +463,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
463463
self.installDebugMemoryPressureObserverIfNeeded()
464464
#endif
465465
self.ensureStatusController()
466-
self.closeSwiftUISettingsPlaceholderWindow()
466+
DispatchQueue.main.async { [weak self] in
467+
self?.placeholderSettingsWindowGuard.sweep()
468+
}
467469
self.observeSettingsApplicationMenuLanguage()
468470
self.scheduleSettingsApplicationMenuValidation(
469471
missingItemRetriesRemaining: Self.settingsMenuReadinessRetryCount,
@@ -503,21 +505,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
503505
}
504506
}
505507

506-
/// The SwiftUI `Settings` scene exists only to own the app-menu Settings command; the real
507-
/// settings window is AppKit-managed (`SettingsWindowController`). macOS can still present or
508-
/// state-restore the scene's empty placeholder window at launch — close it and keep it out of
509-
/// state restoration so it cannot come back on the next launch.
510-
private func closeSwiftUISettingsPlaceholderWindow() {
511-
DispatchQueue.main.async {
512-
for window in NSApp.windows
513-
where window.identifier?.rawValue.hasPrefix("com_apple_SwiftUI_Settings") == true
514-
{
515-
window.isRestorable = false
516-
window.close()
517-
}
518-
}
519-
}
520-
521508
func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
522509
guard !self.isWaitingForConfigPersistence else { return .terminateLater }
523510
guard let save = self.settings?.persistPendingConfigForTermination() else { return .terminateNow }

‎Sources/CodexBar/DockIconController.swift‎

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import CodexBarCore
33

44
struct DockIconWindowDescriptor: Equatable, Sendable {
55
let identifier: String?
6+
let frameAutosaveName: String
67
let title: String
78
let classNames: [String]
89
let width: Double
@@ -13,12 +14,14 @@ struct DockIconWindowDescriptor: Equatable, Sendable {
1314
let isKnownSettingsWindow: Bool
1415

1516
var isSettingsWindow: Bool {
16-
if self.isKnownSettingsWindow {
17-
return true
18-
}
19-
guard let identifier else { return false }
20-
return identifier == SettingsWindowIdentity.identifier
21-
|| identifier.contains("com_apple_SwiftUI_Settings")
17+
self.isKnownSettingsWindow || self.identifier == SettingsWindowIdentity.identifier
18+
}
19+
20+
var isPlaceholderSettingsWindow: Bool {
21+
PlaceholderSettingsWindowDecision.shouldClose(
22+
identifier: self.identifier,
23+
frameAutosaveName: self.frameAutosaveName,
24+
isKnownSettingsWindow: self.isKnownSettingsWindow)
2225
}
2326

2427
var isSparkleWindow: Bool {
@@ -28,6 +31,7 @@ struct DockIconWindowDescriptor: Equatable, Sendable {
2831
}
2932

3033
var isRealWindow: Bool {
34+
guard !self.isPlaceholderSettingsWindow else { return false }
3135
guard self.isVisible, !self.isMiniaturized, self.canBecomeKey else { return false }
3236
guard !self.isTinyWindow, !self.isStatusBarWindow else { return false }
3337
return true
@@ -56,6 +60,7 @@ struct DockIconWindowDescriptor: Equatable, Sendable {
5660

5761
return Self(
5862
identifier: window.identifier?.rawValue,
63+
frameAutosaveName: window.frameAutosaveName,
5964
title: window.title,
6065
classNames: classNames,
6166
width: window.frame.width,
@@ -75,7 +80,7 @@ enum DockIconPolicyDecision {
7580
}
7681

7782
static func shouldPromoteForPresentedWindow(_ window: DockIconWindowDescriptor) -> Bool {
78-
window.isVisible && (window.isSettingsWindow || window.isSparkleWindow)
83+
window.isVisible && !window.isPlaceholderSettingsWindow && (window.isSettingsWindow || window.isSparkleWindow)
7984
}
8085

8186
/// Windows that became eligible for Dock promotion since the last evaluation.

‎Sources/CodexBar/MenuBarMetricWindowResolver.swift‎

Lines changed: 2 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -76,10 +76,8 @@ enum MenuBarMetricWindowResolver {
7676
-> RateWindow?
7777
{
7878
if presentation.automaticSelectionPrioritizesExhaustedWindow,
79-
let exhausted = exhaustedWindow(
80-
primary: snapshot.primary,
81-
secondary: snapshot.secondary,
82-
tertiary: snapshot.tertiary)
79+
let exhausted = [snapshot.primary, snapshot.secondary, snapshot.tertiary]
80+
.compactMap(\.self).first(where: { $0.usedPercent >= 100 })
8381
{
8482
return exhausted
8583
}
@@ -171,17 +169,6 @@ enum MenuBarMetricWindowResolver {
171169
return family
172170
}
173171

174-
private static func exhaustedWindow(
175-
primary: RateWindow?,
176-
secondary: RateWindow?,
177-
tertiary: RateWindow?)
178-
-> RateWindow?
179-
{
180-
[primary, secondary, tertiary]
181-
.compactMap(\.self)
182-
.first { $0.usedPercent >= 100 }
183-
}
184-
185172
/// The Claude spend-limit window when the account only exposes an enterprise/extra-usage spend limit
186173
/// and has no real session/weekly quota lanes (`primary` nil, a `.spendLimit` window, or an explicitly
187174
/// marked placeholder). Lets the automatic and combined metrics surface the spend limit instead of an empty

‎Sources/CodexBar/PlaceholderSettingsWindowGuard.swift‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ final class PlaceholderSettingsWindowGuard {
8181
defer { self.closingWindows.remove(identity) }
8282
// Record before closing: close can synchronously trigger another window notification.
8383
self.closedWindows.add(window)
84+
window.isRestorable = false
8485
self.closeWindow(window)
8586
closedCount += 1
8687
}

‎Sources/CodexBar/Providers/Shared/ProviderImplementationManifest.swift‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ enum ProviderImplementationManifest {
7575
PluginAPIKeyProviderImplementation(spec: AiAndProviderDescriptor.spec),
7676
ZoomMateProviderImplementation(),
7777
PluginAPIKeyProviderImplementation(spec: XAIProviderDescriptor.spec),
78+
PluginCookieProviderImplementation(spec: XAPIProviderDescriptor.spec),
7879
NotionProviderImplementation(),
7980
IBMBobProviderImplementation(),
8081
NousProviderImplementation(),
Lines changed: 1 addition & 0 deletions
Loading

‎Sources/CodexBar/SpendDashboardTrendPanel.swift‎

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,11 @@ struct SpendDashboardTrendPanel: View {
1313
@State private var focusedInterval: DateInterval?
1414

1515
var body: some View {
16-
let legendProviders = self.legendProviders
16+
let legendProviders = SpendTrendChartModel(
17+
group: self.group,
18+
section: self.activeSection,
19+
day: self.day,
20+
overviewInterval: self.focusedInterval).legendProviders(in: self.group)
1721
let sourceID = self.selectedSourceID.flatMap { id in legendProviders.contains { $0.id == id } ? id : nil }
1822
return SpendDashboardPanel {
1923
VStack(alignment: .leading, spacing: 14) {
@@ -102,14 +106,6 @@ struct SpendDashboardTrendPanel: View {
102106
SpendTrendChartModel.focusedDay(self.focusedDay, group: self.group)
103107
}
104108

105-
private var legendProviders: [SpendDashboardModel.ProviderRow] {
106-
SpendTrendChartModel(
107-
group: self.group,
108-
section: self.activeSection,
109-
day: self.day,
110-
overviewInterval: self.focusedInterval).legendProviders(in: self.group)
111-
}
112-
113109
private func dayNavigation(_ day: Date) -> some View {
114110
let index = self.hourlyDays.firstIndex(of: day)
115111
return HStack(spacing: 8) {
@@ -433,7 +429,7 @@ struct SpendTrendChart: View {
433429
}
434430

435431
private func inspectionRows(_ bucket: SpendTrendChartModel.Bucket?) -> some View {
436-
ForEach(bucket?.segments.filter { $0.cost > 0 } ?? []) { segment in
432+
ForEach(bucket?.segments ?? []) { segment in
437433
HStack(spacing: 5) {
438434
Circle().fill(Color(nsColor: SpendChartPalette.color(
439435
sourceID: segment.sourceID, provider: segment.provider, providers: self.group.providers)))

‎Sources/CodexBar/SpendTrendChartModel.swift‎

Lines changed: 19 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ struct SpendTrendChartModel {
4848
{
4949
self.section = section
5050
self.calendar = group.calendar
51-
let points: [Segment]
51+
let points: [(source: (id: String, provider: UsageProvider, name: String), date: Date, cost: Double)]
5252
if section == .hourly {
5353
let day = day ?? Self.focusedDay(nil, group: group) ?? group.chartDomain.lowerBound
5454
let interval = group.calendar.dateInterval(of: .day, for: day)
@@ -59,14 +59,7 @@ struct SpendTrendChartModel {
5959
points = group.hourlyPoints.filter {
6060
$0.hour >= interval.start && $0.hour < interval.end
6161
}.map {
62-
Segment(
63-
sourceID: $0.sourceID,
64-
provider: $0.provider,
65-
name: $0.providerName,
66-
date: $0.hour,
67-
cost: $0.cost,
68-
start: 0,
69-
end: 0)
62+
(($0.sourceID, $0.provider, $0.providerName), $0.hour, $0.cost)
7063
}
7164
} else {
7265
self.scope = overviewInterval.map { $0.start...$0.end } ?? group.chartDomain
@@ -80,39 +73,30 @@ struct SpendTrendChartModel {
8073
points = group.dailyPoints.filter {
8174
$0.day >= scope.lowerBound && $0.day < scope.upperBound
8275
}.map {
83-
Segment(
84-
sourceID: $0.sourceID,
85-
provider: $0.provider,
86-
name: $0.providerName,
87-
date: $0.day,
88-
cost: $0.cost,
89-
start: 0,
90-
end: 0)
76+
(($0.sourceID, $0.provider, $0.providerName), $0.day, $0.cost)
9177
}
9278
}
93-
let filtered = points.filter { sourceID == nil || $0.sourceID == sourceID }
79+
let filtered = points.filter { sourceID == nil || $0.source.id == sourceID }
9480
let unit = self.unit
9581
let grouped = Dictionary(grouping: filtered) {
9682
group.calendar.dateInterval(of: unit, for: $0.date)?.start ?? $0.date
9783
}
9884
self.buckets = grouped.keys.sorted().compactMap { date in
9985
var end = 0.0
100-
let sources = Dictionary(grouping: grouped[date] ?? [], by: \.sourceID)
101-
let segments = sources.keys.sorted().compactMap { sourceID -> Segment? in
102-
guard let point = sources[sourceID]?.first else { return nil }
103-
let cost = (sources[sourceID] ?? []).reduce(0) { $0 + $1.cost }
104-
var segment = Segment(
105-
sourceID: point.sourceID,
106-
provider: point.provider,
107-
name: point.name,
86+
let sources = Dictionary(grouping: grouped[date] ?? [], by: { $0.source.id })
87+
let segments = sources.sorted { $0.key < $1.key }.map { _, points in
88+
let point = points[0]
89+
let cost = points.reduce(0) { $0 + $1.cost }
90+
let start = end
91+
end += cost
92+
return Segment(
93+
sourceID: point.source.id,
94+
provider: point.source.provider,
95+
name: point.source.name,
10896
date: date,
10997
cost: cost,
110-
start: 0,
111-
end: 0)
112-
segment.start = end
113-
end += cost
114-
segment.end = end
115-
return segment
98+
start: start,
99+
end: end)
116100
}
117101
return end.isFinite ? Bucket(date: date, segments: segments) : nil
118102
}
@@ -250,7 +234,7 @@ struct SpendTrendChartModel {
250234
}
251235

252236
func legendProviders(in group: SpendDashboardModel.CurrencyGroup) -> [SpendDashboardModel.ProviderRow] {
253-
let sourceIDs = Set(self.segments.filter { $0.cost > 0 }.map(\.sourceID))
237+
let sourceIDs = Set(self.segments.map(\.sourceID))
254238
return group.providers.filter { sourceIDs.contains($0.id) }
255239
}
256240

@@ -269,10 +253,8 @@ struct SpendTrendChartModel {
269253

270254
static func focusedDay(_ day: Date?, group: SpendDashboardModel.CurrencyGroup) -> Date? {
271255
let days = self.hourlyDays(group)
272-
if let selectedDay = group.selectedDay.map({ group.calendar.startOfDay(for: $0) }),
273-
days.contains(selectedDay) { return selectedDay }
274-
if let day = day.map({ group.calendar.startOfDay(for: $0) }), days.contains(day) { return day }
275-
return days.last
256+
return [group.selectedDay, day].compactMap { $0.map { group.calendar.startOfDay(for: $0) } }
257+
.first(where: days.contains) ?? days.last
276258
}
277259
}
278260

0 commit comments

Comments
 (0)