From 88215b87f4b18b41ac3caf1ab753aa51f2690d54 Mon Sep 17 00:00:00 2001 From: "Daniel (Balanced02)" <28750984+Balanced02@users.noreply.github.com> Date: Wed, 15 Jul 2026 00:32:37 +0100 Subject: [PATCH] feat: local Claude 5H/7D usage when disconnected + branded DMG installer Show last 5H/7D token counts and equivalent cost on the Claude tab from local ~/.claude logs (ccusage-style), so the tab is useful before you Connect. Connecting still adds the live limit %. The release .dmg now opens to a branded, retina drag-to-Applications window (rendered by a new dmgbggen tool as a multi-rep TIFF). Co-Authored-By: Claude Opus 4.8 --- .github/workflows/release.yml | 12 +- .gitignore | 3 + CHANGELOG.md | 13 +++ Package.swift | 3 + README.md | 5 +- .../Claude/ClaudeCostReader.swift | 12 +- Sources/AIUsageBarCore/Models.swift | 13 ++- Sources/AIUsageBarUI/ProviderDetailView.swift | 38 ++++++- Sources/dmgbggen/main.swift | 104 ++++++++++++++++++ Tests/AIUsageBarCoreTests/CoreTests.swift | 6 + 10 files changed, 200 insertions(+), 9 deletions(-) create mode 100644 Sources/dmgbggen/main.swift diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 49d0078..889a1bf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/.gitignore b/.gitignore index ad629c4..b62900c 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,6 @@ dist/ # Claude Code local (non-shared) settings .claude/settings.local.json + +# Superpowers plugin local state +.superpowers/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 690addf..ee98c63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Package.swift b/Package.swift index 918011f..10bad25 100644 --- a/Package.swift +++ b/Package.swift @@ -34,6 +34,9 @@ let package = Package( .executableTarget( name: "icongen" ), + .executableTarget( + name: "dmgbggen" + ), .executableTarget( name: "usageprobe", dependencies: ["AIUsageBarCore"] diff --git a/README.md b/README.md index 3f5fa1e..7f00564 100644 --- a/README.md +++ b/README.md @@ -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 | @@ -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.) diff --git a/Sources/AIUsageBarCore/Claude/ClaudeCostReader.swift b/Sources/AIUsageBarCore/Claude/ClaudeCostReader.swift index 3eac812..1a487b8 100644 --- a/Sources/AIUsageBarCore/Claude/ClaudeCostReader.swift +++ b/Sources/AIUsageBarCore/Claude/ClaudeCostReader.swift @@ -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 @@ -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 { @@ -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 { @@ -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) @@ -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 diff --git a/Sources/AIUsageBarCore/Models.swift b/Sources/AIUsageBarCore/Models.swift index 6f07d61..e11930e 100644 --- a/Sources/AIUsageBarCore/Models.swift +++ b/Sources/AIUsageBarCore/Models.swift @@ -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 @@ -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 } } diff --git a/Sources/AIUsageBarUI/ProviderDetailView.swift b/Sources/AIUsageBarUI/ProviderDetailView.swift index b61066e..dba626a 100644 --- a/Sources/AIUsageBarUI/ProviderDetailView.swift +++ b/Sources/AIUsageBarUI/ProviderDetailView.swift @@ -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) ?? []) } @@ -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")) + } + } +} + struct WindowRow: View { let window: UsageWindow let accent: Color diff --git a/Sources/dmgbggen/main.swift b/Sources/dmgbggen/main.swift new file mode 100644 index 0000000..d950e97 --- /dev/null +++ b/Sources/dmgbggen/main.swift @@ -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 +// +// 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() diff --git a/Tests/AIUsageBarCoreTests/CoreTests.swift b/Tests/AIUsageBarCoreTests/CoreTests.swift index 41f3f7f..31079bd 100644 --- a/Tests/AIUsageBarCoreTests/CoreTests.swift +++ b/Tests/AIUsageBarCoreTests/CoreTests.swift @@ -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