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 @@ -6,6 +6,7 @@
- Agent Sessions: list and focus live local or SSH-discovered Codex and Claude Code sessions from the menu and CLI.

### Fixed
- Gemini: prefer Google's paid-tier plan label over generic Free, Workspace, or Paid fallbacks while preserving acronym casing in the CLI. Thanks @Yuxin-Qiao!
- Codex: avoid false session-reset celebrations from transient zero-usage samples until the reset boundary advances. Thanks @kiranmagic7!
- Settings: keep visual-only preference changes on cached UI paths instead of refreshing provider quotas, while preserving refreshes for data-affecting settings. Thanks @Zihao-Qi!
- Ollama: recognize current WorkOS AuthKit sessions during browser-cookie discovery and manual cookie validation. Thanks @joeVenner!
Expand Down
31 changes: 24 additions & 7 deletions Sources/CodexBarCLI/CLIRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ enum CLIRenderer {
if provider == .codex {
return CodexPlanFormatting.displayName(plan) ?? plan
}
return plan.capitalized
return self.nonCodexPlanDisplay(provider: provider, plan: plan)
}

static func colorizeAccentBold(_ text: String) -> String {
Expand Down Expand Up @@ -891,7 +891,7 @@ enum CLIRenderer {
{
plan
} else {
plan.capitalized
self.nonCodexPlanDisplay(provider: provider, plan: plan)
}
lines.append(self.labelValueLine("Plan", value: displayPlan, useColor: context.useColor))
}
Expand All @@ -903,6 +903,13 @@ enum CLIRenderer {
}
}

private static func nonCodexPlanDisplay(provider: UsageProvider, plan: String) -> String {
if provider == .gemini {
return UsageFormatter.cleanPlanName(plan)
}
return plan.capitalized
}

// swiftlint:disable:next function_parameter_count
private static func appendRateWindowLines(
provider: UsageProvider,
Expand Down Expand Up @@ -1078,8 +1085,12 @@ enum CLIRenderer {
{
guard kind.supports(provider: provider) else { return nil }
// Only pace a real session window here; Claude w/o 5-hour data falls a 7-day window into primary.
if case .session = kind, let minutes = window.windowMinutes, minutes > 300 { return nil }
if provider == .ollama, window.windowMinutes == nil { return nil }
if case .session = kind, let minutes = window.windowMinutes, minutes > 300 {
return nil
}
if provider == .ollama, window.windowMinutes == nil {
return nil
}
guard window.remainingPercent > 0 else { return nil }
// workDays applies only to the weekly (10 080-min) window; UsagePace.weekly ignores it for other durations.
let workDays = kind == .weekly ? weeklyWorkDays : nil
Expand Down Expand Up @@ -1179,7 +1190,9 @@ enum CLIRenderer {
kind: PaceKind,
now: Date) -> String?
{
if pace.willLastToReset { return self.combinedLastsLabel(for: pace, provider: provider) }
if pace.willLastToReset {
return self.combinedLastsLabel(for: pace, provider: provider)
}
guard let etaSeconds = pace.etaSeconds else { return nil }
let etaText = Self.paceDurationText(seconds: etaSeconds, now: now)
switch kind {
Expand Down Expand Up @@ -1209,8 +1222,12 @@ enum CLIRenderer {
private static func paceDurationText(seconds: TimeInterval, now: Date) -> String {
let date = now.addingTimeInterval(seconds)
let countdown = UsageFormatter.resetCountdownDescription(from: date, now: now)
if countdown == "now" { return "now" }
if countdown.hasPrefix("in ") { return String(countdown.dropFirst(3)) }
if countdown == "now" {
return "now"
}
if countdown.hasPrefix("in ") {
return String(countdown.dropFirst(3))
}
return countdown
}

Expand Down
91 changes: 67 additions & 24 deletions Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe.swift
Original file line number Diff line number Diff line change
Expand Up @@ -337,25 +337,10 @@ public struct GeminiStatusProbe: Sendable {

let snapshot = try Self.parseAPIResponse(data, email: claims.email)

// Plan display strings with tier mapping:
// - standard-tier: Paid subscription (AI Pro, AI Ultra, Code Assist
// Standard/Enterprise, Developer Program Premium)
// - free-tier + hd claim: Workspace account (Gemini included free since Jan 2025)
// - free-tier: Personal free account (1000 req/day limit)
// - legacy-tier: Unknown legacy/grandfathered tier
// - nil (API failed): Leave blank (no display)
let plan: String? = switch (caStatus.tier, claims.hostedDomain) {
case (.standard, _):
"Paid"
case let (.free, .some(domain)):
{ Self.log.info("Workspace account detected", metadata: ["domain": domain]); return "Workspace" }()
case (.free, .none):
{ Self.log.info("Personal free account"); return "Free" }()
case (.legacy, _):
"Legacy"
case (.none, _):
{ Self.log.info("Tier detection failed, leaving plan blank"); return nil }()
}
let plan = Self.resolveAccountPlan(
tier: caStatus.tier,
hostedDomain: claims.hostedDomain,
paidTierName: caStatus.paidTierName)

return GeminiStatusSnapshot(
modelQuotas: snapshot.modelQuotas,
Expand Down Expand Up @@ -411,8 +396,9 @@ public struct GeminiStatusProbe: Sendable {
private struct CodeAssistStatus {
let tier: GeminiUserTierId?
let projectId: String?
let paidTierName: String?

static let empty = CodeAssistStatus(tier: nil, projectId: nil)
static let empty = CodeAssistStatus(tier: nil, projectId: nil, paidTierName: nil)
}

private static func loadCodeAssistStatus(
Expand Down Expand Up @@ -484,20 +470,26 @@ public struct GeminiStatusProbe: Sendable {
}

let tierId = (json["currentTier"] as? [String: Any])?["id"] as? String
let paidTierName = Self.parsePaidTierName(from: json)

guard let tierId else {
Self.log.warning("loadCodeAssist: no currentTier.id in response", metadata: [
"json": "\(json)",
])
return CodeAssistStatus(tier: nil, projectId: projectId)
return CodeAssistStatus(tier: nil, projectId: projectId, paidTierName: paidTierName)
}

guard let tier = GeminiUserTierId(rawValue: tierId) else {
Self.log.warning("loadCodeAssist: unknown tier ID", metadata: ["tierId": tierId])
return CodeAssistStatus(tier: nil, projectId: projectId)
return CodeAssistStatus(tier: nil, projectId: projectId, paidTierName: paidTierName)
}

Self.log.info("loadCodeAssist: success", metadata: ["tier": tierId, "projectId": projectId ?? "nil"])
return CodeAssistStatus(tier: tier, projectId: projectId)
Self.log.info("loadCodeAssist: success", metadata: [
"tier": tierId,
"projectId": projectId ?? "nil",
"paidTierName": paidTierName ?? "nil",
])
return CodeAssistStatus(tier: tier, projectId: projectId, paidTierName: paidTierName)
}

private struct OAuthCredentials {
Expand Down Expand Up @@ -1142,6 +1134,57 @@ public struct GeminiStatusProbe: Sendable {
}
}

extension GeminiStatusProbe {
/// Plan display strings with tier mapping:
/// - paidTier.name: Most-specific paid subscription label from Google, regardless of currentTier
/// - standard-tier: Paid subscription fallback (Code Assist Standard/Enterprise, Developer Program Premium)
/// - free-tier + hd claim: Workspace account (Gemini included free since Jan 2025)
/// - free-tier: Personal free account
/// - legacy-tier: Unknown legacy/grandfathered tier
/// - nil (API failed): Leave blank (no display)
fileprivate static func resolveAccountPlan(
tier: GeminiUserTierId?,
hostedDomain: String?,
paidTierName: String?) -> String?
{
// Match Gemini CLI's contract: a named paid tier is the most specific plan signal,
// even when currentTier is missing, unknown, or still reports free-tier.
if let paidTierName {
self.log.info("Paid tier detected", metadata: [
"tier": tier?.rawValue ?? "unknown",
"plan": paidTierName,
])
return paidTierName
}

switch (tier, hostedDomain) {
case (.standard, _):
return "Paid"
Comment thread
steipete marked this conversation as resolved.
case let (.free, .some(domain)):
Self.log.info("Workspace account detected", metadata: ["domain": domain])
return "Workspace"
case (.free, .none):
Self.log.info("Personal free account")
return "Free"
case (.legacy, _):
return "Legacy"
case (.none, _):
self.log.info("Tier detection failed, leaving plan blank")
return nil
}
}

private static func parsePaidTierName(from json: [String: Any]) -> String? {
guard let paidTier = json["paidTier"] as? [String: Any],
let rawName = paidTier["name"] as? String
Comment on lines +1178 to +1179

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve paid-tier signals when names are omitted

When loadCodeAssist returns a paidTier object that has an id but no name, this parser returns nil, so resolveAccountPlan falls through to currentTier and can still display a paid consumer account as Free when currentTier.id is free-tier. Preserve paidTier.id or at least the presence of paidTier as a paid fallback when the specific display name is absent.

Useful? React with 👍 / 👎.

else {
return nil
}
let trimmed = rawName.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
}

extension GeminiStatusProbe {
private static let processTimeoutQueue = DispatchQueue(
label: "com.steipete.codexbar.gemini-process-timeout",
Expand Down
30 changes: 30 additions & 0 deletions Tests/CodexBarTests/CLISnapshotTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,36 @@ import Testing

// swiftlint:disable:next type_body_length
struct CLISnapshotTests {
@Test
func `renders Gemini paid plan without changing acronym casing`() {
let identity = ProviderIdentitySnapshot(
providerID: .gemini,
accountEmail: nil,
accountOrganization: nil,
loginMethod: "Gemini Code Assist in Google One AI Pro")
let snapshot = UsageSnapshot(
primary: nil,
secondary: nil,
tertiary: nil,
updatedAt: Date(timeIntervalSince1970: 0),
identity: identity)

let output = CLIRenderer.renderText(
provider: .gemini,
snapshot: snapshot,
credits: nil,
context: RenderContext(
header: "Gemini",
status: nil,
useColor: false,
resetStyle: .absolute))

#expect(CLIRenderer.planBadgeText(provider: .gemini, snapshot: snapshot) ==
"Gemini Code Assist in Google One AI Pro")
#expect(output.contains("Plan: Gemini Code Assist in Google One AI Pro"))
#expect(!output.contains("Google One Ai Pro"))
}

@Test
func `renders Factory token rate billing with time window labels`() {
let snap = UsageSnapshot(
Expand Down
34 changes: 29 additions & 5 deletions Tests/CodexBarTests/GeminiAPITestHelpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -76,19 +76,36 @@ enum GeminiAPITestHelpers {
return "header.\(encoded).sig"
}

static func loadCodeAssistResponse(tierId: String, projectId: String? = nil) -> Data {
var payload: [String: Any] = [
"currentTier": [
static func loadCodeAssistResponse(
tierId: String?,
projectId: String? = nil,
paidTierName: String? = nil) -> Data
{
var payload: [String: Any] = [:]
if let tierId {
payload["currentTier"] = [
"id": tierId,
"name": tierId.replacingOccurrences(of: "-tier", with: ""),
],
]
]
}
if let projectId {
payload["cloudaicompanionProject"] = projectId
}
if let paidTierName {
payload["paidTier"] = [
"name": paidTierName,
]
}
return self.jsonData(payload)
}

static func loadCodeAssistConsumerPlusResponse(projectId: String? = "cloudaicompanion-123") -> Data {
self.loadCodeAssistResponse(
tierId: "free-tier",
projectId: projectId,
paidTierName: "Plus")
}

static func loadCodeAssistFreeTierResponse() -> Data {
self.loadCodeAssistResponse(tierId: "free-tier")
}
Expand All @@ -97,6 +114,13 @@ enum GeminiAPITestHelpers {
self.loadCodeAssistResponse(tierId: "standard-tier")
}

static func loadCodeAssistGoogleOneProResponse(projectId: String? = "cloudaicompanion-123") -> Data {
self.loadCodeAssistResponse(
tierId: "standard-tier",
projectId: projectId,
paidTierName: "Gemini Code Assist in Google One AI Pro")
}

static func loadCodeAssistLegacyTierResponse() -> Data {
self.loadCodeAssistResponse(tierId: "legacy-tier")
}
Expand Down
38 changes: 38 additions & 0 deletions Tests/CodexBarTests/GeminiMenuCardTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,44 @@ import Testing
@testable import CodexBar

struct GeminiMenuCardTests {
@Test
func `gemini plan preserves upstream acronym casing`() throws {
let identity = ProviderIdentitySnapshot(
providerID: .gemini,
accountEmail: nil,
accountOrganization: nil,
loginMethod: "Gemini Code Assist in Google One AI Pro")
let snapshot = UsageSnapshot(
primary: nil,
secondary: nil,
tertiary: nil,
updatedAt: Date(timeIntervalSince1970: 0),
identity: identity)
let metadata = try #require(ProviderDefaults.metadata[.gemini])

let model = UsageMenuCardView.Model.make(.init(
provider: .gemini,
metadata: metadata,
snapshot: snapshot,
credits: nil,
creditsError: nil,
dashboard: nil,
dashboardError: nil,
tokenSnapshot: nil,
tokenError: nil,
account: AccountInfo(email: nil, plan: nil),
isRefreshing: false,
lastError: nil,
usageBarsShowUsed: false,
resetTimeDisplayStyle: .countdown,
tokenCostUsageEnabled: false,
showOptionalCreditsAndExtraUsage: true,
hidePersonalInfo: false,
now: Date(timeIntervalSince1970: 0)))

#expect(model.planText == "Gemini Code Assist in Google One AI Pro")
}

@Test
func `gemini model uses flash lite title for tertiary metric`() throws {
let now = Date()
Expand Down
Loading