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
25 changes: 12 additions & 13 deletions Sources/CodexBar/Menu/MenuBarLayoutRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -466,6 +466,9 @@ final class MenuBarLayoutRenderer {
let providerTintColor = Self.effectiveProviderTintColor(for: data.provider.firstPartyProvider, options: options)
let foregroundColor = providerTintColor ?? Self.neutralForegroundColor(options)
let isTintActive = providerTintColor != nil
let renderedIcon = icon.map { image in
providerTintColor.map { Self.attachmentImage(image, tint: $0, isMonochrome: false) } ?? image
}
let paragraphStyle = NSMutableParagraphStyle()
if isStacked {
let lineHeight: CGFloat = options.size == .large ? 11 : 9.5
Expand Down Expand Up @@ -493,12 +496,14 @@ final class MenuBarLayoutRenderer {
let spanningIcon = options.highContrast && canSurfaceStackedIcon && renderedLines.first?.first == .icon
? icon : nil
// High-contrast layouts keep icon + text together for consistent rendering.
// Provider-colored layouts keep the icon in attributed title to preserve the custom tint.
// Colored spanning icons carry their tint in the image; inline icons reuse it.
// With a missing icon the token still renders its placeholder inside the title.
let leadingIcon: NSImage? = if options.highContrast || (isStacked && !canSurfaceStackedIcon) || isTintActive {
let leadingIcon: NSImage? = if options.highContrast || (isStacked && !canSurfaceStackedIcon)
|| (isTintActive && !canSurfaceStackedIcon)
{
nil
} else if renderedLines.first?.first == .icon, icon != nil {
icon.map { Self.offsetLeadingIcon($0, adjustment: options.verticalAdjustment) }
renderedIcon.map { Self.offsetLeadingIcon($0, adjustment: options.verticalAdjustment) }
} else {
nil
}
Expand Down Expand Up @@ -531,14 +536,13 @@ final class MenuBarLayoutRenderer {
var renderedItem = Self.renderItem(
token,
data: data,
icon: icon,
icon: renderedIcon,
style: TokenStyle(
font: font,
foregroundColor: foregroundColor,
iconHeight: Self.iconHeight(size: options.size, isStacked: isStacked),
attributes: attributes),
options: options,
providerTintColor: providerTintColor)
options: options)
switch token {
case .percent(window: .automatic), .pace(window: .automatic), .resetCountdown, .resetAbsolute,
.windowResetCountdown(window: .automatic), .windowResetAbsolute(window: .automatic):
Expand Down Expand Up @@ -760,8 +764,7 @@ final class MenuBarLayoutRenderer {
data: MenuBarLayoutRenderData,
icon: NSImage?,
style: TokenStyle,
options: MenuBarLayoutRenderOptions,
providerTintColor: NSColor? = nil)
options: MenuBarLayoutRenderOptions)
-> (value: NSAttributedString, accessibilityText: String?)
{
if let rendered = self.renderProviderTextItem(
Expand Down Expand Up @@ -789,12 +792,8 @@ final class MenuBarLayoutRenderer {
accessibilityText: L("Icon unavailable"),
attributes: style.attributes)
}
let tintColor = providerTintColor ?? style.foregroundColor
let attachment = NSTextAttachment()
attachment.image = Self.attachmentImage(
icon,
tint: tintColor,
isMonochrome: providerTintColor == nil)
attachment.image = Self.attachmentImage(icon, tint: style.foregroundColor)
let height = style.iconHeight
let width = icon.size.height > 0 ? icon.size.width * height / icon.size.height : height
attachment.bounds = NSRect(
Expand Down
23 changes: 22 additions & 1 deletion Tests/CodexBarTests/ClaudeSubscriptionCLITests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,15 @@ struct ClaudeSubscriptionCLITests {

actor Server {
let scenario: Scenario
let challengesAccount: Bool
var billed = false
var requests: [String] = []
static let org = "11111111-1111-4111-8111-111111111111"
static let otherOrg = "22222222-2222-4222-8222-222222222222"
init(_ scenario: Scenario) { self.scenario = scenario }
init(_ scenario: Scenario, challengesAccount: Bool = false) {
self.scenario = scenario
self.challengesAccount = challengesAccount
}

func respond(_ request: URLRequest) async throws -> (Data, URLResponse) {
let url = try #require(request.url)
Expand All @@ -38,6 +42,13 @@ struct ClaudeSubscriptionCLITests {
body = "{}"
code = 404
case "/api/account":
if self.challengesAccount {
return try (Data("<html>Just a moment...</html>".utf8), #require(HTTPURLResponse(
url: url,
statusCode: 403,
httpVersion: nil,
headerFields: ["cf-mitigated": "challenge", "Content-Type": "text/html"])))
}
let email = self.billed && self.scenario == .changedAccount
? "other@example.com" : "fixture@example.com"
let account = self.billed && self.scenario == .changedAccount ? "other-account" : "fixture-account"
Expand Down Expand Up @@ -149,6 +160,16 @@ struct ClaudeSubscriptionCLITests {
#expect(await server.requests.isEmpty)
}

@Test func `challenged web account stops optional OAuth billing without losing quota`() async throws {
let server = Server(.renewal, challengesAccount: true)
let result = try await self.fetch(server, source: .oauth)
#expect(result.usage.primary?.usedPercent == 11)
#expect(result.usage.subscriptionRenewsAt == nil)
#expect(result.usage.subscriptionExpiresAt == nil)
#expect(await server.requests == ["/api/oauth/profile", "/api/account"])
#expect(await !server.billed)
}

@Test func `optional deadline cannot discard successful quota`() async throws {
let result = try await ClaudeSubscriptionMetadataFetcher.$timeoutForTesting.withValue(.zero) {
try await self.fetch(Server(.stalled))
Expand Down
45 changes: 45 additions & 0 deletions Tests/CodexBarTests/CodexMenuBarLayoutTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import CodexBarCore
import Foundation
import Testing
@testable import CodexBar

@MainActor
struct CodexMenuBarLayoutTests {
@Test
func `switching a custom Codex layout from automatic to session retains its own values`() throws {
let fixture = MenuBarLayoutRendererTests()
let settings = testSettingsStore(
suiteName: "CodexMenuBarLayoutTests", userDefaults: InMemoryUserDefaults())
let store = UsageStore(
fetcher: UsageFetcher(environment: [:]),
browserDetection: BrowserDetection(cacheTTL: 0),
settings: settings,
startupBehavior: .testing)
let snapshot = UsageSnapshot(
primary: RateWindow(usedPercent: 2, windowMinutes: 300, resetsAt: nil, resetDescription: nil),
secondary: RateWindow(usedPercent: 6, windowMinutes: 10080, resetsAt: nil, resetDescription: nil),
updatedAt: fixture.now)
store._setSnapshotForTesting(snapshot, provider: .codex)
let data = store.menuBarLayoutRenderData(
provider: .codex,
snapshot: snapshot,
iconKey: "fixture",
accountLabel: nil,
costs: .init(today: nil, last30Days: nil, todayUSD: nil, last30DaysUSD: nil),
now: fixture.now)
let layout = try JSONDecoder().decode(MenuBarLayout.self, from: Data(
#"{"lines":[[{"icon":{}},{"percent":{"window":"session"}},{"percent":{"window":"weekly"}}]]}"#.utf8))
let renderer = MenuBarLayoutRenderer()
let options = fixture.options(colorByProvider: true, showWindowLabels: false)
let automatic = renderer.render(
layout: MenuBarLayout(lines: [[.icon, .percent(window: .automatic), .percent(window: .weekly)]]),
data: data,
icon: nil,
options: options)
#expect(automatic.attributedTitle.string == "–\u{2009}6%\u{2009}6%")
let result = renderer.render(layout: layout, data: data, icon: nil, options: options)
#expect(data.session?.usedPercent == 2)
#expect(data.weekly?.usedPercent == 6)
#expect(result.attributedTitle.string == "–\u{2009}2%\u{2009}6%")
}
}
32 changes: 30 additions & 2 deletions Tests/CodexBarTests/MenuBarLayoutRendererTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -969,7 +969,36 @@ struct MenuBarLayoutRendererTests {
#expect(abs(centroids[0] - centroids[1] - 2) < 0.6, "Text and embedded meter share one vertical nudge")
}

@Test(arguments: ["highContrast", "forceStackedStyle", "colorByProvider", "missingIcon"])
@Test
func `provider coloring preserves the leading icon across both text rows`() throws {
let icon = NSImage(size: NSSize(width: 16, height: 16), flipped: false) { rect in
NSColor.black.setFill()
rect.fill()
return true
}
icon.isTemplate = true
let layout = MenuBarLayout(lines: [
[.icon, .progressBar(window: .session), .windowResetCountdown(window: .session)],
[.percent(window: .weekly), .windowResetCountdown(window: .weekly)],
])
let renderer = MenuBarLayoutRenderer()
let options = self.options(colorByProvider: true)
let colored = renderer.render(layout: layout, data: self.data(provider: .claude), icon: icon, options: options)
let neutral = renderer.render(
layout: layout, data: self.data(provider: .claude), icon: icon, options: self.options())
let leading = try #require(colored.leadingIcon)
#expect(leading.size == neutral.leadingIcon?.size)
#expect(!leading.isTemplate)
#expect(colored.attributedTitle.string == neutral.attributedTitle.string)
#expect(colored.accessibilityLabel == neutral.accessibilityLabel)
let pixel = try #require(Self.statusBitmap(image: leading, scale: 1).colorAt(x: 8, y: 8))
let tint = try #require(MenuBarLayoutRenderer.effectiveProviderTintColor(for: .claude, options: options))
#expect(abs(pixel.redComponent - tint.redComponent) < 0.01)
#expect(abs(pixel.greenComponent - tint.greenComponent) < 0.01)
#expect(abs(pixel.blueComponent - tint.blueComponent) < 0.01)
}

@Test(arguments: ["highContrast", "forceStackedStyle", "missingIcon"])
func `two row leading icons retain rendering overrides`(override: String) {
let icon = NSImage(size: NSSize(width: 16, height: 16))
icon.isTemplate = true
Expand All @@ -978,7 +1007,6 @@ struct MenuBarLayoutRendererTests {
data: self.data(),
icon: override == "missingIcon" ? nil : icon,
options: self.options(
colorByProvider: override == "colorByProvider",
highContrast: override == "highContrast",
forceStackedStyle: override == "forceStackedStyle"))

Expand Down
2 changes: 2 additions & 0 deletions docs/claude.md
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,8 @@ is stored on the token account in the config file and does not change quota fetc
including Firefox import, remains unavailable. This enables the same API request path as macOS; it does not
bypass Cloudflare challenges, refresh Claude Code OAuth credentials, or guarantee that a browser session will
work outside the browser. Use the OAuth source when the web request is challenged.
Successful OAuth quota and a valid manual cookie can still leave subscription dates unavailable:
a challenge on the optional web account check stops billing enrichment before subscription details are fetched.
- Linux CLI Auto mode: an existing valid manual cookie makes Web eligible ahead of CLI after upgrading.
Web success returns without launching Claude Code; authentication rejection or a Cloudflare challenge falls
back to an available CLI using the existing Auto policy. Cancellation stops without launching the CLI.
Expand Down
1 change: 1 addition & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,7 @@ The layout editor includes an optional **Tokens / min** value token (for example

Switching from **Icon & percent** back to **Meters** or **Critters** redraws the selected style immediately, even when usage has not changed.

Custom two-line layouts show a single leading provider icon at full size beside both text rows, including when colored by provider. Merged-provider rows, multiple icons, icon-only rows, and high-contrast mode retain inline icons.
An empty secondary meter keeps the same height and position as a populated meter. The compact lower lane is reserved for layouts showing a taller credits meter.

Custom two-line layouts show a single leading provider icon at full size beside both text rows, including in high-contrast mode. High contrast keeps the icon and text together in the existing contrast rendering path. Merged-provider rows, multiple icons, icon-only rows, and provider-colored layouts retain inline icons.
Expand Down
Loading