Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
4739dfa
Price OpenCodex usage once per entry and stop per-entry catalog/overl…
olddonkey Aug 22, 2026
aa47362
docs: add changelog entry for #3136
olddonkey Aug 22, 2026
653057d
docs: comment the OpenCodex price-once context and memo semantics
olddonkey Aug 22, 2026
d6d281e
docs: update appcast for 0.54.1
steipete Aug 23, 2026
4b14ed9
chore: open 0.54.2 unreleased changelog section
steipete Aug 23, 2026
6ef5c5a
Stop re-merging the Codex plan-utilization history with itself on eve…
olddonkey Aug 23, 2026
07bd85a
Fix Codex day cost blanked by trace-only priority turns (#3150)
olddonkey Aug 23, 2026
63dc1cd
docs: credit #3141 and #3150 changelog entries
steipete Aug 23, 2026
c2701d2
fix(qwen-cloud): restore Brave browser support in cookie import (#3148)
umutkeltek Aug 23, 2026
b843190
Fix OpenRouter completed-day activity query (#3138)
kiranmagic7 Aug 23, 2026
0b715a4
Preserve unknown Grok period usage (#3159)
anupamchugh Aug 23, 2026
9b034df
fix: report non-writable CLI path conflicts (#3153)
yicone Aug 23, 2026
daa16fb
Fix single-quota icon scaling (#3155)
akshayprabhu200 Aug 23, 2026
87a16ff
docs: credit #3138 #3148 #3153 #3155 #3159 changelog entries
steipete Aug 23, 2026
6538ac3
fix(spend): silent refresh and invalidation coverage (#3106)
Yuxin-Qiao Aug 23, 2026
926f5b3
Improve Antigravity retrieval: retired Flash alias and offline fallba…
Yuxin-Qiao Aug 23, 2026
7427660
feat(spend): add tokscale-compatible local readers for Cursor and Ant…
Yuxin-Qiao Aug 23, 2026
ec1c13d
docs: credit #3106 #3113 #3119 changelog entries
steipete Aug 23, 2026
b83a967
feat: add CHF display currency (#3149)
steipete Aug 23, 2026
3f3d75b
test: fix currency fixtures after CHF became supported
steipete Aug 23, 2026
d927836
fix(codex): tokscale parity for token counts - max cached, clamped, r…
Yuxin-Qiao Aug 23, 2026
23ccb7d
docs: credit #3120 changelog entry
steipete Aug 23, 2026
03c5e52
test: fix remaining CHF unconvertible fixtures after #3149
steipete Aug 23, 2026
1699ad8
fix: detect ChatGPT-hosted Codex activity (#3163)
steipete Aug 23, 2026
6de934e
fix(antigravity): reuse signed-in agy for quota refresh (#3161)
steipete Aug 23, 2026
100deb6
docs: credit #3161 and #3163 changelog entries
steipete Aug 24, 2026
146f473
Fix Claude web cookie refresh (#3162)
Zihao-Qi Aug 24, 2026
971ba9d
docs: credit #3162 changelog entry
steipete Aug 24, 2026
c670e81
Merge branch 'pr3136' into verify-3136
steipete Aug 24, 2026
338cc2d
chore: regenerate parser hash on merged tree
steipete Aug 24, 2026
a5d2d21
test: include 0.54.2 parity hash in predecessor list
steipete Aug 24, 2026
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
147 changes: 147 additions & 0 deletions .github/pr-proof/3106-dashboard-snapshot.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
{
"generatedAt" : "2026-08-23T05:39:37Z",
"host" : {
"codexBarVersion" : null,
"refreshIntervalSeconds" : 0
},
"providers" : [
{
"cost" : null,
"credits" : null,
"display" : {
"accentColor" : "#49A3B0",
"priority" : "normal",
"sortKey" : 0
},
"enabled" : true,
"error" : {
"code" : 1,
"kind" : "provider",
"message" : "Network error: The operation couldn’t be completed. Operation not permitted"
},
"id" : "codex",
"identity" : null,
"name" : "Codex",
"source" : "auto",
"status" : null,
"updatedAt" : "2026-08-23T05:39:36Z",
"windows" : [

]
},
{
"cost" : null,
"credits" : null,
"display" : {
"accentColor" : "#3B82F6",
"priority" : "normal",
"sortKey" : 10
},
"enabled" : true,
"error" : null,
"id" : "opencodego",
"identity" : null,
"name" : "OpenCode Go",
"source" : "local",
"status" : null,
"updatedAt" : "2026-08-23T05:39:32Z",
"windows" : [
{
"kind" : "session",
"label" : "5-hour",
"remainingPercent" : 100,
"resetAt" : "2026-08-23T10:39:32Z",
"usedPercent" : 0
},
{
"kind" : "weekly",
"label" : "Weekly",
"remainingPercent" : 92.1,
"resetAt" : "2026-08-23T23:59:59Z",
"usedPercent" : 7.9
},
{
"kind" : "tertiary",
"label" : "Monthly",
"remainingPercent" : 96,
"resetAt" : "2026-09-20T04:11:23Z",
"usedPercent" : 4
}
]
},
{
"cost" : null,
"credits" : null,
"display" : {
"accentColor" : "#60BA7E",
"priority" : "normal",
"sortKey" : 20
},
"enabled" : true,
"error" : {
"code" : 1,
"kind" : "provider",
"message" : "antigravity usage timed out"
},
"id" : "antigravity",
"identity" : null,
"name" : "Antigravity",
"source" : "auto",
"status" : null,
"updatedAt" : "2026-08-23T05:39:37Z",
"windows" : [

]
},
{
"cost" : null,
"credits" : null,
"display" : {
"accentColor" : "#00BFA5",
"priority" : "normal",
"sortKey" : 30
},
"enabled" : true,
"error" : {
"code" : 1,
"kind" : "provider",
"message" : "The operation couldn’t be completed. Operation not permitted"
},
"id" : "cursor",
"identity" : null,
"name" : "Cursor",
"source" : "auto",
"status" : null,
"updatedAt" : "2026-08-23T05:39:37Z",
"windows" : [

]
},
{
"cost" : null,
"credits" : null,
"display" : {
"accentColor" : "#527DF0",
"priority" : "normal",
"sortKey" : 40
},
"enabled" : true,
"error" : {
"code" : 1,
"kind" : "provider",
"message" : "The operation couldn’t be completed. Operation not permitted"
},
"id" : "deepseek",
"identity" : null,
"name" : "DeepSeek",
"source" : "auto",
"status" : null,
"updatedAt" : "2026-08-23T05:39:37Z",
"windows" : [

]
}
],
"schemaVersion" : 1,
"staleAfterSeconds" : 180
}
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,28 @@
# Changelog

## 0.54.2 — Unreleased

### Performance
- OpenCodex: price each `usage.jsonl` entry once with a pre-resolved models.dev catalog and custom-pricing overlay, memoize day/hour buckets per calendar interval, and read the models.dev cache metadata with a plain `stat` instead of an extended-attribute read — the OpenCodex spend refresh drops from ~12 s to ~3 s on a 35k-entry log with identical output; existing cost caches are adopted on upgrade, not rebuilt (#3136). Thanks @olddonkey!
- Claude: web-cookie refresh works in ad-hoc development builds by routing cookie cache entries to process memory, keeping persistent Keychain storage for signed builds and OAuth credentials; cookie imports prefer Chrome and evaluate other browsers lazily (#3162). Thanks @Zihao-Qi!

- Adaptive (agent-aware) refresh now detects ongoing Codex activity from the unified ChatGPT.app: the ChatGPT-bundled Codex is recognized at approved locations (signing-validated) and recent rollout writes hold the 5-minute active cadence (#3160, #3163).
- Antigravity: menu-bar quota refresh reuses an already signed-in `agy` CLI without requiring the desktop app, and logged-out/keyring-timeout states show sign-in guidance instead of "Launch Antigravity" (#3146, #3161).
- Codex: tokscale parity for local token counts — cached usage derives from the larger of `cached_input_tokens`/`cache_read_input_tokens`, out-of-order token_count events are detected field-level before watermark latching, and bare usage rows in non-event rollout lines parse (#3120). Thanks @Yuxin-Qiao!
- Added CHF (Swiss Franc) to the display currency options (#3149).
- Spend dashboard: silently refresh independent providers (Claude/Cursor) when their token publications update, bucket the activity heatmap with the configured IANA timezone, and stop coalescing display-affecting ownership and revision changes (#3106). Thanks @Yuxin-Qiao!
- Spend: add tokscale-compatible local readers for Cursor and Antigravity local history (#3113). Thanks @Yuxin-Qiao!
- Antigravity: map retired Flash wire IDs to their current tier, and count local conversations as an offline fallback when the desktop app is unavailable (#3119). Thanks @Yuxin-Qiao!
- Antigravity: reuse a signed-in `agy` for menu-bar quota refreshes without the desktop app, and report CLI sign-in failures instead of asking users to launch an unavailable app (#3146).
- Fixed single-quota menu-bar icons understating remaining usage: a provider's only meaningful quota now renders as one prominent meter instead of half an icon next to a reserved empty lane, including when it arrives in the secondary slot (#3154, #3155). Thanks @akshayprabhu200!
- Grok: report period-only CLI-proxy responses as unknown usage instead of 0% when Grok Build has hit its free limit, keeping identity and reset metadata (#3157, #3159). Thanks @anupamchugh!
- Refresh: detect ongoing Codex conversations hosted inside ChatGPT.app in Adaptive (agent-aware) mode without treating an idle open app as coding activity (#3160).
- OpenRouter: request the latest completed UTC day from the Activity API instead of the current date, fixing the HTTP 400 that suppressed spend history (#3133, #3138). Thanks @kiranmagic7!
- Qwen Cloud: restored Brave browser support in cookie import (#3148). Thanks @umutkeltek!
- CLI install: report success even when unrelated PATH directories are non-writable, while still listing genuine `codexbar` conflicts from earlier PATH entries (#3153). Thanks @yicone!
- Fixed the Codex cost card blanking Today and recent days when priority processing is in use: row-ownership evidence now accepts the trace database's tier classification instead of discarding whole (day, model) groups (#3150). Thanks @olddonkey!
- Stopped re-merging the Codex plan-utilization history with itself on every refresh and menu open — the legacy-bucket migration now runs only when a non-canonical bucket actually exists (#3141). Thanks @olddonkey!

## 0.54.1 — 2026-08-21

### Highlights
Expand Down
70 changes: 61 additions & 9 deletions Sources/CodexBar/IconRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,23 @@ import CodexBarCore

// swiftlint:disable:next type_body_length
enum IconRenderer {
struct QuotaLayoutPolicy: Hashable {
let reservesMissingSecondaryLane: Bool
let treatsExhaustedSecondaryAsMissing: Bool

static func provider(_ provider: UsageProvider) -> Self {
let presentation = ProviderDescriptorRegistry.descriptor(for: provider).presentation
return Self(
reservesMissingSecondaryLane: presentation.reservesMissingSecondaryIconLane,
treatsExhaustedSecondaryAsMissing: presentation.treatsExhaustedSecondaryIconWindowAsMissing)
}

static func style(_ style: IconStyle) -> Self {
UsageProvider(rawValue: style.rawValue).map(self.provider)
?? Self(reservesMissingSecondaryLane: false, treatsExhaustedSecondaryAsMissing: false)
}
}

private static let creditsCap: Double = 1000
private static let baseSize = NSSize(width: 18, height: 18)
// Render to an 18×18 pt template (36×36 px at 2×) to match the system menu bar size.
Expand All @@ -28,6 +45,11 @@ enum IconRenderer {

private static let grid = PixelGrid(scale: outputScale)

static func fillWidthPixels(remaining: Double, rectWidth: Int) -> Int {
let clamped = max(0, min(remaining / 100, 1))
return max(0, min(rectWidth, Int((CGFloat(rectWidth) * CGFloat(clamped)).rounded())))
}

private struct IconCacheKey: Hashable {
let primary: Int
let weekly: Int
Expand All @@ -36,6 +58,7 @@ enum IconRenderer {
let style: Int
let indicator: Int
let hideCritters: Bool
let quotaLayoutPolicy: QuotaLayoutPolicy
}

private final class IconCacheStore: @unchecked Sendable {
Expand Down Expand Up @@ -120,8 +143,10 @@ enum IconRenderer {
wiggle: CGFloat = 0,
tilt: CGFloat = 0,
statusIndicator: ProviderStatusIndicator = .none,
hideCritters: Bool = false) -> NSImage
hideCritters: Bool = false,
quotaLayoutPolicy: QuotaLayoutPolicy? = nil) -> NSImage
{
let quotaLayoutPolicy = quotaLayoutPolicy ?? .style(style)
let shouldCache = blink <= 0.0001 && wiggle <= 0.0001 && tilt <= 0.0001
let render = {
self.renderImage {
Expand Down Expand Up @@ -178,8 +203,7 @@ enum IconRenderer {

// Fill: clip to the capsule and paint a left-to-right rect so the progress edge is straight.
if let remaining {
let clamped = max(0, min(remaining / 100, 1))
let fillWidthPx = max(0, min(rectPx.w, Int((CGFloat(rectPx.w) * CGFloat(clamped)).rounded())))
let fillWidthPx = Self.fillWidthPixels(remaining: remaining, rectWidth: rectPx.w)
if fillWidthPx > 0 {
NSGraphicsContext.current?.cgContext.saveGState()
trackPath.addClip()
Expand Down Expand Up @@ -638,8 +662,7 @@ enum IconRenderer {

let providerPresentation = UsageProvider(rawValue: style.rawValue)
.map { ProviderDescriptorRegistry.descriptor(for: $0).presentation }
let usesMissingSecondaryLayout =
providerPresentation?.treatsExhaustedSecondaryIconWindowAsMissing == true
let usesMissingSecondaryLayout = quotaLayoutPolicy.treatsExhaustedSecondaryAsMissing
let effectiveWeeklyRemaining: Double? = {
if usesMissingSecondaryLayout, let weeklyRemaining, weeklyRemaining <= 0 {
return nil
Expand Down Expand Up @@ -670,7 +693,22 @@ enum IconRenderer {
let twistFactory = decorations.contains(.factory)
let twistWarp = decorations.contains(.warp)

if weeklyAvailable {
if let bottomValue, bottomValue > 0, topValue == nil,
!quotaLayoutPolicy.reservesMissingSecondaryLane,
!usesMissingSecondaryLayout
{
// Some providers surface their only meaningful quota in the secondary slot.
drawBar(
rectPx: creditsRectPx,
remaining: bottomValue,
addNotches: twistNotches,
addFace: twistFace,
addGeminiTwist: twistGemini,
addAntigravityTwist: twistAntigravity,
addFactoryTwist: twistFactory,
addWarpTwist: twistWarp,
blink: blink)
} else if weeklyAvailable {
// Normal: top=primary, bottom=secondary (bonus/weekly).
drawBar(
rectPx: topRectPx,
Expand All @@ -693,8 +731,6 @@ enum IconRenderer {
blink: blink)
drawBar(rectPx: bottomRectPx, remaining: nil, alpha: 0.45)
} else {
// Weekly missing (e.g. Claude enterprise): keep normal layout but
// dim the bottom track to indicate N/A.
if topValue == nil, let ratio = creditsRatio {
// Credits-only: show credits prominently (e.g. credits loaded before usage).
drawBar(
Expand All @@ -709,7 +745,22 @@ enum IconRenderer {
addWarpTwist: twistWarp,
blink: blink)
drawBar(rectPx: creditsBottomRectPx, remaining: nil, alpha: 0.45)
} else if !quotaLayoutPolicy.reservesMissingSecondaryLane, let topValue {
// One meaningful quota should read as one meter. Reserving an unavailable second
// lane makes (for example) 46% remaining look like roughly 23% of the icon.
drawBar(
rectPx: creditsRectPx,
remaining: topValue,
addNotches: twistNotches,
addFace: twistFace,
addGeminiTwist: twistGemini,
addAntigravityTwist: twistAntigravity,
addFactoryTwist: twistFactory,
addWarpTwist: twistWarp,
blink: blink)
} else {
// Missing secondary (for example Claude Enterprise): preserve the normal two-lane
// layout and dim the unavailable lane.
drawBar(
rectPx: topRectPx,
remaining: topValue,
Expand Down Expand Up @@ -765,7 +816,8 @@ enum IconRenderer {
stale: stale,
style: self.styleKey(style),
indicator: self.indicatorKey(statusIndicator),
hideCritters: hideCritters)
hideCritters: hideCritters,
quotaLayoutPolicy: quotaLayoutPolicy)
if let cached = self.cachedIcon(for: key) {
return cached
}
Expand Down
3 changes: 2 additions & 1 deletion Sources/CodexBar/MenuContent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,7 @@ struct StatusIconView: View {
stale: self.store.isStale(provider: self.provider),
style: self.store.style(for: self.provider),
statusIndicator: self.store.statusIndicator(for: self.provider),
hideCritters: self.store.settings.menuBarHidesCritters)
hideCritters: self.store.settings.menuBarHidesCritters,
quotaLayoutPolicy: .provider(self.provider))
}
}
14 changes: 14 additions & 0 deletions Sources/CodexBar/PlanUtilizationHistoryStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@ struct PlanUtilizationSeriesHistory: Codable, Equatable, Sendable {
let windowMinutes: Int
let entries: [PlanUtilizationHistoryEntry]

private enum CodingKeys: String, CodingKey {
case name
case windowMinutes
case entries
}

init(name: PlanUtilizationSeriesName, windowMinutes: Int, entries: [PlanUtilizationHistoryEntry]) {
self.name = name
self.windowMinutes = windowMinutes
Expand All @@ -56,6 +62,14 @@ struct PlanUtilizationSeriesHistory: Codable, Equatable, Sendable {
}
}

init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let name = try container.decode(PlanUtilizationSeriesName.self, forKey: .name)
let windowMinutes = try container.decode(Int.self, forKey: .windowMinutes)
let entries = try container.decode([PlanUtilizationHistoryEntry].self, forKey: .entries)
self.init(name: name, windowMinutes: windowMinutes, entries: entries)
}

var latestCapturedAt: Date? {
self.entries.last?.capturedAt
}
Expand Down
Loading