Skip to content
Open
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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
[![License: MIT](https://img.shields.io/badge/license-MIT-6e5aff?style=flat-square)](LICENSE)
[![Site](https://img.shields.io/badge/site-codexbar.app-16d3b4?style=flat-square)](https://codexbar.app)

<a href="https://codexbar.app"><img src="docs/social.png" alt="CodexBar — every AI coding limit in your menu bar. 63 providers." width="100%" /></a>
<a href="https://codexbar.app"><img src="docs/social.png" alt="CodexBar — every AI coding limit in your menu bar. 64 providers." width="100%" /></a>

Tiny macOS 14+ menu bar app that keeps **AI coding-provider limits visible** and shows when each window resets. Codex, OpenAI, Claude, Cursor, Gemini, Copilot, Grok, GroqCloud, ElevenLabs, Deepgram, z.ai, MiniMax, Kiro, Zed, Vertex AI, Augment, OpenRouter, LiteLLM, LLM Proxy, Codebuff, Command Code, ClinePass, AWS Bedrock, and many newer coding providers. One status item per provider, or Merge Icons mode with a provider switcher. No Dock icon, minimal UI, dynamic bar icons.

Expand Down Expand Up @@ -94,6 +94,7 @@ See [CLI configuration](docs/cli-configuration.md) for the full flow.
- [Manus](docs/manus.md) — Browser `session_id` auth for credit balance, monthly credits, and daily refresh tracking.
- [MiniMax](docs/minimax.md) — API token, cookie header, or browser cookies for coding-plan usage.
- [T3 Chat](docs/t3chat.md) — Browser cookies capture for Base and Overage usage buckets.
- [ZoomMate](docs/zoommate.md) — Chrome cookie auto-import or manual cURL capture for credits usage.
- [Kimi](docs/kimi.md) — Auth token (JWT from `kimi-auth` cookie) for weekly quota + 5‑hour rate limit.
- [Kilo](docs/kilo.md) — API token with CLI-auth fallback for Kilo Pass usage.
- [Kiro](docs/kiro.md) — CLI-based usage; monthly credits + bonus credits.
Expand Down
59 changes: 58 additions & 1 deletion Sources/CodexBar/InlineUsageDashboardContent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,12 @@ extension UsageMenuCardView.Model {
{
return Self.poeInlineDashboard(usage, now: input.now)
}
if input.provider == .zoommate,
let history = input.snapshot?.zoommateCreditsHistory,
!history.dailyBreakdown().isEmpty || history.pacingVerdict() != nil
{
return Self.zoommateInlineDashboard(history)
}
if [.codex, .claude, .vertexai, .bedrock, .cursor, .opencodego].contains(input.provider),
input.tokenCostInlineDashboardEnabled,
let tokenSnapshot = input.tokenSnapshot,
Expand All @@ -268,6 +274,57 @@ extension UsageMenuCardView.Model {
return nil
}

private static func zoommateInlineDashboard(
_ history: ZoomMateCreditsHistorySnapshot)
-> InlineUsageDashboardModel
{
let breakdown = history.dailyBreakdown()
let today = history.todayCreditsUsed()
let total = breakdown.reduce(0) { $0 + $1.totalCreditsUsed }
let points = breakdown.suffix(30).map {
InlineUsageDashboardModel.Point(
id: $0.day,
label: Self.shortDayLabel($0.day),
value: $0.totalCreditsUsed,
accessibilityValue: "\($0.day): \(Self.creditsSummary($0.totalCreditsUsed))")
}
var details: [String] = []
if let pace = history.pacingVerdict() {
details.append(Self.zoommatePaceLabel(for: pace))
}
var model = InlineUsageDashboardModel(
accessibilityLabel: L("ZoomMate 30 day credits usage trend"),
valueStyle: .tokens,
kpis: [
.init(title: L("Today"), value: Self.creditsSummary(today ?? 0), emphasis: true),
.init(title: L("30d credits"), value: Self.creditsSummary(total), emphasis: false),
],
points: points,
detailLines: details)
model.barColor = Self.inlineDashboardBarColor(for: .zoommate)
return model
}

private static func creditsSummary(_ value: Double) -> String {
value.formatted(.number.precision(.fractionLength(0...2)))
}

private static func zoommatePaceLabel(for pace: UsagePace) -> String {
let deltaValue = Int(abs(pace.deltaPercent).rounded())
switch pace.stage {
case .onTrack:
return L("Pace: on track")
case .slightlyAhead, .ahead, .farAhead:
return deltaValue == 0
? L("Pace: ahead of budget")
: L("Pace: %d%% ahead of budget", deltaValue)
case .slightlyBehind, .behind, .farBehind:
return deltaValue == 0
? L("Pace: behind budget")
: L("Pace: %d%% behind budget", deltaValue)
}
}

static func usesProviderCostHistoryAsPrimaryDashboard(_ provider: UsageProvider) -> Bool {
provider == .openai || provider == .mistral || provider == .groq
}
Expand Down Expand Up @@ -777,7 +834,7 @@ extension UsageMenuCardView.Model {
return .currency(symbol: symbol)
}

private static func shortDayLabel(_ day: String) -> String {
static func shortDayLabel(_ day: String) -> String {
let pieces = day.split(separator: "-")
guard pieces.count == 3, let rawDay = Int(pieces[2]) else { return day }
return "\(rawDay)"
Expand Down
10 changes: 10 additions & 0 deletions Sources/CodexBar/MenuCardView+ModelHelpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,16 @@ extension UsageMenuCardView.Model {
self.placeholder != nil
}

var creditsOnlyInlineUsageDashboard: Bool {
self.creditsText != nil &&
self.inlineUsageDashboard != nil &&
self.metrics.isEmpty &&
self.usageNotes.isEmpty &&
self.openAIAPIUsage == nil &&
self.codexResetCredits == nil &&
self.placeholder == nil
}

var usesStackedDetailLayout: Bool {
!self.metrics.isEmpty ||
self.creditsText != nil ||
Expand Down
7 changes: 5 additions & 2 deletions Sources/CodexBar/MenuCardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -192,10 +192,10 @@ struct UsageMenuCardView: View {
let hasCost = liveModel.tokenUsage != nil || hasProviderCost

VStack(alignment: .leading, spacing: 12) {
if hasUsage {
if hasUsage, !liveModel.creditsOnlyInlineUsageDashboard {
UsageMenuCardUsageContentView(model: liveModel, showBottomDivider: false)
}
if hasUsage, hasCredits || hasCost {
if hasUsage, !liveModel.creditsOnlyInlineUsageDashboard, hasCredits || hasCost {
Divider()
}
if let credits = liveModel.creditsText {
Expand All @@ -208,6 +208,9 @@ struct UsageMenuCardView: View {
hintCopyText: liveModel.creditsHintCopyText,
progressColor: liveModel.progressColor)
}
if liveModel.creditsOnlyInlineUsageDashboard, let dashboard = liveModel.inlineUsageDashboard {
InlineUsageDashboardContent(model: dashboard)
}
if hasCredits, hasCost {
Divider()
}
Expand Down
1 change: 1 addition & 0 deletions Sources/CodexBar/PreferencesDebugPane.swift
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,7 @@ struct DebugPane: View {
Text("Augment").tag(UsageProvider.augment)
Text("Amp").tag(UsageProvider.amp)
Text("T3 Chat").tag(UsageProvider.t3chat)
Text("ZoomMate").tag(UsageProvider.zoommate)
Text("Ollama").tag(UsageProvider.ollama)
}
.pickerStyle(.segmented)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ enum ProviderImplementationRegistry {
case .wayfinder: WayfinderProviderImplementation()
case .zenmux: ZenMuxProviderImplementation()
case .aiand: AiAndProviderImplementation()
case .zoommate: ZoomMateProviderImplementation()
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import AppKit
import CodexBarCore
import Foundation
import SwiftUI

struct ZoomMateProviderImplementation: ProviderImplementation {
let id: UsageProvider = .zoommate

/// ZoomMate is a web-cookie provider with no CLI/version detector, so the default detail line
/// ("zoommate not detected") would misleadingly read as "provider not found". Match the other
/// web-cookie providers (Cursor, Perplexity, Manus, …) and surface the source instead.
@MainActor
func presentation(context _: ProviderPresentationContext) -> ProviderPresentation {
ProviderPresentation { _ in "web" }
}

@MainActor
func observeSettings(_ settings: SettingsStore) {
_ = settings.zoomMateCookieSource
_ = settings.zoomMateCookieHeader
}

@MainActor
func settingsSnapshot(context: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution? {
.zoommate(context.settings.zoomMateSettingsSnapshot(tokenOverride: context.tokenOverride))
}

@MainActor
func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] {
let cookieBinding = Binding(
get: { context.settings.zoomMateCookieSource.rawValue },
set: { raw in
context.settings.zoomMateCookieSource = ProviderCookieSource(rawValue: raw) ?? .auto
})
let cookieOptions = ProviderCookieSourceUI.options(
allowsOff: false,
keychainDisabled: context.settings.debugDisableKeychainAccess)

let cookieSubtitle: () -> String? = {
ProviderCookieSourceUI.subtitle(
source: context.settings.zoomMateCookieSource,
keychainDisabled: context.settings.debugDisableKeychainAccess,
auto: "Automatically signs in using your ZoomMate session cookies from Chrome.",
manual: "Paste a cURL capture from the ZoomMate AI credit usage page.",
off: "Paste a cURL capture from the ZoomMate AI credit usage page.")
}

return [
ProviderSettingsPickerDescriptor(
id: "zoommate-cookie-source",
title: "Cookie source",
subtitle: "Automatically signs in using your ZoomMate session cookies from Chrome.",
dynamicSubtitle: cookieSubtitle,
binding: cookieBinding,
options: cookieOptions,
isVisible: nil,
onChange: nil,
trailingText: {
ProviderCookieRefreshAction.trailingText(
provider: .zoommate,
cookieSource: context.settings.zoomMateCookieSource,
context: context)
},
trailingActions: [
ProviderCookieRefreshAction.descriptor(
provider: .zoommate,
cookieSource: { context.settings.zoomMateCookieSource },
context: context),
]),
]
}

@MainActor
func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] {
[
ProviderSettingsFieldDescriptor(
id: "zoommate-cookie",
title: "ZoomMate capture",
subtitle: "Paste a full cURL capture from the ZoomMate AI credit usage page. " +
"The token expires approximately hourly, so you may need to re-paste periodically.",
kind: .secure,
placeholder: "curl 'https://ai.zoom.us/ai-computer/api/v1/credits/status' -H 'authorization: ...'",
binding: context.stringBinding(\.zoomMateCookieHeader),
actions: [
ProviderSettingsActionDescriptor(
id: "zoommate-open-app",
title: "Open ZoomMate",
style: .link,
isVisible: nil,
perform: {
if let url = URL(string: "https://zoommate.zoom.us/#/?settings=credit-usage") {
NSWorkspace.shared.open(url)
}
}),
],
isVisible: { context.settings.zoomMateCookieSource == .manual },
onActivate: nil),
]
}
}
36 changes: 36 additions & 0 deletions Sources/CodexBar/Providers/ZoomMate/ZoomMateSettingsStore.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import CodexBarCore
import Foundation

extension SettingsStore {
var zoomMateCookieHeader: String {
get { self.configSnapshot.providerConfig(for: .zoommate)?.sanitizedCookieHeader ?? "" }
set {
self.updateProviderConfig(provider: .zoommate) { entry in
entry.cookieHeader = self.normalizedConfigValue(newValue)
}
self.logSecretUpdate(provider: .zoommate, field: "cookieHeader", value: newValue)
}
}

var zoomMateCookieSource: ProviderCookieSource {
get { self.resolvedCookieSource(provider: .zoommate, fallback: .auto) }
set {
self.updateProviderConfig(provider: .zoommate) { entry in
entry.cookieSource = newValue
}
self.logProviderModeChange(provider: .zoommate, field: "cookieSource", value: newValue.rawValue)
}
}
}

extension SettingsStore {
func zoomMateSettingsSnapshot(
tokenOverride: TokenAccountOverride?) -> ProviderSettingsSnapshot.ZoomMateProviderSettings
{
self.resolvedCookieSettings(
provider: .zoommate,
configuredSource: self.zoomMateCookieSource,
configuredHeader: self.zoomMateCookieHeader,
tokenOverride: tokenOverride)
}
}
Loading