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
2 changes: 0 additions & 2 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,12 @@ let package = Package(
dependencies: [
.package(url: "https://github.com/kishikawakatsumi/KeychainAccess.git", from: "4.2.2"),
.package(url: "https://github.com/sindresorhus/LaunchAtLogin-Modern.git", from: "1.1.0"),
.package(url: "https://github.com/groue/GRDB.swift.git", from: "7.5.0"),
],
targets: [
.target(
name: "ClaudeUsageCore",
dependencies: [
"KeychainAccess",
.product(name: "GRDB", package: "GRDB.swift"),
],
path: "Sources/ClaudeUsageCore"
),
Expand Down
8 changes: 3 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,17 @@ A macOS menubar app that tracks your Claude (Code) usage in real time.

## Features

- **Menubar indicator** — circular ring gauge with percentage, always visible
- **Dual-percent menubar** — consumer plans show `5h% · 7d%` inline, each color-coded by threshold. Enterprise plans keep the ring gauge with monthly spend.
- **Usage cards** — 5-hour, 7-day, Sonnet, and Opus utilization at a glance
- **Enterprise support** — monthly credit spend with dollar/percentage toggle and burn rate projection
- **Usage history** — area chart with adaptive time range (auto/7d/30d), stored in SQLite
- **Burn rate alerts** — notification when projected to hit limit within 60 minutes
- **Threshold notifications** — configurable alerts at 80% and 95% usage
- **CSV export** — export full history for analysis
- **OAuth sign-in** — authenticate via your browser with your Claude account
- **Claude Code auto-detect** — picks up credentials from Claude Code if installed (file-based, no keychain prompts)
- **Token refresh** — keeps your session alive without re-authenticating
- **Auto-polling** — configurable interval (1/5/15/30 min) with exponential backoff
- **Response cache** — hydrates the menu bar instantly on launch and defers the first poll when the cached response is still fresh, so rapid restarts don't stack API calls
- **Sleep/wake aware** — pauses polling on sleep, resumes on wake
- **Multi-language** — English, Dutch, German, French, Spanish, Portuguese (BR & PT)
- **Light & dark mode** — system colors throughout

## Installation
Expand Down Expand Up @@ -78,7 +76,7 @@ The app polls `https://api.anthropic.com/api/oauth/usage` on a configurable inte
| Opus | 7-day usage for Opus models specifically |
| Credits | Enterprise monthly spend vs limit |

Usage history is stored locally in `~/.config/claude-usage/history.db` (SQLite) with 30-day retention.
The last successful response is cached at `~/Library/Application Support/cc-stats/last-usage.json` so the menu bar can render immediately on relaunch without a network round-trip. Delete that file if you want to force a clean fetch.

## Configuration

Expand Down
6 changes: 0 additions & 6 deletions Resources/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,6 @@
<key>CFBundleLocalizations</key>
<array>
<string>en</string>
<string>nl</string>
<string>de</string>
<string>fr</string>
<string>es</string>
<string>pt-PT</string>
<string>pt-BR</string>
</array>
<key>LSUIElement</key>
<true/>
Expand Down
560 changes: 53 additions & 507 deletions Resources/Localizable.xcstrings

Large diffs are not rendered by default.

10 changes: 5 additions & 5 deletions Sources/ClaudeUsageApp/App.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,28 +15,28 @@ struct ClaudeUsageApp: App {
MenuBarLabel(viewModel: appDelegate.viewModel)
}
.menuBarExtraStyle(.window)

Settings {
SettingsRootView(viewModel: appDelegate.viewModel)
}
}
}

@MainActor
final class AppDelegate: NSObject, NSApplicationDelegate {
private let notificationService = NotificationService()
private let historyStore = UsageHistoryStore()

lazy var viewModel = UsageViewModel(
credentialProvider: {
try? OAuthCredential.fromKeychain()
},
notificationService: notificationService,
historyStore: historyStore
notificationService: notificationService
)

lazy var widgetController = FloatingWidgetController(viewModel: viewModel)

func applicationDidFinishLaunching(_ notification: Notification) {
NSApplication.shared.setActivationPolicy(.accessory)
let dir = UsageHistoryStore.defaultDirectory()
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
notificationService.requestPermission()
viewModel.startPolling()

Expand Down
81 changes: 54 additions & 27 deletions Sources/ClaudeUsageApp/MenuBarLabel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,55 +8,82 @@ struct MenuBarLabel: View {
@AppStorage("criticalThreshold") private var criticalThreshold = 80.0
@AppStorage("colorMode") private var colorMode = "traffic_light"

private var pct: Double {
var body: some View {
if viewModel.isEnterprise {
let extra = viewModel.usage?.extraUsage
guard let used = extra?.usedCredits, let limit = extra?.monthlyLimit, limit > 0 else { return 0 }
return (used / limit) * 100
enterpriseLabel
} else {
consumerLabel
}
return viewModel.usage?.fiveHour?.utilization ?? 0
}

private var displayText: String {
if viewModel.isEnterprise {
if showPercentage {
return "\(Int(pct))%"
}
guard let used = viewModel.usage?.extraUsage?.usedCreditsAmount else { return "--" }
if used < 1 { return "$0" }
return String(format: "$%.0f", used)
private var consumerLabel: some View {
let primary = MenuBarMetrics.consumerPrimaryPercent(from: viewModel.usage)
let secondary = MenuBarMetrics.consumerSecondaryPercent(from: viewModel.usage)
return HStack(spacing: 2) {
percentText(primary)
Text("·")
.foregroundStyle(.secondary)
percentText(secondary)
}
return viewModel.menuBarText
.font(.system(size: 11, weight: .medium, design: .rounded))
.monospacedDigit()
}

private var ringColor: Color {
if colorMode == "single_color" {
// Teal gradient: lighter at low usage, darker at high
return Color(.systemTeal).opacity(0.4 + (pct / 100.0) * 0.6)
@ViewBuilder
private func percentText(_ value: Int?) -> some View {
if let value {
Text("\(value)%")
.foregroundStyle(color(for: Double(value)))
} else {
Text("--")
.foregroundStyle(.secondary)
}
// Traffic light mode
switch pct {
case 0..<warningThreshold: return Color(.systemGreen)
case warningThreshold..<criticalThreshold: return Color(.systemOrange)
default: return Color(.systemRed)
}

private func color(for percent: Double) -> Color {
switch MenuBarMetrics.thresholdColor(
for: percent,
warning: warningThreshold,
critical: criticalThreshold,
colorMode: colorMode
) {
case .ok: return Color(.systemGreen)
case .warning: return Color(.systemOrange)
case .critical: return Color(.systemRed)
case .tealScale(let opacity): return Color(.systemTeal).opacity(opacity)
}
}

var body: some View {
private var enterpriseLabel: some View {
HStack(spacing: 4) {
ZStack {
Circle()
.stroke(Color.primary.opacity(0.2), lineWidth: 2)
Circle()
.trim(from: 0, to: min(pct / 100.0, 1.0))
.stroke(ringColor, style: StrokeStyle(lineWidth: 2, lineCap: .round))
.trim(from: 0, to: min(enterprisePct / 100.0, 1.0))
.stroke(color(for: enterprisePct), style: StrokeStyle(lineWidth: 2, lineCap: .round))
.rotationEffect(.degrees(-90))
}
.frame(width: 12, height: 12)

Text(displayText)
Text(enterpriseDisplayText)
.font(.system(size: 11, weight: .medium, design: .rounded))
.monospacedDigit()
}
}

private var enterprisePct: Double {
let extra = viewModel.usage?.extraUsage
guard let used = extra?.usedCredits, let limit = extra?.monthlyLimit, limit > 0 else { return 0 }
return (used / limit) * 100
}

private var enterpriseDisplayText: String {
if showPercentage {
return "\(Int(enterprisePct))%"
}
guard let used = viewModel.usage?.extraUsage?.usedCreditsAmount else { return "--" }
if used < 1 { return "$0" }
return String(format: "$%.0f", used)
}
}
95 changes: 33 additions & 62 deletions Sources/ClaudeUsageApp/PopoverView.swift
Original file line number Diff line number Diff line change
@@ -1,22 +1,27 @@
import SwiftUI
import ClaudeUsageCore
import UniformTypeIdentifiers

struct MenuContentView: View {
@ObservedObject var viewModel: UsageViewModel
var widgetController: FloatingWidgetController?
@StateObject private var authFlow = AuthFlowState()
@State private var exportError: String?
@State private var showSettings = false
@Environment(\.openSettings) private var openSettings

var body: some View {
VStack(alignment: .leading, spacing: 8) {
if viewModel.usage != nil, viewModel.profile != nil {
HStack(spacing: 4) {
Spacer()
Text(viewModel.planTier.label)
.font(.system(size: 9, weight: .medium))
.foregroundStyle(.tertiary)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.primary.opacity(0.06), in: Capsule())
}
}
if let usage = viewModel.usage {
UsageDetailView(usage: usage, creditProjection: viewModel.creditProjection)

if !viewModel.historyPoints.isEmpty {
UsageChartView(points: viewModel.historyPoints, isEnterprise: viewModel.isEnterprise)
}
} else if authFlow.isAwaitingCode {
OAuthCodeEntryView(authFlow: authFlow, viewModel: viewModel)
} else if let error = viewModel.error {
Expand All @@ -27,43 +32,30 @@ struct MenuContentView: View {
ProgressView("Loading...")
}

// Update banner
if let update = viewModel.availableUpdate {
HStack(spacing: 6) {
Circle()
.fill(.orange)
.frame(width: 6, height: 6)
Link("v\(update.version) available", destination: update.releaseURL)
.font(.caption)
.foregroundStyle(.orange)
Spacer()
Button {
viewModel.dismissUpdate()
} label: {
Image(systemName: "xmark")
.font(.system(size: 8, weight: .bold))
}
.buttonStyle(.borderless)
.foregroundStyle(.tertiary)
}
}

// Footer
HStack(spacing: 12) {
HStack(spacing: 8) {
if let lastUpdated = viewModel.lastUpdated {
Text("Updated \(lastUpdated, style: .relative) ago")
Text(lastUpdated, style: .relative)
.foregroundStyle(.tertiary)
.font(.system(size: 9))
.monospacedDigit()
}
Spacer()
if !viewModel.historyPoints.isEmpty {
Button { exportCSV() } label: {
Image(systemName: "square.and.arrow.up")

if let update = viewModel.availableUpdate {
Link(destination: update.releaseURL) {
HStack(spacing: 3) {
Circle().fill(.orange).frame(width: 5, height: 5)
Text("v\(update.version)")
.font(.system(size: 9, weight: .medium))
.foregroundStyle(.orange)
}
}
.buttonStyle(.borderless)
.foregroundStyle(.secondary)
.help("Export CSV")
.buttonStyle(.plain)
.help("Download update")
}

Spacer()

if viewModel.usage != nil {
Button { Task { await viewModel.refresh() } } label: {
Image(systemName: "arrow.clockwise")
Expand All @@ -84,39 +76,18 @@ struct MenuContentView: View {
}
}
Button {
showSettings.toggle()
NSApp.activate(ignoringOtherApps: true)
openSettings()
} label: {
Image(systemName: "gearshape")
}
.buttonStyle(.borderless)
.foregroundStyle(showSettings ? .primary : .secondary)
.foregroundStyle(.secondary)
.help("Settings")
}
.font(.system(size: 12))

if showSettings {
Divider()
SettingsView(viewModel: viewModel)
}
}
.padding(12)
.frame(width: 300)
}

private func exportCSV() {
let csv = CSVExporter.export(viewModel.historyPoints)
let panel = NSSavePanel()
panel.nameFieldStringValue = "claude-usage.csv"
panel.allowedContentTypes = [.commaSeparatedText]
panel.begin { response in
guard response == .OK, let url = panel.url else { return }
do {
try csv.write(to: url, atomically: true, encoding: .utf8)
} catch {
Task { @MainActor in
exportError = "Export failed: \(error.localizedDescription)"
}
}
}
.frame(width: 260)
}
}
Loading
Loading