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
163 changes: 109 additions & 54 deletions Sources/CodexBar/MenuBarMetricWindowResolver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@ import CodexBarCore
import Foundation

enum MenuBarMetricWindowResolver {
private enum Lane {
case primary
case secondary
case tertiary
}

static func rateWindow(
preference: MenuBarMetricPreference,
provider: UsageProvider,
Expand All @@ -12,68 +18,117 @@ enum MenuBarMetricWindowResolver {
guard let snapshot else { return nil }
switch preference {
case .tertiary:
if provider == .perplexity {
return snapshot.tertiary ?? snapshot.secondary ?? snapshot.primary
}
guard provider == .cursor else {
if provider == .antigravity {
return snapshot.tertiary ?? snapshot.secondary ?? snapshot.primary
}
return snapshot.primary ?? snapshot.secondary
}
return snapshot.tertiary ?? snapshot.secondary ?? snapshot.primary
return Self.window(in: snapshot, following: Self.tertiaryOrder(for: provider))
case .primary:
if provider == .perplexity {
return snapshot.primary ?? snapshot.secondary ?? snapshot.tertiary
}
if provider == .antigravity {
return snapshot.primary ?? snapshot.secondary ?? snapshot.tertiary
}
return snapshot.primary ?? snapshot.secondary
return Self.window(in: snapshot, following: Self.primaryOrder(for: provider))
case .secondary:
if provider == .perplexity {
return snapshot.secondary ?? snapshot.tertiary ?? snapshot.primary
}
if provider == .antigravity {
return snapshot.secondary ?? snapshot.primary ?? snapshot.tertiary
}
return snapshot.secondary ?? snapshot.primary
return Self.window(in: snapshot, following: Self.secondaryOrder(for: provider))
case .average:
guard supportsAverage,
let primary = snapshot.primary,
let secondary = snapshot.secondary
else {
if provider == .antigravity {
return snapshot.primary ?? snapshot.secondary ?? snapshot.tertiary
}
return snapshot.primary ?? snapshot.secondary
}
let usedPercent = (primary.usedPercent + secondary.usedPercent) / 2
return RateWindow(usedPercent: usedPercent, windowMinutes: nil, resetsAt: nil, resetDescription: nil)
return Self.averageWindow(provider: provider, snapshot: snapshot, supportsAverage: supportsAverage)
case .automatic:
return Self.automaticWindow(provider: provider, snapshot: snapshot)
}
}

private static func tertiaryOrder(for provider: UsageProvider) -> [Lane] {
if provider == .zai {
return [.tertiary, .primary, .secondary]
}
if provider == .perplexity || provider == .cursor || provider == .antigravity {
return [.tertiary, .secondary, .primary]
}
return [.primary, .secondary]
}

private static func primaryOrder(for provider: UsageProvider) -> [Lane] {
if provider == .zai {
return [.primary, .tertiary, .secondary]
}
if provider == .perplexity || provider == .antigravity {
return [.primary, .secondary, .tertiary]
}
return [.primary, .secondary]
}

private static func secondaryOrder(for provider: UsageProvider) -> [Lane] {
if provider == .zai || provider == .antigravity {
return [.secondary, .primary, .tertiary]
}
if provider == .perplexity {
return [.secondary, .tertiary, .primary]
}
return [.secondary, .primary]
}

private static func averageWindow(
provider: UsageProvider,
snapshot: UsageSnapshot,
supportsAverage: Bool)
-> RateWindow?
{
guard supportsAverage,
let primary = snapshot.primary,
let secondary = snapshot.secondary
else {
if provider == .antigravity {
return snapshot.primary ?? snapshot.secondary ?? snapshot.tertiary
}
if provider == .perplexity {
return snapshot.automaticPerplexityWindow()
}
if provider == .factory || provider == .kimi {
return snapshot.secondary ?? snapshot.primary
}
if provider == .copilot,
let primary = snapshot.primary,
let secondary = snapshot.secondary
{
return primary.usedPercent >= secondary.usedPercent ? primary : secondary
}
if provider == .cursor {
return Self.mostConstrainedWindow(
primary: snapshot.primary,
secondary: snapshot.secondary,
tertiary: snapshot.tertiary)
return self.window(in: snapshot, following: [.primary, .secondary, .tertiary])
}
return snapshot.primary ?? snapshot.secondary
}

let usedPercent = (primary.usedPercent + secondary.usedPercent) / 2
return RateWindow(usedPercent: usedPercent, windowMinutes: nil, resetsAt: nil, resetDescription: nil)
}

private static func automaticWindow(provider: UsageProvider, snapshot: UsageSnapshot) -> RateWindow? {
if provider == .antigravity {
return self.window(in: snapshot, following: [.primary, .secondary, .tertiary])
}
if provider == .perplexity {
return snapshot.automaticPerplexityWindow()
}
if provider == .zai {
return self.mostConstrainedWindow(
primary: snapshot.primary,
secondary: snapshot.tertiary,
tertiary: nil) ?? snapshot.secondary
}
if provider == .factory || provider == .kimi {
return snapshot.secondary ?? snapshot.primary
}
if provider == .copilot,
let primary = snapshot.primary,
let secondary = snapshot.secondary
{
return primary.usedPercent >= secondary.usedPercent ? primary : secondary
}
if provider == .cursor {
return Self.mostConstrainedWindow(
primary: snapshot.primary,
secondary: snapshot.secondary,
tertiary: snapshot.tertiary)
}
return snapshot.primary ?? snapshot.secondary
}

private static func window(in snapshot: UsageSnapshot, following lanes: [Lane]) -> RateWindow? {
for lane in lanes {
if let window = self.window(in: snapshot, lane: lane) {
return window
}
}
return nil
}

private static func window(in snapshot: UsageSnapshot, lane: Lane) -> RateWindow? {
switch lane {
case .primary:
snapshot.primary
case .secondary:
snapshot.secondary
case .tertiary:
snapshot.tertiary
}
}

private static func mostConstrainedWindow(
Expand Down
4 changes: 4 additions & 0 deletions Sources/CodexBar/MenuCardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -943,6 +943,7 @@ extension UsageMenuCardView.Model {
let zaiUsage = input.provider == .zai ? snapshot.zaiUsage : nil
let zaiTokenDetail = Self.zaiLimitDetailText(limit: zaiUsage?.tokenLimit)
let zaiTimeDetail = Self.zaiLimitDetailText(limit: zaiUsage?.timeLimit)
let zaiSessionDetail = Self.zaiLimitDetailText(limit: zaiUsage?.sessionTokenLimit)
let openRouterQuotaDetail = Self.openRouterQuotaDetail(provider: input.provider, snapshot: snapshot)
if input.provider == .codex, let codexProjection = input.codexProjection {
metrics.append(contentsOf: Self.codexRateMetrics(
Expand Down Expand Up @@ -984,6 +985,9 @@ extension UsageMenuCardView.Model {
{
tertiaryDetailText = detail
}
if input.provider == .zai, let detail = zaiSessionDetail {
tertiaryDetailText = detail
}
// Perplexity purchased credits don't reset; show balance without "Resets" prefix.
let opusResetText: String? = input.provider == .perplexity
? opus.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines)
Expand Down
1 change: 0 additions & 1 deletion Sources/CodexBar/PreferencesProvidersPane.swift
Original file line number Diff line number Diff line change
Expand Up @@ -440,7 +440,6 @@ struct ProvidersPane: View {
}

func menuBarMetricPicker(for provider: UsageProvider) -> ProviderSettingsPickerDescriptor? {
if provider == .zai { return nil }
let options: [ProviderSettingsPickerOption]
if provider == .openrouter {
options = [
Expand Down
9 changes: 2 additions & 7 deletions Sources/CodexBar/SettingsStore+MenuPreferences.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import Foundation

extension SettingsStore {
func menuBarMetricPreference(for provider: UsageProvider) -> MenuBarMetricPreference {
if provider == .zai { return .primary }
if provider == .openrouter {
let raw = self.menuBarMetricPreferencesRaw[provider.rawValue] ?? ""
let preference = MenuBarMetricPreference(rawValue: raw) ?? .automatic
Expand All @@ -26,10 +25,6 @@ extension SettingsStore {
}

func setMenuBarMetricPreference(_ preference: MenuBarMetricPreference, for provider: UsageProvider) {
if provider == .zai {
self.menuBarMetricPreferencesRaw[provider.rawValue] = MenuBarMetricPreference.primary.rawValue
return
}
if provider == .openrouter {
switch preference {
case .automatic, .primary:
Expand All @@ -51,11 +46,11 @@ extension SettingsStore {
}

func menuBarMetricSupportsTertiary(for provider: UsageProvider) -> Bool {
provider == .cursor || provider == .perplexity
provider == .cursor || provider == .perplexity || provider == .zai
}

func menuBarMetricSupportsTertiary(for provider: UsageProvider, snapshot: UsageSnapshot?) -> Bool {
if provider == .cursor {
if provider == .cursor || provider == .zai {
return snapshot?.tertiary != nil
}
return self.menuBarMetricSupportsTertiary(for: provider)
Expand Down
17 changes: 17 additions & 0 deletions Sources/CodexBarCore/Host/PTY/TTYCommandRunner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -487,6 +487,16 @@ public struct TTYCommandRunner {
return appended
}

func drainRemainingOutput(until drainDeadline: Date) {
while Date() < drainDeadline {
let newData = readChunk()
if newData.isEmpty {
usleep(20000)
continue
}
}
}

func firstLink(in data: Data) -> String? {
guard let s = String(data: data, encoding: .utf8) else { return nil }
let pattern = #"https?://[A-Za-z0-9._~:/?#\[\]@!$&'()*+,;=%-]+"#
Expand Down Expand Up @@ -621,6 +631,13 @@ public struct TTYCommandRunner {
usleep(50000)
}
}
} else if !proc.isRunning {
// PTY-backed scripts can exit before their final echo becomes readable on the parent side.
// Give the kernel a brief non-blocking drain window so we don't lose the last line of output.
let drainFor = max(0, min(0.2, deadline.timeIntervalSinceNow))
if drainFor > 0 {
drainRemainingOutput(until: Date().addingTimeInterval(drainFor))
}
}

let text = String(data: buffer, encoding: .utf8) ?? ""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ public enum ZaiProviderDescriptor {
displayName: "z.ai",
sessionLabel: "Tokens",
weeklyLabel: "MCP",
opusLabel: nil,
supportsOpus: false,
opusLabel: "5-hour",
supportsOpus: true,
supportsCredits: false,
creditsHint: "",
toggleTitle: "Show z.ai usage",
Expand Down
39 changes: 34 additions & 5 deletions Sources/CodexBarCore/Providers/Zai/ZaiUsageStats.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ public enum ZaiLimitUnit: Int, Sendable {
case days = 1
case hours = 3
case minutes = 5
case weeks = 6
}

/// A single limit entry from the z.ai API
Expand Down Expand Up @@ -69,6 +70,8 @@ extension ZaiLimitEntry {
return self.number * 60
case .days:
return self.number * 24 * 60
case .weeks:
return self.number * 7 * 24 * 60
case .unknown:
return nil
}
Expand All @@ -80,6 +83,7 @@ extension ZaiLimitEntry {
case .minutes: "minute"
case .hours: "hour"
case .days: "day"
case .weeks: "week"
case .unknown: nil
}
guard let unitLabel else { return nil }
Expand Down Expand Up @@ -129,12 +133,21 @@ public struct ZaiUsageDetail: Sendable, Codable {
/// Complete z.ai usage response
public struct ZaiUsageSnapshot: Sendable {
public let tokenLimit: ZaiLimitEntry?
/// Shorter-window TOKENS_LIMIT (e.g. 5-hour), present only when the API returns two TOKENS_LIMIT entries.
public let sessionTokenLimit: ZaiLimitEntry?
public let timeLimit: ZaiLimitEntry?
public let planName: String?
public let updatedAt: Date

public init(tokenLimit: ZaiLimitEntry?, timeLimit: ZaiLimitEntry?, planName: String?, updatedAt: Date) {
public init(
tokenLimit: ZaiLimitEntry?,
sessionTokenLimit: ZaiLimitEntry? = nil,
timeLimit: ZaiLimitEntry?,
planName: String?,
updatedAt: Date)
{
self.tokenLimit = tokenLimit
self.sessionTokenLimit = sessionTokenLimit
self.timeLimit = timeLimit
self.planName = planName
self.updatedAt = updatedAt
Expand All @@ -150,13 +163,13 @@ extension ZaiUsageSnapshot {
public func toUsageSnapshot() -> UsageSnapshot {
let primaryLimit = self.tokenLimit ?? self.timeLimit
let secondaryLimit = (self.tokenLimit != nil && self.timeLimit != nil) ? self.timeLimit : nil

let primary = primaryLimit.map { Self.rateWindow(for: $0) } ?? RateWindow(
usedPercent: 0,
windowMinutes: nil,
resetsAt: nil,
resetDescription: nil)
let secondary = secondaryLimit.map { Self.rateWindow(for: $0) }
let tertiary = self.sessionTokenLimit.map { Self.rateWindow(for: $0) }

let planName = self.planName?.trimmingCharacters(in: .whitespacesAndNewlines)
let loginMethod = (planName?.isEmpty ?? true) ? nil : planName
Expand All @@ -168,7 +181,7 @@ extension ZaiUsageSnapshot {
return UsageSnapshot(
primary: primary,
secondary: secondary,
tertiary: nil,
tertiary: tertiary,
providerCost: nil,
zaiUsage: self,
updatedAt: self.updatedAt,
Expand Down Expand Up @@ -364,22 +377,38 @@ public struct ZaiUsageFetcher: Sendable {
throw ZaiUsageError.parseFailed("Missing data")
}

var tokenLimit: ZaiLimitEntry?
var tokenLimits: [ZaiLimitEntry] = []
var timeLimit: ZaiLimitEntry?

for limit in responseData.limits {
if let entry = limit.toLimitEntry() {
switch entry.type {
case .tokensLimit:
tokenLimit = entry
tokenLimits.append(entry)
case .timeLimit:
timeLimit = entry
}
}
}

// Multiple TOKENS_LIMIT entries: shortest window → sessionTokenLimit (tertiary),
// longest → tokenLimit (primary).
let tokenLimit: ZaiLimitEntry?
let sessionTokenLimit: ZaiLimitEntry?
if tokenLimits.count >= 2 {
let sorted = tokenLimits.sorted {
($0.windowMinutes ?? Int.max) < ($1.windowMinutes ?? Int.max)
}
sessionTokenLimit = sorted.first
tokenLimit = sorted.last
} else {
tokenLimit = tokenLimits.first
sessionTokenLimit = nil
}

return ZaiUsageSnapshot(
tokenLimit: tokenLimit,
sessionTokenLimit: sessionTokenLimit,
timeLimit: timeLimit,
planName: responseData.planName,
updatedAt: Date())
Expand Down
Loading