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 @@ -12,6 +12,7 @@
- LiteLLM: show personal and team spend amounts directly on budget rows while suppressing duplicate budget sections. Thanks @hololee!

### Fixed
- Menu bar: show provider status markers only for the provider rendered in each icon. Thanks @Zihao-Qi!
- Provider probes: cap captured subprocess output at 1 MiB per stream without dropping valid text at a truncated UTF-8 boundary. Thanks @ProspectOre!
- Provider switcher: keep Codex quota rows visible when switching away and back during a manual refresh, including menus with usage-history sections. Thanks @Yuxin-Qiao!
- Bedrock: ignore invalid billing dates when selecting the latest usage values. Thanks @ProspectOre!
Expand Down
24 changes: 20 additions & 4 deletions Sources/CodexBar/IconRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -944,6 +944,8 @@ enum IconRenderer {
y: 2,
width: size,
height: size)
Self.clearStatusOverlayHalo(
NSBezierPath(ovalIn: rect.insetBy(dx: -1, dy: -1)))
let path = NSBezierPath(ovalIn: rect)
color.setFill()
path.fill()
Expand All @@ -953,21 +955,35 @@ enum IconRenderer {
y: 4,
width: 2.0,
height: 6)
let linePath = NSBezierPath(roundedRect: lineRect, xRadius: 1, yRadius: 1)
color.setFill()
linePath.fill()

let dotRect = Self.snapRect(
x: Self.baseSize.width - 6,
y: 2,
width: 2.0,
height: 2.0)

let haloRect = lineRect.union(dotRect).insetBy(dx: -1, dy: -1)
Self.clearStatusOverlayHalo(
NSBezierPath(roundedRect: haloRect, xRadius: 2, yRadius: 2))

let linePath = NSBezierPath(roundedRect: lineRect, xRadius: 1, yRadius: 1)
color.setFill()
linePath.fill()
NSBezierPath(ovalIn: dotRect).fill()
case .none:
break
}
}

private static func clearStatusOverlayHalo(_ path: NSBezierPath) {
guard let ctx = NSGraphicsContext.current?.cgContext else { return }
ctx.saveGState()
ctx.setBlendMode(.clear)
// The fill color is ignored by .clear; it only drives the path fill operation.
NSColor.black.setFill()
path.fill()
ctx.restoreGState()
}

private static func withScaledContext(_ draw: () -> Void) {
guard let ctx = NSGraphicsContext.current?.cgContext else {
draw()
Expand Down
8 changes: 1 addition & 7 deletions Sources/CodexBar/StatusItemController+Animation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -291,13 +291,7 @@ extension StatusItemController {
let tilt: CGFloat =
style == .combined ? 0 : self.tiltAmount(for: primaryProvider) * .pi / 28

let statusIndicator: ProviderStatusIndicator = {
for provider in self.store.enabledProvidersForDisplay() {
let indicator = self.store.statusIndicator(for: provider)
if indicator.hasIssue { return indicator }
}
return .none
}()
let statusIndicator = self.store.statusIndicator(for: primaryProvider)
if showBrandPercent,
let brand = ProviderBrandIcon.image(for: primaryProvider)
{
Expand Down
15 changes: 3 additions & 12 deletions Sources/CodexBar/StatusItemController+IconObservation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,9 @@ extension StatusItemController {
if mergeIcons {
let primary = self.primaryProviderForUnifiedIcon()
primaryProvider = primary
providerSignatures = [
self.providerStoreIconObservationSignature(for: primary, showBrandPercent: showBrandPercent),
"mergedStatus=\(self.mergedIconStatusIndicator().rawValue)",
].joined(separator: "||")
providerSignatures = self.providerStoreIconObservationSignature(
for: primary,
showBrandPercent: showBrandPercent)
} else {
primaryProvider = nil
providerSignatures = UsageProvider.allCases
Expand Down Expand Up @@ -60,12 +59,4 @@ extension StatusItemController {
"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
}
}
49 changes: 49 additions & 0 deletions Tests/CodexBarTests/CodexbarTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,55 @@ struct CodexBarTests {
#expect(remaining.secondary == nil)
}

@Test
func `status overlays cut halos through the quota bar and keep glyphs visible`() throws {
let plain = IconRenderer.makeIcon(
primaryRemaining: 100,
weeklyRemaining: 100,
creditsRemaining: nil,
stale: false,
style: .combined,
statusIndicator: .none)
let plainRep = try #require(plain.representations.compactMap { $0 as? NSBitmapImageRep }.first {
$0.pixelsWide == 36 && $0.pixelsHigh == 36
})

func alpha(_ rep: NSBitmapImageRep, x: Int, y: Int) -> CGFloat {
(rep.colorAt(x: x, y: y) ?? .clear).alphaComponent
}

for indicator in [ProviderStatusIndicator.minor, .major] {
let marked = IconRenderer.makeIcon(
primaryRemaining: 100,
weeklyRemaining: 100,
creditsRemaining: nil,
stale: false,
style: .combined,
statusIndicator: indicator)
let markedRep = try #require(marked.representations.compactMap { $0 as? NSBitmapImageRep }.first {
$0.pixelsWide == 36 && $0.pixelsHigh == 36
})

var cutoutPixels = 0
var glyphPixels = 0
for y in 0..<markedRep.pixelsHigh {
for x in 0..<markedRep.pixelsWide {
let plainAlpha = alpha(plainRep, x: x, y: y)
let markedAlpha = alpha(markedRep, x: x, y: y)
if plainAlpha > 0.5, markedAlpha < 0.05 {
cutoutPixels += 1
}
if plainAlpha < 0.05, markedAlpha > 0.5 {
glyphPixels += 1
}
}
}

#expect(cutoutPixels >= 8, "Expected halo cutout pixels for \(indicator)")
#expect(glyphPixels >= 4, "Expected visible glyph pixels for \(indicator)")
}
}

@Test
func `icon renderer codex eyes punch through when unknown`() {
// Regression: when remaining is nil, CoreGraphics inherits the previous fill alpha which caused
Expand Down
52 changes: 52 additions & 0 deletions Tests/CodexBarTests/StatusItemAnimationSignatureTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,58 @@ struct StatusItemAnimationSignatureTests {
#expect(controller.lastAppliedMergedIconRenderSignature?.contains("provider=synthetic") == true)
}

@Test
func `merged icon status indicator follows rendered provider`() throws {
let suite = "StatusItemAnimationSignatureTests-merged-status-provider-scope"
let settings = testSettingsStore(suiteName: suite)
settings.statusChecksEnabled = true
settings.refreshFrequency = .manual
settings.mergeIcons = true
settings.selectedMenuProvider = .codex
settings.menuBarShowsBrandIconWithPercent = false

let registry = ProviderRegistry.shared
let codexMeta = try #require(registry.metadata[.codex])
let claudeMeta = try #require(registry.metadata[.claude])
settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true)
settings.setProviderEnabled(provider: .claude, metadata: claudeMeta, enabled: true)

let fetcher = UsageFetcher()
let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings)
let controller = StatusItemController(
store: store,
settings: settings,
account: fetcher.loadAccountInfo(),
updater: DisabledUpdaterController(),
preferencesSelection: PreferencesSelection(),
statusBar: testStatusBar())
defer { controller.releaseStatusItemsForTesting() }

let snapshot = UsageSnapshot(
primary: RateWindow(usedPercent: 50, windowMinutes: nil, resetsAt: nil, resetDescription: nil),
secondary: nil,
updatedAt: Date())
store._setSnapshotForTesting(snapshot, provider: .codex)
store._setSnapshotForTesting(snapshot, provider: .claude)
store.statuses[.claude] = ProviderStatus(
indicator: .major,
description: "Claude status issue",
updatedAt: Date(timeIntervalSince1970: 20))

controller.applyIcon(phase: nil)

#expect(controller.primaryProviderForUnifiedIcon() == .codex)
#expect(controller.lastAppliedMergedIconRenderSignature?.contains("provider=codex") == true)
#expect(controller.lastAppliedMergedIconRenderSignature?.contains("status=none") == true)

settings.selectedMenuProvider = .claude
controller.applyIcon(phase: nil)

#expect(controller.primaryProviderForUnifiedIcon() == .claude)
#expect(controller.lastAppliedMergedIconRenderSignature?.contains("provider=claude") == true)
#expect(controller.lastAppliedMergedIconRenderSignature?.contains("status=major") == true)
}

@Test
func `merged icon follows overview provider order when first overview provider is loading`() {
let suite = "StatusItemAnimationSignatureTests-merged-overview-provider-order"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ struct StatusItemIconObservationSignatureTests {
}

@Test
func `merged store icon observation signature changes when non primary status changes`() throws {
func `merged store icon observation signature ignores non primary status changes`() throws {
let (settings, store, controller) = self.makeController(
suiteName: "StatusItemIconObservationSignatureTests-merged-secondary-status")
defer { controller.releaseStatusItemsForTesting() }
Expand All @@ -202,7 +202,7 @@ struct StatusItemIconObservationSignatureTests {
description: "Claude status issue",
updatedAt: Date(timeIntervalSince1970: 20))

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

@Test
Expand Down