Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

### Fixed
- Menu bar: defer merged-menu close rebuilds and cache repeated menu-card height measurements so dismissing or rapidly switching the merged dropdown avoids rebuilding SwiftUI-backed cards on the main thread (#1274, #1286). Thanks @hhh2210!
- Menu bar: observe a compact icon-state signature so merged status icons no longer redraw for provider snapshot changes that cannot affect the visible icon (#1297). Thanks @hhh2210!

## 0.32.4 — 2026-06-02

Expand Down
46 changes: 20 additions & 26 deletions Sources/CodexBar/StatusItemController+Animation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,7 @@ extension StatusItemController {
}

@discardableResult
func applyIcon(phase: Double?) -> Bool { // swiftlint:disable:this function_body_length
func applyIcon(phase: Double?) -> Bool {
guard let button = self.statusItem.button else { return false }

let style = self.store.iconStyle
Expand Down Expand Up @@ -269,17 +269,7 @@ extension StatusItemController {
// In show-used mode, `0` means "unused", not "missing". Keep the weekly lane present.
weekly = Self.loadingPercentEpsilon
}
let codexProjection = self.store.codexConsumerProjectionIfNeeded(
for: primaryProvider,
surface: .menuBar,
snapshotOverride: snapshot,
now: snapshot?.updatedAt ?? Date())
var credits: Double? =
codexProjection?.menuBarFallback == .creditsBalance
? self.store.codexMenuBarCreditsRemaining(
snapshotOverride: snapshot,
now: snapshot?.updatedAt ?? Date())
: nil
var credits = self.menuBarCreditsRemainingForIcon(provider: primaryProvider, snapshot: snapshot)
var stale = self.store.isStale(provider: primaryProvider)
var morphProgress: Double?

Expand Down Expand Up @@ -486,17 +476,7 @@ extension StatusItemController {
// In show-used mode, `0` means "unused", not "missing". Keep the weekly lane present.
weekly = Self.loadingPercentEpsilon
}
let codexProjection = self.store.codexConsumerProjectionIfNeeded(
for: provider,
surface: .menuBar,
snapshotOverride: snapshot,
now: snapshot?.updatedAt ?? Date())
var credits: Double? =
codexProjection?.menuBarFallback == .creditsBalance
? self.store.codexMenuBarCreditsRemaining(
snapshotOverride: snapshot,
now: snapshot?.updatedAt ?? Date())
: nil
var credits = self.menuBarCreditsRemainingForIcon(provider: provider, snapshot: snapshot)
var stale = self.store.isStale(provider: provider)
var morphProgress: Double?

Expand Down Expand Up @@ -587,11 +567,25 @@ extension StatusItemController {
return false
}

private static func iconSignatureValue(_ value: Double?) -> String {
static func iconSignatureValue(_ value: Double?) -> String {
guard let value else { return "nil" }
return String(format: "%.3f", value)
}
Comment on lines +570 to 573

func menuBarCreditsRemainingForIcon(provider: UsageProvider, snapshot: UsageSnapshot?) -> Double? {
// Derive the menu-bar credits fallback from the same Codex projection path the rendered
// icon and menu use (`codexConsumerProjection` -> `menuBarFallback`), instead of a
// hand-rolled rate-window predicate. The projection is pure value composition over
// already-loaded snapshot/credits state (no IO), so this stays cheap while keeping the
// icon render, this signature input, and the menu-bar fallback semantics on a single
// source of truth — a hand-rolled approximation can silently drift from the projection
// as its fallback logic evolves.
guard provider == .codex else { return nil }
return self.store.codexMenuBarCreditsRemaining(
snapshotOverride: snapshot,
now: snapshot?.updatedAt ?? Date())
}
Comment on lines +575 to +587

func quotaWarningFlashActive(provider: UsageProvider, now: Date = Date()) -> Bool {
guard let until = self.quotaWarningFlashUntil[provider] else { return false }
if until > now { return true }
Expand Down Expand Up @@ -894,7 +888,7 @@ extension StatusItemController {
self.menuBarMetricWindow(for: provider, snapshot: snapshot)
}

private func primaryProviderForUnifiedIcon() -> UsageProvider {
func primaryProviderForUnifiedIcon() -> UsageProvider {
// When "show highest usage" is enabled, auto-select the provider closest to rate limit.
if self.settings.menuBarShowsHighestUsage,
self.shouldMergeIcons,
Expand Down Expand Up @@ -966,7 +960,7 @@ extension StatusItemController {
self.tickBlink(now: now)
}

private func shouldAnimate(provider: UsageProvider, mergeIcons: Bool? = nil) -> Bool {
func shouldAnimate(provider: UsageProvider, mergeIcons: Bool? = nil) -> Bool {
if self.store.debugForceAnimation { return true }

let isMerged = mergeIcons ?? self.shouldMergeIcons
Expand Down
70 changes: 70 additions & 0 deletions Sources/CodexBar/StatusItemController+IconObservation.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import CodexBarCore
import Foundation

extension StatusItemController {
func storeIconObservationSignature() -> String {
let showBrandPercent = self.settings.menuBarShowsBrandIconWithPercent
let mergeIcons = self.shouldMergeIcons
let visibleProviders = self.store.enabledProvidersForDisplay().map(\.rawValue).sorted().joined(separator: ",")
let providerSignatures: String
let primaryProvider: UsageProvider?
if mergeIcons {
let primary = self.primaryProviderForUnifiedIcon()
primaryProvider = primary
providerSignatures = [
self.providerStoreIconObservationSignature(for: primary, showBrandPercent: showBrandPercent),
"mergedStatus=\(self.mergedIconStatusIndicator().rawValue)",
].joined(separator: "||")
} else {
primaryProvider = nil
providerSignatures = UsageProvider.allCases
.filter { self.isVisible($0) }
.map { self.providerStoreIconObservationSignature(for: $0, showBrandPercent: showBrandPercent) }
.joined(separator: "||")
}
Comment on lines +8 to +24
return [
"merge=\(mergeIcons ? "1" : "0")",
"visible=\(visibleProviders)",
"primary=\(primaryProvider?.rawValue ?? "nil")",
"iconStyle=\(self.store.iconStyle.rawValue)",
"showUsed=\(self.settings.usageBarsShowUsed ? "1" : "0")",
"brandPercent=\(showBrandPercent ? "1" : "0")",
"needsAnimation=\(self.needsMenuBarIconAnimation() ? "1" : "0")",
providerSignatures,
].joined(separator: "|")
}

private func providerStoreIconObservationSignature(for provider: UsageProvider, showBrandPercent: Bool) -> String {
let snapshot = self.store.snapshot(for: provider)
let style = self.store.style(for: provider)
let resolved = snapshot.map {
IconRemainingResolver.resolvedPercents(
snapshot: $0,
style: style,
showUsed: self.settings.usageBarsShowUsed)
}
let creditsRemaining = self.menuBarCreditsRemainingForIcon(provider: provider, snapshot: snapshot)
let displayText = showBrandPercent ? self.menuBarDisplayText(for: provider, snapshot: snapshot) : nil

return [
provider.rawValue,
"style=\(style.rawValue)",
"primary=\(Self.iconSignatureValue(resolved?.primary))",
"weekly=\(Self.iconSignatureValue(resolved?.secondary))",
"credits=\(Self.iconSignatureValue(creditsRemaining))",
"stale=\(self.store.isStale(provider: provider) ? "1" : "0")",
"status=\(self.store.statusIndicator(for: provider).rawValue)",
"anim=\(self.shouldAnimate(provider: provider) ? "1" : "0")",
"refreshing=\(self.store.refreshingProviders.contains(provider) ? "1" : "0")",
"text=\(displayText ?? "nil")",
].joined(separator: "|")
}

private func mergedIconStatusIndicator() -> ProviderStatusIndicator {
for provider in self.store.enabledProvidersForDisplay() {
let indicator = self.store.statusIndicator(for: provider)
if indicator.hasIssue { return indicator }
}
return .none
}
}
45 changes: 0 additions & 45 deletions Sources/CodexBar/StatusItemController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -467,51 +467,6 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin
}
}

func storeIconObservationSignature() -> String {
let showBrandPercent = self.settings.menuBarShowsBrandIconWithPercent
let mergeIcons = self.shouldMergeIcons
let needsAnimation = self.needsMenuBarIconAnimation()
let providerSignatures = UsageProvider.allCases.map {
self.providerStoreIconObservationSignature(for: $0, showBrandPercent: showBrandPercent)
}.joined(separator: "||")
let visibleProviders = self.store.enabledProvidersForDisplay().map(\.rawValue).sorted().joined(separator: ",")
return [
"merge=\(mergeIcons ? "1" : "0")",
"visible=\(visibleProviders)",
"iconStyle=\(String(describing: self.store.iconStyle))",
"brandPercent=\(showBrandPercent ? "1" : "0")",
"needsAnimation=\(needsAnimation ? "1" : "0")",
providerSignatures,
].joined(separator: "|")
}

private func providerStoreIconObservationSignature(for provider: UsageProvider, showBrandPercent: Bool) -> String {
let snapshot = self.store.snapshot(for: provider)
let stale = self.store.isStale(provider: provider)
let status = self.store.statusIndicator(for: provider).rawValue
let isVisibleForAnimation = self.shouldMergeIcons ? self.isEnabled(provider) : self.isVisible(provider)
let isAnimating = isVisibleForAnimation && !stale && snapshot == nil
let isRefreshingWarpPlaceholder = self.store.refreshingProviders.contains(provider)
let creditsRemaining = provider == .codex
? self.store.codexMenuBarCreditsRemaining(
snapshotOverride: snapshot,
now: snapshot?.updatedAt ?? Date())
: nil
let displayText = showBrandPercent ? self.menuBarDisplayText(for: provider, snapshot: snapshot) : nil

return [
provider.rawValue,
"style=\(String(describing: self.store.style(for: provider)))",
"snapshot=\(String(describing: snapshot))",
"stale=\(stale ? "1" : "0")",
"status=\(status)",
"anim=\(isAnimating ? "1" : "0")",
"refreshing=\(isRefreshingWarpPlaceholder ? "1" : "0")",
"credits=\(String(describing: creditsRemaining))",
"text=\(displayText ?? "nil")",
].joined(separator: "|")
}

private func observeDebugForceAnimation() {
withObservationTracking {
_ = self.store.debugForceAnimation
Expand Down
2 changes: 1 addition & 1 deletion Sources/CodexBarCore/Providers/Providers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ public enum UsageProvider: String, CaseIterable, Sendable, Codable {

// swiftformat:enable sortDeclarations

public enum IconStyle: Sendable, CaseIterable {
public enum IconStyle: String, Sendable, CaseIterable {
case codex
case openai
case claude
Expand Down
146 changes: 142 additions & 4 deletions Tests/CodexBarTests/StatusItemIconObservationSignatureTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ struct StatusItemIconObservationSignatureTests {
if let codexMeta = registry.metadata[.codex] {
settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true)
}
if let claudeMeta = registry.metadata[.claude] {
settings.setProviderEnabled(provider: .claude, metadata: claudeMeta, enabled: false)
}

let fetcher = UsageFetcher()
let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings)
Expand Down Expand Up @@ -55,6 +58,126 @@ struct StatusItemIconObservationSignatureTests {
#expect(controller.storeIconObservationSignature() == baseline)
}

@Test
func `store icon observation signature ignores non visual snapshot churn`() {
let (_, store, controller) = self.makeController(
suiteName: "StatusItemIconObservationSignatureTests-snapshot-metadata")
defer { controller.releaseStatusItemsForTesting() }

let baseline = controller.storeIconObservationSignature()

store._setSnapshotForTesting(
Self.makeSnapshot(
provider: .codex,
email: "rotated-account@example.com",
updatedAt: Date(timeIntervalSince1970: 200)),
provider: .codex)

let signature = controller.storeIconObservationSignature()

#expect(signature == baseline)
#expect(!signature.contains("rotated-account@example.com"))
}

@Test
func `merged store icon observation signature ignores non primary snapshot churn`() throws {
let (settings, store, controller) = self.makeController(
suiteName: "StatusItemIconObservationSignatureTests-merged-secondary-snapshot")
defer { controller.releaseStatusItemsForTesting() }

let registry = ProviderRegistry.shared
let claudeMetadata = try #require(registry.metadata[.claude])
settings.setProviderEnabled(provider: .claude, metadata: claudeMetadata, enabled: true)
store._setSnapshotForTesting(
Self.makeSnapshot(provider: .claude, email: "claude@example.com"),
provider: .claude)
let baseline = controller.storeIconObservationSignature()

store._setSnapshotForTesting(
Self.makeSnapshot(
provider: .claude,
email: "changed@example.com",
primaryUsedPercent: 99,
secondaryUsedPercent: 88,
updatedAt: Date(timeIntervalSince1970: 300)),
provider: .claude)

#expect(controller.storeIconObservationSignature() == baseline)
}

@Test
func `store icon observation signature changes when icon percentages change`() {
let (_, store, controller) = self.makeController(
suiteName: "StatusItemIconObservationSignatureTests-percent-change")
defer { controller.releaseStatusItemsForTesting() }

let baseline = controller.storeIconObservationSignature()

store._setSnapshotForTesting(
Self.makeSnapshot(
provider: .codex,
email: "icon@example.com",
primaryUsedPercent: 42,
secondaryUsedPercent: 63),
provider: .codex)

#expect(controller.storeIconObservationSignature() != baseline)
}

@Test
func `store icon observation signature changes when credit fallback changes`() {
let (_, store, controller) = self.makeController(
suiteName: "StatusItemIconObservationSignatureTests-credit-fallback")
defer { controller.releaseStatusItemsForTesting() }

store._setSnapshotForTesting(
Self.makeSnapshot(
provider: .codex,
email: "icon@example.com",
primaryUsedPercent: 100,
secondaryUsedPercent: 20),
provider: .codex)
store.credits = CreditsSnapshot(remaining: 80, events: [], updatedAt: Date(timeIntervalSince1970: 100))
let baseline = controller.storeIconObservationSignature()

store.credits = CreditsSnapshot(remaining: 42, events: [], updatedAt: Date(timeIntervalSince1970: 200))

#expect(controller.storeIconObservationSignature() != baseline)
}

@Test
func `store icon observation signature ignores unused credit balance`() {
let (_, store, controller) = self.makeController(
suiteName: "StatusItemIconObservationSignatureTests-unused-credits")
defer { controller.releaseStatusItemsForTesting() }

store.credits = CreditsSnapshot(remaining: 80, events: [], updatedAt: Date(timeIntervalSince1970: 100))
let baseline = controller.storeIconObservationSignature()

store.credits = CreditsSnapshot(remaining: 42, events: [], updatedAt: Date(timeIntervalSince1970: 200))

#expect(controller.storeIconObservationSignature() == baseline)
}

@Test
func `merged store icon observation signature changes when non primary status changes`() throws {
let (settings, store, controller) = self.makeController(
suiteName: "StatusItemIconObservationSignatureTests-merged-secondary-status")
defer { controller.releaseStatusItemsForTesting() }

let registry = ProviderRegistry.shared
let claudeMetadata = try #require(registry.metadata[.claude])
settings.setProviderEnabled(provider: .claude, metadata: claudeMetadata, enabled: true)
let baseline = controller.storeIconObservationSignature()

store.statuses[.claude] = ProviderStatus(
indicator: .major,
description: "Claude status issue",
updatedAt: Date(timeIntervalSince1970: 20))

#expect(controller.storeIconObservationSignature() != baseline)
}

@Test
func `store icon observation signature changes when status indicator changes`() {
let (_, store, controller) = self.makeController(
Expand All @@ -75,11 +198,26 @@ struct StatusItemIconObservationSignatureTests {
#expect(controller.storeIconObservationSignature() != baseline)
}

private static func makeSnapshot(provider: UsageProvider, email: String) -> UsageSnapshot {
private static func makeSnapshot(
provider: UsageProvider,
email: String,
primaryUsedPercent: Double = 10,
secondaryUsedPercent: Double = 20,
updatedAt: Date = Date(timeIntervalSince1970: 100))
-> UsageSnapshot
{
UsageSnapshot(
primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil),
secondary: RateWindow(usedPercent: 20, windowMinutes: 10080, resetsAt: nil, resetDescription: nil),
updatedAt: Date(timeIntervalSince1970: 100),
primary: RateWindow(
usedPercent: primaryUsedPercent,
windowMinutes: 300,
resetsAt: nil,
resetDescription: nil),
secondary: RateWindow(
usedPercent: secondaryUsedPercent,
windowMinutes: 10080,
resetsAt: nil,
resetDescription: nil),
updatedAt: updatedAt,
identity: ProviderIdentitySnapshot(
providerID: provider,
accountEmail: email,
Expand Down
Loading