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
12 changes: 9 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -83,11 +83,17 @@ jobs:
ditto -c -k --keepParent dist/AIUsageBar.app "$ZIP"
STAGE="$RUNNER_TEMP/dmgsrc"; rm -rf "$STAGE"; mkdir -p "$STAGE"
cp -R dist/AIUsageBar.app "$STAGE/"
# A laid-out DMG: app on the left, Applications on the right, with a baked
# .DS_Store so Finder always shows the drag-to-install window. Fall back to
# a plain symlink DMG if create-dmg's Finder scripting hiccups on the runner.
# Render the branded DMG window background (title + drag-to-Applications
# arrow) as a multi-rep TIFF (1x + retina in one file). If it fails,
# --background points at a missing file and create-dmg falls back.
.build/release/dmgbggen dist/dmg-background.tiff || swift run -c release dmgbggen dist/dmg-background.tiff || true
# A laid-out DMG: app on the left, Applications on the right, over the
# branded background, with a baked .DS_Store so Finder always shows the
# drag-to-install window. Fall back to a plain symlink DMG if create-dmg's
# Finder scripting hiccups on the runner.
if create-dmg \
--volname "AI Usage Bar" --window-size 540 380 --icon-size 128 \
--background dist/dmg-background.tiff \
--icon "AIUsageBar.app" 140 190 --app-drop-link 400 190 \
--hide-extension "AIUsageBar.app" --no-internet-enable \
"$DMG" "$STAGE" && [ -f "$DMG" ]; then
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,6 @@ dist/

# Claude Code local (non-shared) settings
.claude/settings.local.json

# Superpowers plugin local state
.superpowers/
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,19 @@ as you cut a tag.

## [Unreleased]

## [0.1.6] - 2026-07-14

### Added
- **Local Claude usage without connecting.** When Claude isn't connected, the Claude tab now
shows your **last 5H and 7D** token counts and equivalent cost, derived entirely from your
local `~/.claude` logs (ccusage-style) — so the tab is useful before you ever click **Connect**.
Connecting still adds the live limit percentage from Claude's usage endpoint.

### Changed
- The release `.dmg` now opens to a **branded installer window** — app icon, a purple
drag-to-Applications arrow, and the app name/tagline over a light background — rendered at
retina resolution (a multi-representation TIFF Finder resolves per display).

## [0.1.5] - 2026-07-14

### Changed
Expand Down
3 changes: 3 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ let package = Package(
.executableTarget(
name: "icongen"
),
.executableTarget(
name: "dmgbggen"
),
.executableTarget(
name: "usageprobe",
dependencies: ["AIUsageBarCore"]
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ Everything is local — no proxy, no telemetry, no account of ours.
| Provider | Source | Fidelity |
|---|---|---|
| **Codex** | Newest `token_count` event under the configured Codex root (defaults to `$CODEX_HOME`, then `~/.codex`) — real 5h/weekly %, resets, plan, credits, tokens. Zero-auth. | ✅ Full |
| **Claude** | `GET api.anthropic.com/api/oauth/usage` per profile (OAuth token from the Keychain **after you Connect**, account matched via `/api/oauth/profile`). Falls back to local token-activity if the endpoint is unavailable. | ✅ Full % |
| **Claude** | `GET api.anthropic.com/api/oauth/usage` per profile (OAuth token from the Keychain **after you Connect**, account matched via `/api/oauth/profile`). Before connecting (or if the endpoint is unavailable) shows **local 5H / 7D** token activity + equivalent cost from `~/.claude` logs. | ✅ Full % |
| **Gemini** | Detects `gemini-cli` and reads the selected configuration root (defaults to `~/.gemini`); shows the plan cap or "not detected". gemini-cli persists no live quota. | ⚠️ Best-effort |
| **Custom** | Any folder of `.jsonl` logs + the dot-paths you configure (e.g. `rate_limit.used_percent`, `rate_limit.resets_at`). Newest file, last matching line. | ✅ Whatever the tool writes |

Expand Down Expand Up @@ -117,7 +117,8 @@ Scripts/build-app.sh --install # build → /Applications → launch

Nothing prompts for credentials at launch. To see **live** Claude limits, open the Claude tab and
click **Connect** (or Settings → Claude) — macOS then asks once to **Allow** Keychain access for
the Claude token; choose **Always Allow**. Your account and cost data show without connecting.
the Claude token; choose **Always Allow**. Your account, cost, and **last 5H / 7D local usage**
(derived from your `~/.claude` logs) show without connecting.
(Building locally auto-signs with an installed Developer ID for a stable signature; released builds
are notarized, so that one-time grant sticks.)

Expand Down
12 changes: 10 additions & 2 deletions Sources/AIUsageBarCore/Claude/ClaudeCostReader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ public enum ClaudeCostReader {
let cacheDir = cacheDirectory ?? UsageHistory.defaultDirectory()

let cutoff30 = now.addingTimeInterval(-30 * 24 * 3600)
let cutoff5h = now.addingTimeInterval(-5 * 3600)
let cutoff7d = now.addingTimeInterval(-7 * 24 * 3600)
let cal = Calendar.current
let startOfToday = cal.startOfDay(for: now)
let startOfMonth = cal.date(from: cal.dateComponents([.year, .month], from: now)) ?? startOfToday
Expand All @@ -22,7 +24,7 @@ public enum ClaudeCostReader {
// Skip the (potentially large) full parse when nothing changed since the
// last compute — the signature is just file paths + mtimes + sizes. The
// version prefix invalidates the cache when the aggregation logic changes.
let signature = "v4|" + fileSignature(files)
let signature = "v5|" + fileSignature(files)
let dayKey = Int(startOfToday.timeIntervalSince1970)
if let cached = loadCache(dir: cacheDir, profileID: profile.id),
cached.signature == signature, cached.day == dayKey {
Expand All @@ -39,6 +41,8 @@ public enum ClaudeCostReader {
var repoDaily: [String: [Double]] = [:] // repo → per-day $ (oldest→newest)
var rawInput = 0, cacheCreation = 0, cacheRead = 0
var cacheSavedUSD = 0.0
var last5hTokens = 0, last7dTokens = 0
var last5hUSD = 0.0, last7dUSD = 0.0
var repoNames: [String: String] = [:] // cwd → project name (memoized string parse)

for file in files {
Expand Down Expand Up @@ -69,6 +73,8 @@ public enum ClaudeCostReader {
monthUSD += usd
if ts >= startOfMonth { monthToDateUSD += usd }
if ts >= startOfToday { todayUSD += usd }
if ts >= cutoff5h { last5hTokens += toks; last5hUSD += usd }
if ts >= cutoff7d { last7dTokens += toks; last7dUSD += usd }

let mk = shortModel(model)
let m = byModel[mk] ?? (0, 0); byModel[mk] = (m.tokens + toks, m.usd + usd)
Expand Down Expand Up @@ -109,7 +115,9 @@ public enum ClaudeCostReader {
dailyUSD: repoDaily[repo] ?? [Double](repeating: 0, count: dailyBuckets))
}.sorted { $0.usd > $1.usd },
cacheHitRatio: hit,
cacheSavedUSD: cacheSavedUSD
cacheSavedUSD: cacheSavedUSD,
last5hTokens: last5hTokens, last5hUSD: last5hUSD,
last7dTokens: last7dTokens, last7dUSD: last7dUSD
)
saveCache(dir: cacheDir, profileID: profile.id, signature: signature, day: dayKey, summary: summary)
return summary
Expand Down
13 changes: 12 additions & 1 deletion Sources/AIUsageBarCore/Models.swift
Original file line number Diff line number Diff line change
Expand Up @@ -143,10 +143,17 @@ public struct CostSummary: Codable, Sendable, Hashable {
public var byRepo: [RepoCost]
public var cacheHitRatio: Double? // 0–1
public var cacheSavedUSD: Double
// Local rolling-window usage (ccusage-style — from log timestamps, no live API).
public var last5hTokens: Int
public var last5hUSD: Double
public var last7dTokens: Int
public var last7dUSD: Double

public init(todayUSD: Double, monthUSD: Double, monthToDateUSD: Double, totalTokens: Int,
byModel: [ModelCost], byRepo: [RepoCost],
cacheHitRatio: Double?, cacheSavedUSD: Double) {
cacheHitRatio: Double?, cacheSavedUSD: Double,
last5hTokens: Int = 0, last5hUSD: Double = 0,
last7dTokens: Int = 0, last7dUSD: Double = 0) {
self.todayUSD = todayUSD
self.monthUSD = monthUSD
self.monthToDateUSD = monthToDateUSD
Expand All @@ -155,6 +162,10 @@ public struct CostSummary: Codable, Sendable, Hashable {
self.byRepo = byRepo
self.cacheHitRatio = cacheHitRatio
self.cacheSavedUSD = cacheSavedUSD
self.last5hTokens = last5hTokens
self.last5hUSD = last5hUSD
self.last7dTokens = last7dTokens
self.last7dUSD = last7dUSD
}
}

Expand Down
38 changes: 37 additions & 1 deletion Sources/AIUsageBarUI/ProviderDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,13 @@ struct AccountBlock: View {
header

if usage.windows.isEmpty {
emptyState
// No live limits (not connected) — show ccusage-style local usage
// from the logs so the account is still useful without the Keychain.
if let cost = usage.cost, cost.last7dTokens > 0 {
LocalUsageRows(cost: cost, accent: accent)
} else {
emptyState
}
} else {
VStack(spacing: 12) {
ForEach(usage.windows) { WindowRow(window: $0, accent: accent, samples: history?($0) ?? []) }
Expand Down Expand Up @@ -225,6 +231,36 @@ struct AccountBlock: View {

/// One window row: `LABEL 45% resets in…` over a full-width meter,
/// with a pace tick and (only when relevant) a burn-rate warning.
/// ccusage-style local usage windows (5H / 7D) derived from the logs — token
/// volume + equivalent cost, no live quota %. Shown when a Claude account isn't
/// connected, so it stays useful without any Keychain access.
struct LocalUsageRows: View {
let cost: CostSummary
let accent: Color

var body: some View {
VStack(alignment: .leading, spacing: 8) {
usageRow("5H", tokens: cost.last5hTokens, usd: cost.last5hUSD)
usageRow("7D", tokens: cost.last7dTokens, usd: cost.last7dUSD)
Text("Local activity from your logs — Connect for the live limit %.")
.font(.caption2).foregroundStyle(.tertiary)
}
}

private func usageRow(_ label: String, tokens: Int, usd: Double) -> some View {
HStack(spacing: 10) {
Text(label).font(.system(size: 12, weight: .semibold))
.frame(width: 32, alignment: .leading)
Text("\(Theme.compactTokens(tokens) ?? "0") tok")
.font(.system(size: 12).monospacedDigit()).foregroundStyle(.secondary)
Spacer(minLength: 6)
Text(Theme.usd(usd))
.font(.system(size: 12, weight: .medium).monospacedDigit())
.foregroundStyle(Theme.modelColor("Opus"))
}
}
}

Comment on lines +234 to +263

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Use the provided accent color instead of hardcoding "Opus".

The LocalUsageRows struct receives the account's accent color from the caller, but the property goes unused because the USD value's foreground style is hardcoded to Theme.modelColor("Opus"). This forces the local-usage totals to render in purple instead of matching the account's accent color.

🎨 Proposed fix
-                .foregroundStyle(Theme.modelColor("Opus"))
+                .foregroundStyle(accent)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/// ccusage-style local usage windows (5H / 7D) derived from the logs — token
/// volume + equivalent cost, no live quota %. Shown when a Claude account isn't
/// connected, so it stays useful without any Keychain access.
struct LocalUsageRows: View {
let cost: CostSummary
let accent: Color
var body: some View {
VStack(alignment: .leading, spacing: 8) {
usageRow("5H", tokens: cost.last5hTokens, usd: cost.last5hUSD)
usageRow("7D", tokens: cost.last7dTokens, usd: cost.last7dUSD)
Text("Local activity from your logs — Connect for the live limit %.")
.font(.caption2).foregroundStyle(.tertiary)
}
}
private func usageRow(_ label: String, tokens: Int, usd: Double) -> some View {
HStack(spacing: 10) {
Text(label).font(.system(size: 12, weight: .semibold))
.frame(width: 32, alignment: .leading)
Text("\(Theme.compactTokens(tokens) ?? "0") tok")
.font(.system(size: 12).monospacedDigit()).foregroundStyle(.secondary)
Spacer(minLength: 6)
Text(Theme.usd(usd))
.font(.system(size: 12, weight: .medium).monospacedDigit())
.foregroundStyle(Theme.modelColor("Opus"))
}
}
}
/// ccusage-style local usage windows (5H / 7D) derived from the logs — token
/// volume + equivalent cost, no live quota %. Shown when a Claude account isn't
/// connected, so it stays useful without any Keychain access.
struct LocalUsageRows: View {
let cost: CostSummary
let accent: Color
var body: some View {
VStack(alignment: .leading, spacing: 8) {
usageRow("5H", tokens: cost.last5hTokens, usd: cost.last5hUSD)
usageRow("7D", tokens: cost.last7dTokens, usd: cost.last7dUSD)
Text("Local activity from your logs — Connect for the live limit %.")
.font(.caption2).foregroundStyle(.tertiary)
}
}
private func usageRow(_ label: String, tokens: Int, usd: Double) -> some View {
HStack(spacing: 10) {
Text(label).font(.system(size: 12, weight: .semibold))
.frame(width: 32, alignment: .leading)
Text("\(Theme.compactTokens(tokens) ?? "0") tok")
.font(.system(size: 12).monospacedDigit()).foregroundStyle(.secondary)
Spacer(minLength: 6)
Text(Theme.usd(usd))
.font(.system(size: 12, weight: .medium).monospacedDigit())
.foregroundStyle(accent)
}
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/AIUsageBarUI/ProviderDetailView.swift` around lines 234 - 263, Update
LocalUsageRows.usageRow so the USD value uses the provided accent color for its
foreground style instead of Theme.modelColor("Opus"), ensuring local-usage
totals match the caller-supplied account accent.

struct WindowRow: View {
let window: UsageWindow
let accent: Color
Expand Down
104 changes: 104 additions & 0 deletions Sources/dmgbggen/main.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import SwiftUI
import AppKit

// Renders the DMG window background: branded title + a "drag to Applications"
// swoosh arrow between the app icon (left) and the Applications folder (right).
// Writes a multi-representation TIFF (540×380 @1x + 1080×760 @2x in one file) so
// Finder shows it crisp on both retina and non-retina — create-dmg has no @2x
// support, but Finder resolves the right rep from the TIFF. Also writes a PNG
// preview next to it for docs.
// dmgbggen <out.tiff>
//
// Must match Scripts/release create-dmg geometry: window 540×380, app icon at
// (140,190), Applications drop-link at (400,190) — measured from the top-left.

private let W: CGFloat = 540
private let H: CGFloat = 380

struct DMGBackground: View {
var body: some View {
ZStack {
LinearGradient(colors: [Color(red: 0.99, green: 0.99, blue: 1.0),
Color(red: 0.93, green: 0.92, blue: 0.97)],
startPoint: .top, endPoint: .bottom)

// Title + tagline (top, clear of the y=190 icon row).
VStack(spacing: 5) {
Text("AI Usage Bar")
.font(.system(size: 25, weight: .bold))
.foregroundStyle(Color(red: 0.13, green: 0.14, blue: 0.20))
Text("Every AI-coding limit, in your menu bar")
.font(.system(size: 12.5))
.foregroundStyle(Color(red: 0.42, green: 0.42, blue: 0.48))
}
.position(x: W / 2, y: 52)

// The swoosh arrow from the app icon toward Applications (icons drawn
// by Finder at x=140 and x=400; keep clear of their ~64px radius).
SwooshArrow()
.stroke(Color(red: 0.55, green: 0.48, blue: 0.95),
style: StrokeStyle(lineWidth: 5, lineCap: .round, lineJoin: .round))
.shadow(color: Color(red: 0.55, green: 0.48, blue: 0.95).opacity(0.25), radius: 4, y: 2)

Text("Drag to Applications")
.font(.system(size: 11, weight: .medium))
.foregroundStyle(Color(red: 0.55, green: 0.48, blue: 0.95))
.position(x: W / 2, y: 250)

// Faint hint labels under the two icon slots (Finder also draws the
// real icon labels here; this just anchors the eye).
}
.frame(width: W, height: H)
}
}

/// A curved arrow (quadratic swoosh) from ~(205,190) to ~(338,190) with a head.
struct SwooshArrow: Shape {
func path(in rect: CGRect) -> Path {
var p = Path()
let start = CGPoint(x: 208, y: 188)
let end = CGPoint(x: 336, y: 190)
let control = CGPoint(x: 272, y: 232) // dips down for a swoosh
p.move(to: start)
p.addQuadCurve(to: end, control: control)
// Arrowhead at `end`, pointing up-right along the tangent.
let a1 = CGPoint(x: end.x - 15, y: end.y - 2)
let a2 = CGPoint(x: end.x - 4, y: end.y + 13)
p.move(to: a1); p.addLine(to: end); p.addLine(to: a2)
return p
}
}

@MainActor
func rep(scale: CGFloat) -> NSBitmapImageRep? {
let renderer = ImageRenderer(content: DMGBackground())
renderer.scale = scale
guard let cg = renderer.cgImage else { return nil }
let rep = NSBitmapImageRep(cgImage: cg)
rep.size = NSSize(width: W, height: H) // point size; pixels = W*scale → tags the DPI
return rep
}

@MainActor
func render() {
let out = CommandLine.arguments.dropFirst().first ?? "dmg-background.tiff"
guard let r1 = rep(scale: 1), let r2 = rep(scale: 2) else {
print("dmgbggen: render failed"); return
}
// Multi-rep TIFF: Finder picks the 2x rep on retina, the 1x elsewhere.
let image = NSImage(size: NSSize(width: W, height: H))
image.addRepresentation(r1)
image.addRepresentation(r2)
if let tiff = image.tiffRepresentation {
try? tiff.write(to: URL(fileURLWithPath: out))
print("dmgbggen: wrote \(out) (1x + 2x)")
}
// PNG preview (the 2x rep) for docs.
if let png = r2.representation(using: .png, properties: [:]) {
let preview = (out as NSString).deletingPathExtension + ".png"
try? png.write(to: URL(fileURLWithPath: preview))
print("dmgbggen: wrote \(preview)")
}
}

render()
6 changes: 6 additions & 0 deletions Tests/AIUsageBarCoreTests/CoreTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,12 @@ final class CoreTests: XCTestCase {
XCTAssertEqual(cost.byModel.first?.model, "Opus") // sorted by $ desc
XCTAssertEqual(Set(cost.byRepo.map(\.repo)), ["api-server", "web-app"])

// Rolling local-usage windows: all fixture data is ~1h old, so 5h & 7d == total.
XCTAssertEqual(cost.last5hUSD, cost.monthUSD, accuracy: 1e-6)
XCTAssertEqual(cost.last7dUSD, cost.monthUSD, accuracy: 1e-6)
XCTAssertEqual(cost.last5hTokens, cost.totalTokens)
XCTAssertEqual(cost.last7dTokens, cost.totalTokens)

// Per-project drilldown: each project carries its own model mix + daily trend.
let apiRepo = try XCTUnwrap(cost.byRepo.first { $0.repo == "api-server" })
XCTAssertEqual(apiRepo.byModel.map(\.model), ["Opus"]) // api-server used only Opus
Expand Down
Loading