diff --git a/.github/workflows/release-menubar.yml b/.github/workflows/release-menubar.yml
new file mode 100644
index 0000000..b3902d3
--- /dev/null
+++ b/.github/workflows/release-menubar.yml
@@ -0,0 +1,69 @@
+name: Release macOS Menubar
+
+# Triggers on a `mac-v*` tag push (e.g. `git tag mac-v0.8.0 && git push origin mac-v0.8.0`),
+# or manually via the Actions tab. Builds a universal arm64+x86_64 bundle, ad-hoc signs it,
+# zips via `ditto`, and uploads the zip to the GitHub Release. `npx codeburn menubar` clears
+# the download quarantine flag on install so Gatekeeper stays quiet.
+on:
+ push:
+ tags:
+ - 'mac-v*'
+ workflow_dispatch:
+ inputs:
+ version:
+ description: 'Version label for the bundle (e.g. v0.8.0 or dev-preview)'
+ required: true
+ default: 'dev-preview'
+
+permissions:
+ contents: write # Needed to create the release + upload assets.
+
+jobs:
+ build:
+ runs-on: macos-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Resolve version label
+ id: version
+ run: |
+ if [[ "${GITHUB_REF}" == refs/tags/mac-v* ]]; then
+ echo "value=${GITHUB_REF#refs/tags/mac-}" >> "$GITHUB_OUTPUT"
+ else
+ echo "value=${{ github.event.inputs.version }}" >> "$GITHUB_OUTPUT"
+ fi
+
+ - name: Show Swift toolchain
+ run: swift --version
+
+ - name: Build + bundle + zip
+ run: mac/Scripts/package-app.sh "${{ steps.version.outputs.value }}"
+
+ - name: Upload artifact (for manual runs)
+ if: github.event_name == 'workflow_dispatch'
+ uses: actions/upload-artifact@v4
+ with:
+ name: CodeBurnMenubar-${{ steps.version.outputs.value }}
+ path: mac/.build/dist/CodeBurnMenubar-*.zip
+ if-no-files-found: error
+
+ - name: Create / update GitHub Release
+ if: startsWith(github.ref, 'refs/tags/mac-v')
+ uses: softprops/action-gh-release@v2
+ with:
+ tag_name: ${{ github.ref_name }}
+ name: Menubar ${{ steps.version.outputs.value }}
+ body: |
+ Install with:
+
+ ```
+ npx codeburn menubar
+ ```
+
+ That command drops the app into `~/Applications`, clears the download
+ quarantine, and launches it. If you download the zip from this page directly
+ and macOS shows "cannot verify developer", right-click the app in Finder and
+ pick Open to whitelist it once.
+ files: mac/.build/dist/CodeBurnMenubar-*.zip
+ fail_on_unmatched_files: true
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0e49607..831b81d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,27 @@
# Changelog
+## 0.7.2 - 2026-04-17
+
+### Added
+- **Native macOS menubar app.** Swift + SwiftUI app under `mac/` replaces the SwiftBar plugin. Agent tabs, Today/7/30/Month/All period switcher, Trend/Forecast/Pulse/Stats/Plan insights, activity and model breakdowns, optimize findings, CSV/JSON export, instant currency switching, live 60s refresh.
+- **`codeburn menubar`.** One-command install: downloads the latest `.app` from GitHub Releases, strips Gatekeeper quarantine, drops it into `~/Applications`, and launches it. `--force` reinstalls in place.
+- **`status --format menubar-json`.** Structured payload consumed by the native menubar app. Current-period totals, per-activity and per-model breakdowns, provider costs, optimize findings, and 365-day history.
+- **Release workflow.** `.github/workflows/release-menubar.yml` builds a universal `.app` bundle and zip on `mac-v*` tag push.
+
+### Changed
+- **`codeburn export -f csv`** now writes a folder of one-table-per-file CSVs (`summary`, `daily`, `activity`, `models`, `projects`, `sessions`, `tools`, `shell-commands`) plus a `README.txt` index. Each file opens cleanly as a single table in any spreadsheet.
+- **`codeburn export -f json`** upgraded to schema `codeburn.export.v2` with currency metadata.
+
+### Fixed
+- **`codeburn status` terminal Today/Month** now buckets by local date instead of UTC, so spend shows correctly during the window between local midnight and UTC midnight.
+- **FX rate validation.** Frankfurter responses are checked to be finite and within `[0.0001, 1_000_000]` before they affect displayed costs.
+
+### Removed
+- **SwiftBar plugin.** `src/menubar.ts`, `codeburn install-menubar`, `codeburn uninstall-menubar`, and `status --format menubar` are gone. The native Swift app is the single menubar surface.
+
+### Security
+- **`codeburn export -o` guard.** Writes a `.codeburn-export` marker into every folder it creates and refuses to reuse non-marked directories or overwrite existing files, so a typo like `-o ~/.ssh/id_ed25519` cannot delete a sensitive file.
+
## 0.7.1 - 2026-04-17
### Security
diff --git a/README.md b/README.md
index 151a090..4b6e167 100644
--- a/README.md
+++ b/README.md
@@ -19,7 +19,7 @@
-By task type, tool, model, MCP server, and project. Supports **Claude Code**, **Codex** (OpenAI), **Cursor**, **OpenCode**, **Pi**, and **GitHub Copilot** with a provider plugin system. Tracks one-shot success rate per activity type so you can see where the AI nails it first try vs. burns tokens on edit/test/fix retries. Interactive TUI dashboard with gradient charts, responsive panels, and keyboard navigation. macOS menu bar widget via SwiftBar. CSV/JSON export.
+By task type, tool, model, MCP server, and project. Supports **Claude Code**, **Codex** (OpenAI), **Cursor**, **OpenCode**, **Pi**, and **GitHub Copilot** with a provider plugin system. Tracks one-shot success rate per activity type so you can see where the AI nails it first try vs. burns tokens on edit/test/fix retries. Interactive TUI dashboard with gradient charts, responsive panels, and keyboard navigation. Native macOS menubar app in `mac/`. CSV/JSON export.
Works by reading session data directly from disk. No wrapper, no proxy, no API keys. Pricing from LiteLLM (auto-cached, all models supported).
@@ -156,14 +156,13 @@ The menu bar widget includes a currency picker with 17 common currencies. For an
## Menu Bar
-
+
```bash
-codeburn install-menubar # install SwiftBar/xbar plugin
-codeburn uninstall-menubar # remove it
+npx codeburn menubar
```
-Requires [SwiftBar](https://github.com/swiftbar/SwiftBar) (`brew install --cask swiftbar`). Shows today's cost in the menu bar with a flame icon. Dropdown shows activity breakdown, model costs, token stats, per-provider cost breakdown, and a currency picker. Refreshes every 5 minutes.
+One command: downloads the latest `.app`, installs into `~/Applications`, and launches it. Re-run with `--force` to reinstall. Native Swift + SwiftUI app lives in `mac/` (see `mac/README.md` for build details). Shows today's cost with a flame icon, opens a popover with agent tabs, period switcher (Today / 7 Days / 30 Days / Month / All), Trend / Forecast / Pulse / Stats / Plan insights, activity and model breakdowns, optimize findings, and CSV/JSON export. Refreshes live via FSEvents plus a 60-second poll.
## What it tracks
@@ -269,7 +268,7 @@ src/
classifier.ts 13-category task classifier
types.ts Type definitions
format.ts Text rendering (status bar)
- menubar.ts SwiftBar plugin generator
+ menubar-json.ts Payload builder consumed by the native macOS menubar app in mac/
export.ts CSV/JSON multi-period export
config.ts Config file management (~/.config/codeburn/)
currency.ts Currency conversion, exchange rates, Intl formatting
diff --git a/assets/menubar.png b/assets/menubar.png
index 1f93d4d..1ad264a 100644
Binary files a/assets/menubar.png and b/assets/menubar.png differ
diff --git a/mac/.gitignore b/mac/.gitignore
new file mode 100644
index 0000000..a14fabd
--- /dev/null
+++ b/mac/.gitignore
@@ -0,0 +1,6 @@
+.build/
+.swiftpm/
+Package.resolved
+*.xcodeproj/
+*.xcworkspace/
+DerivedData/
diff --git a/mac/Package.swift b/mac/Package.swift
new file mode 100644
index 0000000..67509f2
--- /dev/null
+++ b/mac/Package.swift
@@ -0,0 +1,26 @@
+// swift-tools-version: 6.0
+import PackageDescription
+
+let package = Package(
+ name: "CodeBurnMenubar",
+ platforms: [
+ .macOS(.v14)
+ ],
+ products: [
+ .executable(name: "CodeBurnMenubar", targets: ["CodeBurnMenubar"])
+ ],
+ targets: [
+ .executableTarget(
+ name: "CodeBurnMenubar",
+ path: "Sources/CodeBurnMenubar",
+ swiftSettings: [
+ .enableUpcomingFeature("StrictConcurrency")
+ ]
+ ),
+ .testTarget(
+ name: "CodeBurnMenubarTests",
+ dependencies: ["CodeBurnMenubar"],
+ path: "Tests/CodeBurnMenubarTests"
+ )
+ ]
+)
diff --git a/mac/README.md b/mac/README.md
new file mode 100644
index 0000000..4d3e2ae
--- /dev/null
+++ b/mac/README.md
@@ -0,0 +1,88 @@
+# CodeBurn Menubar (macOS)
+
+Native Swift + SwiftUI menubar app. The codeburn menubar surface.
+
+## Requirements
+
+- macOS 14+ (Sonoma)
+- Swift 6.0+ toolchain (bundled with Xcode 16 or standalone)
+- `codeburn` CLI installed globally (`npm install -g codeburn`) or available at a path you pass via `CODEBURN_BIN`
+
+## Install (end users)
+
+One command:
+
+```bash
+npx codeburn menubar
+```
+
+That's it. The command downloads the latest `.app` from GitHub Releases, drops it into `~/Applications`, clears Gatekeeper quarantine, and launches it. Re-running it upgrades in place with `--force`, or just launches the existing copy otherwise.
+
+If you already have the CLI installed globally (`npm install -g codeburn`), `codeburn menubar` works the same way.
+
+### Build from source
+
+For contributors running a local build instead of the packaged release:
+
+```bash
+npm install -g codeburn # CLI the app shells out to for data
+git clone https://github.com/AgentSeal/codeburn.git
+cd codeburn/mac
+swift build -c release
+.build/release/CodeBurnMenubar # launch
+```
+
+## Build & run (dev against a local CLI checkout)
+
+```bash
+cd mac
+swift build
+# Point the app at your dev CLI build instead of the globally installed `codeburn`:
+npm --prefix .. run build
+CODEBURN_BIN="node $(pwd)/../dist/cli.js" swift run
+```
+
+The app registers itself as a menubar accessory (`LSUIElement = true` at runtime). No Dock icon.
+
+## Data source
+
+On launch and every 60 seconds thereafter, the app spawns `codeburn status --format menubar-json --no-optimize` directly (argv, no shell) via `CodeburnCLI.makeProcess` and decodes the JSON into `MenubarPayload`. The manual refresh button in the footer invokes the same command without `--no-optimize`, which includes optimize findings but takes longer.
+
+Override the binary via the `CODEBURN_BIN` environment variable (default: `codeburn` on PATH). The value is validated against a strict allowlist (alphanumerics plus `._/-` space) before use, so a malicious env var can't inject shell commands.
+
+## Project layout
+
+```
+mac/
+├── Package.swift SwiftPM manifest
+├── Sources/CodeBurnMenubar/
+│ ├── CodeBurnApp.swift @main + MenuBarExtra scene
+│ ├── AppStore.swift @Observable store + enums
+│ ├── Data/MenubarPayload.swift Codable payload types + placeholder
+│ ├── Theme/Theme.swift Design tokens (warm terracotta palette)
+│ └── Views/MenuBarContent.swift Popover layout + footer action bar
+└── README.md This file
+```
+
+## Status
+
+Live data wired. Next iterations:
+
+1. FSEvents watch for `~/.claude/projects/` changes (debounced refresh on real edits)
+2. Persistent disk cache for optimize findings so the default refresh can include them without the 30-second penalty
+3. Currency metadata in the JSON payload + Swift-side formatting
+4. Sparkle auto-update
+5. DMG packaging + Homebrew Cask tap
+
+## Design tokens
+
+Sourced from `~/codeburn-menubar-mac-swiftui.html`. Warm terracotta-ember palette:
+
+- Accent (light): `#C9521D`
+- Accent (dark): `#E8774A`
+- Ember deep: `#8B3E13`
+- Ember glow: `#F0A070`
+- Surface (light): `#FAF7F3`
+- Surface (dark): `#1C1816`
+
+SF Mono for currency values; SF Pro Rounded for hero.
diff --git a/mac/Scripts/package-app.sh b/mac/Scripts/package-app.sh
new file mode 100755
index 0000000..5672b5e
--- /dev/null
+++ b/mac/Scripts/package-app.sh
@@ -0,0 +1,103 @@
+#!/usr/bin/env bash
+# Builds a universal CodeBurnMenubar.app bundle from the SwiftPM target and drops a
+# distributable zip alongside. Used by the GitHub release workflow; also runnable locally.
+#
+# Usage:
+# mac/Scripts/package-app.sh []
+# Defaults to `dev` if no version is given.
+
+set -euo pipefail
+
+VERSION="${1:-dev}"
+BUNDLE_NAME="CodeBurnMenubar.app"
+BUNDLE_ID="org.agentseal.codeburn-menubar"
+EXECUTABLE_NAME="CodeBurnMenubar"
+MIN_MACOS="14.0"
+
+repo_root() {
+ git rev-parse --show-toplevel 2>/dev/null || (cd "$(dirname "$0")/../.." && pwd)
+}
+
+ROOT=$(repo_root)
+MAC_DIR="${ROOT}/mac"
+DIST_DIR="${MAC_DIR}/.build/dist"
+
+cd "${MAC_DIR}"
+
+echo "▸ Cleaning previous dist..."
+rm -rf "${DIST_DIR}"
+mkdir -p "${DIST_DIR}"
+
+echo "▸ Building universal binary (arm64 + x86_64)..."
+swift build -c release --arch arm64 --arch x86_64
+
+BIN_PATH=$(swift build -c release --arch arm64 --arch x86_64 --show-bin-path)
+BUILT_BINARY="${BIN_PATH}/${EXECUTABLE_NAME}"
+if [[ ! -x "${BUILT_BINARY}" ]]; then
+ echo "Binary not found at ${BUILT_BINARY}" >&2
+ exit 1
+fi
+
+echo "▸ Assembling ${BUNDLE_NAME}..."
+BUNDLE="${DIST_DIR}/${BUNDLE_NAME}"
+mkdir -p "${BUNDLE}/Contents/MacOS"
+mkdir -p "${BUNDLE}/Contents/Resources"
+cp "${BUILT_BINARY}" "${BUNDLE}/Contents/MacOS/${EXECUTABLE_NAME}"
+
+cat > "${BUNDLE}/Contents/Info.plist" <
+
+
+
+ CFBundleDevelopmentRegion
+ en
+ CFBundleDisplayName
+ CodeBurn Menubar
+ CFBundleExecutable
+ ${EXECUTABLE_NAME}
+ CFBundleIconFile
+ AppIcon
+ CFBundleIdentifier
+ ${BUNDLE_ID}
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ ${EXECUTABLE_NAME}
+ CFBundlePackageType
+ APPL
+ CFBundleShortVersionString
+ ${VERSION}
+ CFBundleVersion
+ ${VERSION}
+ LSMinimumSystemVersion
+ ${MIN_MACOS}
+ LSUIElement
+
+ NSHighResolutionCapable
+
+ NSHumanReadableCopyright
+ © AgentSeal
+
+
+PLIST
+
+cat > "${BUNDLE}/Contents/PkgInfo" <<'PKG'
+APPL????
+PKG
+
+# Ad-hoc sign so macOS treats the bundle as internally consistent. This satisfies the
+# minimum bundle-validity checks on macOS 14+ and prevents a class of Gatekeeper edge
+# cases on managed Macs. A Developer ID signature (separate setup) would additionally
+# surface the publisher name in Finder; not required here.
+echo "▸ Ad-hoc signing..."
+codesign --force --sign - --timestamp=none --deep "${BUNDLE}" 2>/dev/null || true
+codesign --verify --deep --strict "${BUNDLE}" 2>/dev/null || echo " (signature verify skipped)"
+
+ZIP_NAME="CodeBurnMenubar-${VERSION}.zip"
+ZIP_PATH="${DIST_DIR}/${ZIP_NAME}"
+echo "▸ Packaging ${ZIP_NAME}..."
+(cd "${DIST_DIR}" && /usr/bin/ditto -c -k --keepParent "${BUNDLE_NAME}" "${ZIP_NAME}")
+
+echo ""
+echo "✓ Built ${ZIP_PATH}"
+ls -la "${DIST_DIR}"
diff --git a/mac/Sources/CodeBurnMenubar/AppStore.swift b/mac/Sources/CodeBurnMenubar/AppStore.swift
new file mode 100644
index 0000000..cd26d76
--- /dev/null
+++ b/mac/Sources/CodeBurnMenubar/AppStore.swift
@@ -0,0 +1,307 @@
+import Foundation
+import Observation
+
+private let cacheTTLSeconds: TimeInterval = 300
+
+struct CachedPayload {
+ let payload: MenubarPayload
+ let fetchedAt: Date
+ var isFresh: Bool { Date().timeIntervalSince(fetchedAt) < cacheTTLSeconds }
+}
+
+struct PayloadCacheKey: Hashable {
+ let period: Period
+ let provider: ProviderFilter
+}
+
+@MainActor
+@Observable
+final class AppStore {
+ var selectedProvider: ProviderFilter = .all
+ var selectedPeriod: Period = .today
+ var selectedInsight: InsightMode = .trend
+ var currency: String = "USD"
+ var isLoading: Bool = false
+ var lastError: String?
+ var subscription: SubscriptionUsage?
+ var subscriptionError: String?
+ var subscriptionLoadState: SubscriptionLoadState = .idle
+ var capacityEstimates: [String: CapacityEstimate] = [:]
+
+ private var cache: [PayloadCacheKey: CachedPayload] = [:]
+
+ private var currentKey: PayloadCacheKey {
+ PayloadCacheKey(period: selectedPeriod, provider: selectedProvider)
+ }
+
+ var payload: MenubarPayload {
+ cache[currentKey]?.payload ?? .empty
+ }
+
+ /// Today (across all providers) is pinned for the always-visible menubar icon, independent of
+ /// the popover's selected period or provider.
+ var todayPayload: MenubarPayload? {
+ cache[PayloadCacheKey(period: .today, provider: .all)]?.payload
+ }
+
+ var hasCachedData: Bool {
+ cache[currentKey] != nil
+ }
+
+ var findingsCount: Int {
+ payload.optimize.findingCount
+ }
+
+ /// Switch to a period. Uses cached payload if fresh; otherwise fetches.
+ func switchTo(period: Period) async {
+ selectedPeriod = period
+ if let cached = cache[currentKey], cached.isFresh { return }
+ await refresh(includeOptimize: true)
+ }
+
+ /// Switch to a provider filter. Uses cached payload if fresh; otherwise fetches.
+ func switchTo(provider: ProviderFilter) async {
+ selectedProvider = provider
+ if let cached = cache[currentKey], cached.isFresh { return }
+ await refresh(includeOptimize: true)
+ }
+
+ private var inFlightKeys: Set = []
+
+ /// Refresh the currently selected (period, provider) combination. Guards against concurrent
+ /// fetches for the same key so a slow initial request can't overwrite a newer one that
+ /// finished first (which would show stale numbers the user has already moved past).
+ func refresh(includeOptimize: Bool) async {
+ let key = currentKey
+ guard !inFlightKeys.contains(key) else { return }
+ inFlightKeys.insert(key)
+ isLoading = true
+ defer {
+ inFlightKeys.remove(key)
+ isLoading = false
+ }
+ do {
+ let fresh = try await DataClient.fetch(period: key.period, provider: key.provider, includeOptimize: includeOptimize)
+ cache[key] = CachedPayload(payload: fresh, fetchedAt: Date())
+ lastError = nil
+ } catch {
+ lastError = String(describing: error)
+ NSLog("CodeBurn: fetch failed for \(key.period.rawValue)/\(key.provider.rawValue): \(error)")
+ }
+ }
+
+ /// Background refresh for a period other than the visible one (e.g. keeping today fresh for the menubar badge).
+ /// Does not toggle isLoading, so the popover's loading overlay is unaffected.
+ /// Always uses the .all provider since the menubar badge shows total spend.
+ func refreshQuietly(period: Period) async {
+ do {
+ let fresh = try await DataClient.fetch(period: period, provider: .all, includeOptimize: true)
+ cache[PayloadCacheKey(period: period, provider: .all)] = CachedPayload(payload: fresh, fetchedAt: Date())
+ } catch {
+ NSLog("CodeBurn: quiet refresh failed for \(period.rawValue): \(error)")
+ }
+ }
+
+ /// Fetch Claude subscription usage. Sets subscription = nil on missing creds (API users / unauthenticated).
+ /// Triggered lazily when the user opens the Plan pill, so the Keychain prompt only fires on intent.
+ func refreshSubscription() async {
+ subscriptionLoadState = .loading
+ do {
+ let usage = try await SubscriptionClient.fetch()
+ subscription = usage
+ subscriptionError = nil
+ subscriptionLoadState = .loaded
+ await captureSnapshots(for: usage)
+ } catch SubscriptionError.noCredentials {
+ subscription = nil
+ subscriptionError = nil
+ subscriptionLoadState = .noCredentials
+ } catch {
+ subscription = nil
+ subscriptionError = String(describing: error)
+ subscriptionLoadState = .failed
+ NSLog("CodeBurn: subscription fetch failed: \(error)")
+ }
+ }
+
+ /// Persist one snapshot per window so we can answer "what did the prior cycle end at?"
+ /// when the current window has just reset and projection from current data isn't meaningful.
+ /// Also computes the effective_tokens consumed inside each 7-day window from local history,
+ /// which the CapacityEstimator uses to derive the absolute token capacity per tier.
+ private func captureSnapshots(for usage: SubscriptionUsage) async {
+ let now = Date()
+ let history = payload.history.daily
+
+ let captures: [(key: String, percent: Double?, resetsAt: Date?, effective: Double?)] = [
+ ("five_hour", usage.fiveHourPercent, usage.fiveHourResetsAt, nil),
+ ("seven_day", usage.sevenDayPercent, usage.sevenDayResetsAt,
+ effectiveTokensInLast7Days(history: history, asOf: now)),
+ ("seven_day_opus", usage.sevenDayOpusPercent, usage.sevenDayOpusResetsAt, nil),
+ ("seven_day_sonnet", usage.sevenDaySonnetPercent, usage.sevenDaySonnetResetsAt, nil),
+ ]
+ for capture in captures {
+ guard let percent = capture.percent, let resetsAt = capture.resetsAt else { continue }
+ await SubscriptionSnapshotStore.record(SubscriptionSnapshot(
+ windowKey: capture.key,
+ percent: percent,
+ resetsAt: resetsAt,
+ capturedAt: now,
+ effectiveTokens: capture.effective
+ ))
+ }
+
+ await refreshCapacityEstimates()
+ }
+
+ /// Sum effective tokens (input + 5*output + cache_creation + 0.1*cache_read) across the
+ /// last 7 days of dailyHistory. Used as the "tokens consumed in 7-day window" reading paired
+ /// with the API-reported percent for capacity estimation.
+ private func effectiveTokensInLast7Days(history: [DailyHistoryEntry], asOf now: Date) -> Double {
+ let cutoff = ISO8601DateFormatter().string(from: now.addingTimeInterval(-7 * 86400)).prefix(10)
+ return history
+ .filter { $0.date >= cutoff }
+ .reduce(0.0) { $0 + $1.effectiveTokens }
+ }
+
+ /// Run CapacityEstimator over each window's accumulated snapshots. Only snapshots with a
+ /// non-nil effectiveTokens contribute. Result lives in capacityEstimates dict for UI gating.
+ private func refreshCapacityEstimates() async {
+ var next: [String: CapacityEstimate] = [:]
+ for key in ["seven_day", "seven_day_opus", "seven_day_sonnet"] {
+ let snaps = await SubscriptionSnapshotStore.snapshots(for: key)
+ let capacitySnaps = snaps.compactMap { s -> CapacitySnapshot? in
+ guard let effective = s.effectiveTokens, effective > 0 else { return nil }
+ return CapacitySnapshot(percent: s.percent, effectiveTokens: effective, capturedAt: s.capturedAt)
+ }
+ if let estimate = CapacityEstimator.estimate(capacitySnaps) {
+ next[key] = estimate
+ }
+ }
+ capacityEstimates = next
+ }
+}
+
+enum SupportedCurrency: String, CaseIterable, Identifiable {
+ case USD, GBP, EUR, AUD, CAD, NZD, JPY, CHF, INR, BRL, SEK, SGD, HKD, KRW, MXN, ZAR, DKK
+ var id: String { rawValue }
+ var displayName: String {
+ switch self {
+ case .USD: "US Dollar"
+ case .GBP: "British Pound"
+ case .EUR: "Euro"
+ case .AUD: "Australian Dollar"
+ case .CAD: "Canadian Dollar"
+ case .NZD: "New Zealand Dollar"
+ case .JPY: "Japanese Yen"
+ case .CHF: "Swiss Franc"
+ case .INR: "Indian Rupee"
+ case .BRL: "Brazilian Real"
+ case .SEK: "Swedish Krona"
+ case .SGD: "Singapore Dollar"
+ case .HKD: "Hong Kong Dollar"
+ case .KRW: "South Korean Won"
+ case .MXN: "Mexican Peso"
+ case .ZAR: "South African Rand"
+ case .DKK: "Danish Krone"
+ }
+ }
+}
+
+enum ProviderFilter: String, CaseIterable, Identifiable {
+ case all = "All"
+ case claude = "Claude"
+ case codex = "Codex"
+ case cursor = "Cursor"
+ case copilot = "Copilot"
+
+ var id: String { rawValue }
+
+ /// Maps to the CLI's `--provider` argument values.
+ var cliArg: String {
+ switch self {
+ case .all: "all"
+ case .claude: "claude"
+ case .codex: "codex"
+ case .cursor: "cursor"
+ case .copilot: "copilot"
+ }
+ }
+}
+
+enum SubscriptionLoadState: Sendable, Equatable {
+ case idle // never tried, awaiting user intent
+ case loading // fetch in progress
+ case loaded // success; subscription is populated
+ case noCredentials // tried; user has no Claude OAuth (API user / not logged in)
+ case failed // tried; error occurred
+}
+
+enum InsightMode: String, CaseIterable, Identifiable {
+ case plan = "Plan"
+ case trend = "Trend"
+ case forecast = "Forecast"
+ case pulse = "Pulse"
+ case stats = "Stats"
+ var id: String { rawValue }
+}
+
+enum Period: String, CaseIterable, Identifiable {
+ case today = "Today"
+ case sevenDays = "7 Days"
+ case thirtyDays = "30 Days"
+ case month = "Month"
+ case all = "All"
+
+ var id: String { rawValue }
+
+ /// Maps to the CLI's `--period` argument values.
+ var cliArg: String {
+ switch self {
+ case .today: "today"
+ case .sevenDays: "week"
+ case .thirtyDays: "30days"
+ case .month: "month"
+ case .all: "all"
+ }
+ }
+}
+
+/// NumberFormatter is expensive to instantiate (~microseconds each) and currency/token values
+/// are formatted dozens of times per popover refresh. These shared instances avoid thousands of
+/// allocations per frame while SwiftUI's Observation framework still triggers redraws when
+/// CurrencyState.shared mutates.
+private let groupedDecimalFormatter: NumberFormatter = {
+ let f = NumberFormatter()
+ f.numberStyle = .decimal
+ f.groupingSeparator = ","
+ f.decimalSeparator = "."
+ f.maximumFractionDigits = 2
+ f.minimumFractionDigits = 2
+ return f
+}()
+
+private let thousandsFormatter: NumberFormatter = {
+ let f = NumberFormatter()
+ f.numberStyle = .decimal
+ f.groupingSeparator = ","
+ return f
+}()
+
+extension Double {
+ func asCurrency() -> String {
+ let state = CurrencyState.shared
+ let converted = self * state.rate
+ return state.symbol + (groupedDecimalFormatter.string(from: NSNumber(value: converted)) ?? "\(converted)")
+ }
+
+ func asCompactCurrency() -> String {
+ let state = CurrencyState.shared
+ return String(format: "\(state.symbol)%.2f", self * state.rate)
+ }
+}
+
+extension Int {
+ func asThousandsSeparated() -> String {
+ thousandsFormatter.string(from: NSNumber(value: self)) ?? "\(self)"
+ }
+}
diff --git a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift
new file mode 100644
index 0000000..9a18364
--- /dev/null
+++ b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift
@@ -0,0 +1,182 @@
+import SwiftUI
+import AppKit
+import Observation
+
+private let refreshIntervalSeconds: UInt64 = 60
+private let nanosPerSecond: UInt64 = 1_000_000_000
+private let refreshIntervalNanos: UInt64 = refreshIntervalSeconds * nanosPerSecond
+/// Fixed so the popover's anchor point doesn't shift each time today's cost changes.
+private let statusItemFixedWidth: CGFloat = 130
+private let popoverWidth: CGFloat = 360
+private let popoverHeight: CGFloat = 660
+private let menubarTitleFontSize: CGFloat = 13
+
+@main
+struct CodeBurnApp: App {
+ @NSApplicationDelegateAdaptor(AppDelegate.self) var delegate
+
+ var body: some Scene {
+ // SwiftUI App needs at least one scene. Settings is invisible by default.
+ Settings {
+ EmptyView()
+ }
+ }
+}
+
+@MainActor
+final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
+ private var statusItem: NSStatusItem!
+ private var popover: NSPopover!
+ private let store = AppStore()
+ private var refreshTask: Task?
+
+ func applicationDidFinishLaunching(_ notification: Notification) {
+ // Menubar accessory -- no Dock icon, no app switcher entry.
+ NSApp.setActivationPolicy(.accessory)
+
+ restorePersistedCurrency()
+ setupStatusItem()
+ setupPopover()
+ observeStore()
+ startRefreshLoop()
+ // Subscription is fetched lazily when the user opens the Plan pill, so the macOS
+ // Keychain prompt never fires until the user explicitly asks for it.
+ }
+
+ /// Loads the currency code persisted by `codeburn currency` so a relaunch picks up where
+ /// the user left off. Rate is resolved from the on-disk FX cache if present, otherwise
+ /// fetched live in the background.
+ private func restorePersistedCurrency() {
+ guard let code = CLICurrencyConfig.loadCode(), code != "USD" else { return }
+ let symbol = CurrencyState.symbolForCode(code)
+ store.currency = code
+
+ Task {
+ let cached = await FXRateCache.shared.cachedRate(for: code)
+ await MainActor.run {
+ CurrencyState.shared.apply(code: code, rate: cached, symbol: symbol)
+ }
+ let fresh = await FXRateCache.shared.rate(for: code)
+ if let fresh, fresh != cached {
+ await MainActor.run {
+ CurrencyState.shared.apply(code: code, rate: fresh, symbol: symbol)
+ }
+ }
+ }
+ }
+
+ func applicationWillTerminate(_ notification: Notification) {
+ refreshTask?.cancel()
+ }
+
+ private func startRefreshLoop() {
+ refreshTask = Task { [weak self] in
+ while !Task.isCancelled {
+ guard let self else { return }
+ if self.store.selectedPeriod != .today {
+ await self.store.refreshQuietly(period: .today)
+ }
+ // Optimize is fast (~1s warm-cache) so include findings on every refresh.
+ await self.store.refresh(includeOptimize: true)
+ try? await Task.sleep(nanoseconds: refreshIntervalNanos)
+ }
+ }
+ }
+
+ private func observeStore() {
+ withObservationTracking {
+ _ = store.payload
+ _ = store.todayPayload
+ } onChange: { [weak self] in
+ Task { @MainActor in
+ self?.refreshStatusButton()
+ self?.observeStore()
+ }
+ }
+ }
+
+ // MARK: - Status Item
+
+ private func setupStatusItem() {
+ // Fixed width so the popover anchor (and thus popover position) doesn't shift
+ // every time today's cost or findings badge changes.
+ statusItem = NSStatusBar.system.statusItem(withLength: statusItemFixedWidth)
+ guard let button = statusItem.button else { return }
+ button.target = self
+ button.action = #selector(handleButtonClick(_:))
+ button.sendAction(on: [.leftMouseUp, .rightMouseUp])
+ refreshStatusButton()
+ }
+
+ /// Composes the menubar title as a single attributed string with the flame as an inline
+ /// NSTextAttachment. NSStatusItem's separate `image` + `attributedTitle` path leaves a
+ /// stubborn gap between icon and text on some macOS releases (the icon hugs the left edge
+ /// of the status item, the title starts at its own baseline), so we inline both so they
+ /// flow as one typographic unit with a single, controllable gap.
+ private func refreshStatusButton() {
+ guard let button = statusItem.button else { return }
+
+ // Clear any previously-set image so the attachment is the only glyph rendered.
+ button.image = nil
+ button.imagePosition = .noImage
+
+ let font = NSFont.monospacedDigitSystemFont(ofSize: menubarTitleFontSize, weight: .medium)
+ let flameConfig = NSImage.SymbolConfiguration(pointSize: menubarTitleFontSize, weight: .medium)
+ let flame = NSImage(systemSymbolName: "flame.fill", accessibilityDescription: "CodeBurn")?
+ .withSymbolConfiguration(flameConfig)
+ flame?.isTemplate = true
+
+ let attachment = NSTextAttachment()
+ attachment.image = flame
+ if let size = flame?.size {
+ // Nudge the image down ~2pt so its visual centre sits on the text baseline mid-line
+ // rather than riding high. Exact value tuned against SF Pro Display 13pt.
+ attachment.bounds = CGRect(x: 0, y: -2, width: size.width, height: size.height)
+ }
+
+ let hasPayload = store.todayPayload != nil
+ let valueText = " " + (store.todayPayload?.current.cost.asCompactCurrency() ?? "$—")
+ let color: NSColor = hasPayload ? .labelColor : .secondaryLabelColor
+
+ let composed = NSMutableAttributedString()
+ composed.append(NSAttributedString(attachment: attachment))
+ composed.append(NSAttributedString(
+ string: valueText,
+ attributes: [.font: font, .foregroundColor: color]
+ ))
+ button.attributedTitle = composed
+ }
+
+ // MARK: - Popover
+
+ private func setupPopover() {
+ popover = NSPopover()
+ popover.contentSize = NSSize(width: popoverWidth, height: popoverHeight)
+ popover.behavior = .transient // auto-close only on explicit outside click
+ popover.animates = true
+ popover.delegate = self
+
+ let content = MenuBarContent()
+ .environment(store)
+ .frame(width: popoverWidth)
+
+ popover.contentViewController = NSHostingController(rootView: content)
+ }
+
+ @objc private func handleButtonClick(_ sender: AnyObject?) {
+ guard let button = statusItem.button else { return }
+ if popover.isShown {
+ popover.performClose(sender)
+ } else {
+ NSApp.activate(ignoringOtherApps: true)
+ popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
+ popover.contentViewController?.view.window?.makeKey()
+ }
+ }
+
+ // MARK: - NSPopoverDelegate
+
+ func popoverShouldDetach(_ popover: NSPopover) -> Bool {
+ false
+ }
+}
diff --git a/mac/Sources/CodeBurnMenubar/CurrencyState.swift b/mac/Sources/CodeBurnMenubar/CurrencyState.swift
new file mode 100644
index 0000000..e668139
--- /dev/null
+++ b/mac/Sources/CodeBurnMenubar/CurrencyState.swift
@@ -0,0 +1,209 @@
+import Foundation
+import Observation
+
+private let fxCacheTTLSeconds: TimeInterval = 24 * 3600
+private let frankfurterBaseURL = "https://api.frankfurter.app/latest?from=USD&to="
+/// Defensive bounds on any fetched FX rate. Real-world USD→X rates sit in [0.0001, 200000]
+/// for every ISO 4217 pair; anything outside is either a parser bug or a MITM poisoning
+/// attempt. We clamp hard so UI can't render NaN, negative, or astronomical numbers.
+private let minValidFXRate: Double = 0.0001
+private let maxValidFXRate: Double = 1_000_000
+private let fxFetchTimeoutSeconds: TimeInterval = 10
+
+@Observable
+final class CurrencyState: @unchecked Sendable {
+ static let shared = CurrencyState()
+
+ var code: String = "USD"
+ var rate: Double = 1.0
+ var symbol: String = "$"
+
+ private init() {}
+
+ /// Applies a new currency context. Callers must invoke on the main actor so @Observable
+ /// view updates run on the UI thread. Rejects non-finite or out-of-band rates so a
+ /// poisoned Frankfurter response can't corrupt displayed costs.
+ func apply(code: String, rate: Double?, symbol: String) {
+ self.code = code
+ self.symbol = symbol
+ if let r = rate, r.isFinite, r >= minValidFXRate, r <= maxValidFXRate {
+ self.rate = r
+ }
+ }
+
+ static func symbolForCode(_ code: String) -> String {
+ // Some locales return "US$" for USD or "CA$" for CAD via NumberFormatter. Prefer the
+ // plain glyph form everyone recognises.
+ if let override = symbolOverrides[code] { return override }
+ let formatter = NumberFormatter()
+ formatter.numberStyle = .currency
+ formatter.currencyCode = code
+ formatter.locale = Locale(identifier: "en_\(code.prefix(2))")
+ return formatter.currencySymbol ?? code
+ }
+
+ private static let symbolOverrides: [String: String] = [
+ "USD": "$",
+ "CAD": "$",
+ "AUD": "$",
+ "NZD": "$",
+ "HKD": "$",
+ "SGD": "$",
+ "MXN": "$",
+ "EUR": "\u{20AC}",
+ "GBP": "\u{00A3}",
+ "JPY": "\u{00A5}",
+ "CNY": "\u{00A5}",
+ "KRW": "\u{20A9}",
+ "INR": "\u{20B9}",
+ "BRL": "R$",
+ "CHF": "CHF",
+ "SEK": "kr",
+ "DKK": "kr",
+ "ZAR": "R"
+ ]
+}
+
+actor FXRateCache {
+ static let shared = FXRateCache()
+
+ private struct Entry: Codable {
+ let rate: Double
+ let savedAt: TimeInterval
+ }
+
+ private var entries: [String: Entry] = [:]
+ private var loaded = false
+
+ private var cacheFilePath: String {
+ let base = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
+ return base
+ .appendingPathComponent("codeburn-mac", isDirectory: true)
+ .appendingPathComponent("fx-rates.json")
+ .path
+ }
+
+ private func loadIfNeeded() {
+ guard !loaded else { return }
+ loaded = true
+ do {
+ let data = try SafeFile.read(from: cacheFilePath)
+ let decoded = try JSONDecoder().decode([String: Entry].self, from: data)
+ // Drop any persisted entries whose rate violates the sanity bounds -- covers an
+ // old cache that was written before the clamp was introduced.
+ entries = decoded.filter { _, entry in
+ entry.rate.isFinite && entry.rate >= minValidFXRate && entry.rate <= maxValidFXRate
+ }
+ } catch {
+ entries = [:]
+ }
+ }
+
+ private func persist() {
+ guard let data = try? JSONEncoder().encode(entries) else { return }
+ try? SafeFile.write(data, to: cacheFilePath)
+ }
+
+ /// Returns a cached rate regardless of freshness. Nil if never fetched.
+ func cachedRate(for code: String) -> Double? {
+ if code == "USD" { return 1.0 }
+ loadIfNeeded()
+ return entries[code]?.rate
+ }
+
+ /// Returns a fresh rate, fetching from Frankfurter when cache is stale or absent. Nil on
+ /// failure. The returned rate is always finite, positive, and within the sanity bounds.
+ func rate(for code: String) async -> Double? {
+ if code == "USD" { return 1.0 }
+ loadIfNeeded()
+
+ if let entry = entries[code],
+ Date().timeIntervalSince1970 - entry.savedAt < fxCacheTTLSeconds {
+ return entry.rate
+ }
+
+ guard let url = URL(string: "\(frankfurterBaseURL)\(code)") else { return entries[code]?.rate }
+
+ let config = URLSessionConfiguration.ephemeral
+ config.timeoutIntervalForRequest = fxFetchTimeoutSeconds
+ config.tlsMinimumSupportedProtocolVersion = .TLSv12
+ let session = URLSession(configuration: config)
+
+ do {
+ let (data, response) = try await session.data(from: url)
+ guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
+ return entries[code]?.rate
+ }
+ struct Response: Decodable { let rates: [String: Double] }
+ let decoded = try JSONDecoder().decode(Response.self, from: data)
+ guard let fresh = decoded.rates[code],
+ fresh.isFinite, fresh >= minValidFXRate, fresh <= maxValidFXRate else {
+ NSLog("CodeBurn: discarding out-of-band FX rate for \(code)")
+ return entries[code]?.rate
+ }
+ entries[code] = Entry(rate: fresh, savedAt: Date().timeIntervalSince1970)
+ persist()
+ return fresh
+ } catch {
+ return entries[code]?.rate
+ }
+ }
+}
+
+/// Reads and writes the CLI's persisted currency config (~/.config/codeburn/config.json).
+/// Uses an on-disk flock so a concurrent `codeburn currency ...` invocation from a terminal
+/// can't race the menubar and silently drop each other's writes (TOCTOU on config.json).
+enum CLICurrencyConfig {
+ private static var configDir: String {
+ (NSHomeDirectory() as NSString).appendingPathComponent(".config/codeburn")
+ }
+ private static var configPath: String {
+ (configDir as NSString).appendingPathComponent("config.json")
+ }
+ private static var lockPath: String {
+ (configDir as NSString).appendingPathComponent(".config.lock")
+ }
+
+ static func loadCode() -> String? {
+ guard
+ let data = try? SafeFile.read(from: configPath),
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
+ let currency = json["currency"] as? [String: Any],
+ let code = currency["code"] as? String
+ else {
+ return nil
+ }
+ return code.uppercased()
+ }
+
+ static func persist(code: String) {
+ do {
+ try SafeFile.withExclusiveLock(at: lockPath) {
+ var existing: [String: Any] = [:]
+ if let data = try? SafeFile.read(from: configPath),
+ let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
+ existing = parsed
+ }
+
+ if code == "USD" {
+ existing.removeValue(forKey: "currency")
+ } else {
+ existing["currency"] = [
+ "code": code,
+ "symbol": CurrencyState.symbolForCode(code)
+ ]
+ }
+
+ guard let data = try? JSONSerialization.data(
+ withJSONObject: existing,
+ options: [.prettyPrinted, .sortedKeys]
+ ) else {
+ return
+ }
+ try SafeFile.write(data, to: configPath, mode: 0o600)
+ }
+ } catch {
+ NSLog("CodeBurn: failed to persist currency config: \(error)")
+ }
+ }
+}
diff --git a/mac/Sources/CodeBurnMenubar/Data/CapacityEstimator.swift b/mac/Sources/CodeBurnMenubar/Data/CapacityEstimator.swift
new file mode 100644
index 0000000..446d0e7
--- /dev/null
+++ b/mac/Sources/CodeBurnMenubar/Data/CapacityEstimator.swift
@@ -0,0 +1,127 @@
+import Foundation
+
+public struct CapacitySnapshot: Sendable, Equatable {
+ public let percent: Double // 0..100, Anthropic-reported utilization
+ public let effectiveTokens: Double // weighted sum of input/output/cache tokens consumed at capture
+ public let capturedAt: Date
+
+ public init(percent: Double, effectiveTokens: Double, capturedAt: Date) {
+ self.percent = percent
+ self.effectiveTokens = effectiveTokens
+ self.capturedAt = capturedAt
+ }
+}
+
+public enum CapacityConfidence: String, Sendable {
+ case low, medium, solid
+}
+
+public struct CapacityEstimate: Sendable, Equatable {
+ public let capacity: Double // tokens equivalent to 100%
+ public let confidence: CapacityConfidence
+ public let sampleSize: Int // post-decorrelation count
+ public let nonLinearityWarning: Bool
+
+ public init(capacity: Double, confidence: CapacityConfidence, sampleSize: Int, nonLinearityWarning: Bool) {
+ self.capacity = capacity
+ self.confidence = confidence
+ self.sampleSize = sampleSize
+ self.nonLinearityWarning = nonLinearityWarning
+ }
+}
+
+public enum CapacityEstimator {
+ private static let minSampleSize = 5
+ private static let minPercentRange = 15.0
+ private static let recencyHalfLifeSeconds: Double = 30 * 86400
+ private static let solidR2 = 0.97
+ private static let mediumR2 = 0.85
+ private static let solidSampleThreshold = 15
+ private static let mediumSampleThreshold = 6
+ private static let nonLinearityRunLengthThreshold = 0.7
+
+ public static func estimate(_ snapshots: [CapacitySnapshot], asOf now: Date = Date()) -> CapacityEstimate? {
+ guard snapshots.count >= minSampleSize else { return nil }
+ let percents = snapshots.map(\.percent)
+ let range = (percents.max() ?? 0) - (percents.min() ?? 0)
+ guard range >= minPercentRange else { return nil }
+
+ let weighted = snapshots.map { snap -> (p: Double, t: Double, w: Double) in
+ let ageSeconds = now.timeIntervalSince(snap.capturedAt)
+ let weight = pow(0.5, max(0, ageSeconds) / recencyHalfLifeSeconds)
+ return (snap.percent, snap.effectiveTokens, weight)
+ }
+
+ // Weighted least squares through origin: minimize sum(w * (t - p * cap/100)^2)
+ // Solution: cap = 100 * sum(w * t * p) / sum(w * p * p)
+ let numerator = weighted.reduce(0.0) { $0 + $1.w * $1.t * $1.p }
+ let denominator = weighted.reduce(0.0) { $0 + $1.w * $1.p * $1.p }
+ guard denominator > 0 else { return nil }
+ let capacity = 100.0 * numerator / denominator
+ guard capacity > 0 else { return nil }
+
+ // Weighted R^2 against the through-origin fit.
+ let weightedTokenSum = weighted.reduce(0.0) { $0 + $1.w * $1.t }
+ let weightSum = weighted.reduce(0.0) { $0 + $1.w }
+ let weightedMeanT = weightedTokenSum / max(weightSum, .ulpOfOne)
+ let ssRes = weighted.reduce(0.0) { acc, s in
+ let predicted = s.p * capacity / 100
+ let diff = s.t - predicted
+ return acc + s.w * diff * diff
+ }
+ let ssTot = weighted.reduce(0.0) { acc, s in
+ let diff = s.t - weightedMeanT
+ return acc + s.w * diff * diff
+ }
+ let r2 = ssTot > 0 ? max(0.0, 1.0 - ssRes / ssTot) : 0.0
+
+ let n = snapshots.count
+ let confidence: CapacityConfidence = {
+ if n >= solidSampleThreshold && r2 >= solidR2 { return .solid }
+ if n >= mediumSampleThreshold && r2 >= mediumR2 { return .medium }
+ return .low
+ }()
+
+ let nonLinearityWarning = detectNonLinearity(snapshots: weighted, capacity: capacity)
+
+ return CapacityEstimate(
+ capacity: capacity,
+ confidence: confidence,
+ sampleSize: n,
+ nonLinearityWarning: nonLinearityWarning
+ )
+ }
+
+ /// Sign-test on residuals across the percent range. If residuals form a long monotonic run
+ /// (e.g. all-negative in low percents then all-positive at high), the relationship isn't linear.
+ private static func detectNonLinearity(
+ snapshots: [(p: Double, t: Double, w: Double)],
+ capacity: Double
+ ) -> Bool {
+ let sorted = snapshots.sorted { $0.p < $1.p }
+ let signs = sorted.map { s -> Int in
+ let predicted = s.p * capacity / 100
+ let diff = s.t - predicted
+ if abs(diff) < .ulpOfOne { return 0 }
+ return diff > 0 ? 1 : -1
+ }.filter { $0 != 0 }
+ guard signs.count >= minSampleSize else { return false }
+
+ // Longest single-sign run length / total
+ var longestRun = 0
+ var currentRun = 0
+ var currentSign = 0
+ for s in signs {
+ if s == currentSign {
+ currentRun += 1
+ } else {
+ longestRun = max(longestRun, currentRun)
+ currentSign = s
+ currentRun = 1
+ }
+ }
+ longestRun = max(longestRun, currentRun)
+ let runFraction = Double(longestRun) / Double(signs.count)
+ return runFraction >= nonLinearityRunLengthThreshold
+ }
+}
diff --git a/mac/Sources/CodeBurnMenubar/Data/DataClient.swift b/mac/Sources/CodeBurnMenubar/Data/DataClient.swift
new file mode 100644
index 0000000..6e4dbeb
--- /dev/null
+++ b/mac/Sources/CodeBurnMenubar/Data/DataClient.swift
@@ -0,0 +1,107 @@
+import Foundation
+
+/// Upper bound on payload + stderr bytes read from the CLI. Real payloads top out near 500 KB
+/// (365 days of history with dozens of models); anything larger is pathological and truncating
+/// prevents unbounded memory growth. Hard timeout guards against a hung CLI keeping Process and
+/// Pipe file descriptors pinned forever.
+private let maxPayloadBytes = 20 * 1024 * 1024
+private let maxStderrBytes = 256 * 1024
+private let spawnTimeoutSeconds: UInt64 = 60
+
+enum DataClientError: Error {
+ case spawn(String)
+ case nonZeroExit(code: Int32, stderr: String)
+ case decode(Error)
+ case timeout
+ case outputTooLarge
+}
+
+/// Runs the CLI via argv (no shell interpretation). See `CodeburnCLI` for why we never route
+/// commands through `/bin/zsh -c` anymore.
+struct DataClient {
+ static func fetch(period: Period, provider: ProviderFilter, includeOptimize: Bool) async throws -> MenubarPayload {
+ var subcommand = [
+ "status",
+ "--format", "menubar-json",
+ "--period", period.cliArg,
+ "--provider", provider.cliArg,
+ ]
+ if !includeOptimize {
+ subcommand.append("--no-optimize")
+ }
+
+ let result = try await runCLI(subcommand: subcommand)
+ guard result.exitCode == 0 else {
+ throw DataClientError.nonZeroExit(code: result.exitCode, stderr: result.stderr)
+ }
+ do {
+ return try JSONDecoder().decode(MenubarPayload.self, from: result.stdout)
+ } catch {
+ throw DataClientError.decode(error)
+ }
+ }
+
+ private struct ProcessResult {
+ let stdout: Data
+ let stderr: String
+ let exitCode: Int32
+ }
+
+ private static func runCLI(subcommand: [String]) async throws -> ProcessResult {
+ let process = CodeburnCLI.makeProcess(subcommand: subcommand)
+
+ let outPipe = Pipe()
+ let errPipe = Pipe()
+ process.standardOutput = outPipe
+ process.standardError = errPipe
+
+ do {
+ try process.run()
+ } catch {
+ throw DataClientError.spawn(error.localizedDescription)
+ }
+
+ // Drain both pipes concurrently so a large stderr can't deadlock stdout (the child
+ // blocks on write once the pipe buffer fills). `drain` also enforces a byte cap.
+ async let stdoutData = drain(outPipe.fileHandleForReading, limit: maxPayloadBytes)
+ async let stderrData = drain(errPipe.fileHandleForReading, limit: maxStderrBytes)
+
+ // Wall-clock timeout: if the CLI hangs (parser stuck, disk stall), kill it.
+ let timeoutTask = Task.detached(priority: .utility) {
+ try? await Task.sleep(nanoseconds: spawnTimeoutSeconds * 1_000_000_000)
+ if process.isRunning {
+ process.terminate()
+ }
+ }
+ defer { timeoutTask.cancel() }
+
+ let (out, err) = await (stdoutData, stderrData)
+ process.waitUntilExit()
+
+ if out.count >= maxPayloadBytes {
+ throw DataClientError.outputTooLarge
+ }
+
+ let stderrString = String(data: err, encoding: .utf8) ?? ""
+ return ProcessResult(stdout: out, stderr: stderrString, exitCode: process.terminationStatus)
+ }
+
+ /// Pulls bytes off a pipe until EOF or `limit`. Intentionally uses `availableData`, which
+ /// returns empty on EOF -- no blocking once the child exits.
+ private static func drain(_ handle: FileHandle, limit: Int) async -> Data {
+ await Task.detached(priority: .utility) {
+ var buffer = Data()
+ while buffer.count < limit {
+ let chunk = handle.availableData
+ if chunk.isEmpty { break }
+ let remaining = limit - buffer.count
+ if chunk.count > remaining {
+ buffer.append(chunk.prefix(remaining))
+ break
+ }
+ buffer.append(chunk)
+ }
+ return buffer
+ }.value
+ }
+}
diff --git a/mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift b/mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift
new file mode 100644
index 0000000..2e44fae
--- /dev/null
+++ b/mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift
@@ -0,0 +1,123 @@
+import Foundation
+
+/// Shape of `codeburn status --format menubar-json --period `.
+/// `current` is scoped to the requested period; the whole payload reflects that slice.
+struct MenubarPayload: Codable, Sendable {
+ let generated: String
+ let current: CurrentBlock
+ let optimize: OptimizeBlock
+ let history: HistoryBlock
+}
+
+struct HistoryBlock: Codable, Sendable {
+ let daily: [DailyHistoryEntry]
+}
+
+struct DailyModelBreakdown: Codable, Sendable {
+ let name: String
+ let cost: Double
+ let calls: Int
+ let inputTokens: Int
+ let outputTokens: Int
+
+ var totalTokens: Int { inputTokens + outputTokens }
+}
+
+struct DailyHistoryEntry: Codable, Sendable {
+ let date: String
+ let cost: Double
+ let calls: Int
+ let inputTokens: Int
+ let outputTokens: Int
+ let cacheReadTokens: Int
+ let cacheWriteTokens: Int
+ let topModels: [DailyModelBreakdown]
+
+ /// Pricing-ratio prior: input + 5x output + cache_creation + 0.1x cache_read.
+ /// Matches Anthropic's published per-token pricing on Sonnet/Opus closely enough to be a useful proxy.
+ var effectiveTokens: Double {
+ Double(inputTokens) + 5.0 * Double(outputTokens) + Double(cacheWriteTokens) + 0.1 * Double(cacheReadTokens)
+ }
+}
+
+extension DailyHistoryEntry {
+ /// Required for legacy payloads (no topModels emitted yet).
+ enum CodingKeys: String, CodingKey {
+ case date, cost, calls, inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, topModels
+ }
+ init(from decoder: Decoder) throws {
+ let c = try decoder.container(keyedBy: CodingKeys.self)
+ date = try c.decode(String.self, forKey: .date)
+ cost = try c.decode(Double.self, forKey: .cost)
+ calls = try c.decode(Int.self, forKey: .calls)
+ inputTokens = try c.decode(Int.self, forKey: .inputTokens)
+ outputTokens = try c.decode(Int.self, forKey: .outputTokens)
+ cacheReadTokens = try c.decode(Int.self, forKey: .cacheReadTokens)
+ cacheWriteTokens = try c.decode(Int.self, forKey: .cacheWriteTokens)
+ topModels = try c.decodeIfPresent([DailyModelBreakdown].self, forKey: .topModels) ?? []
+ }
+}
+
+struct CurrentBlock: Codable, Sendable {
+ let label: String
+ let cost: Double
+ let calls: Int
+ let sessions: Int
+ let oneShotRate: Double?
+ let inputTokens: Int
+ let outputTokens: Int
+ let cacheHitPercent: Double
+ let topActivities: [ActivityEntry]
+ let topModels: [ModelEntry]
+ let providers: [String: Double]
+}
+
+struct ActivityEntry: Codable, Sendable {
+ let name: String
+ let cost: Double
+ let turns: Int
+ let oneShotRate: Double?
+}
+
+struct ModelEntry: Codable, Sendable {
+ let name: String
+ let cost: Double
+ let calls: Int
+}
+
+struct OptimizeBlock: Codable, Sendable {
+ let findingCount: Int
+ let savingsUSD: Double
+ let topFindings: [FindingEntry]
+}
+
+struct FindingEntry: Codable, Sendable {
+ let title: String
+ let impact: String
+ let savingsUSD: Double
+}
+
+// MARK: - Empty fallback
+
+extension MenubarPayload {
+ /// Strictly-empty payload. Used as the fallback before real data arrives, so no
+ /// plausible-looking fake numbers leak into the UI.
+ static let empty = MenubarPayload(
+ generated: "",
+ current: CurrentBlock(
+ label: "",
+ cost: 0,
+ calls: 0,
+ sessions: 0,
+ oneShotRate: nil,
+ inputTokens: 0,
+ outputTokens: 0,
+ cacheHitPercent: 0,
+ topActivities: [],
+ topModels: [],
+ providers: [:]
+ ),
+ optimize: OptimizeBlock(findingCount: 0, savingsUSD: 0, topFindings: []),
+ history: HistoryBlock(daily: [])
+ )
+}
diff --git a/mac/Sources/CodeBurnMenubar/Data/SubscriptionClient.swift b/mac/Sources/CodeBurnMenubar/Data/SubscriptionClient.swift
new file mode 100644
index 0000000..79c5794
--- /dev/null
+++ b/mac/Sources/CodeBurnMenubar/Data/SubscriptionClient.swift
@@ -0,0 +1,306 @@
+import Foundation
+import Security
+
+private let credentialsRelativePath = ".claude/.credentials.json"
+private let keychainService = "Claude Code-credentials"
+private let oauthClientID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
+private let refreshURL = URL(string: "https://platform.claude.com/v1/oauth/token")!
+private let usageURL = URL(string: "https://api.anthropic.com/api/oauth/usage")!
+private let betaHeader = "oauth-2025-04-20"
+private let userAgent = "claude-code/2.1.0"
+private let requestTimeout: TimeInterval = 30
+
+/// Claude Code writes Keychain items with `kSecAttrAccount = "default"`. Filtering on this
+/// prevents a planted Keychain item from another app (or a stale install with a mangled
+/// account) from being accepted as our source of OAuth credentials.
+private let expectedKeychainAccounts: Set = ["default"]
+private let maxCredentialBytes = 64 * 1024
+
+enum SubscriptionError: Error, LocalizedError {
+ case noCredentials
+ case credentialsInvalid
+ case refreshFailed(Int, String?)
+ case usageFetchFailed(Int, String?)
+ case decodeFailed(Error)
+
+ var errorDescription: String? {
+ switch self {
+ case .noCredentials: "No Claude OAuth credentials found"
+ case .credentialsInvalid: "Claude OAuth credentials malformed"
+ case let .refreshFailed(code, body): "Token refresh failed (\(code))\(body.map { ": \($0)" } ?? "")"
+ case let .usageFetchFailed(code, body): "Usage fetch failed (\(code))\(body.map { ": \($0)" } ?? "")"
+ case let .decodeFailed(err): "Decode failed: \(err.localizedDescription)"
+ }
+ }
+}
+
+struct SubscriptionClient {
+ static func fetch() async throws -> SubscriptionUsage {
+ let creds = try loadCredentials()
+
+ // Try the usage call with the existing token first. Only refresh on 401.
+ do {
+ let response = try await fetchUsage(token: creds.accessToken)
+ return mapResponse(response, rawTier: creds.rateLimitTier)
+ } catch SubscriptionError.usageFetchFailed(401, _) {
+ guard let refreshToken = creds.refreshToken, !refreshToken.isEmpty else {
+ throw SubscriptionError.usageFetchFailed(401, "no refresh token available")
+ }
+ let newToken = try await refreshAccessToken(refreshToken: refreshToken)
+ let response = try await fetchUsage(token: newToken)
+ return mapResponse(response, rawTier: creds.rateLimitTier)
+ }
+ }
+
+ // MARK: - Credentials
+
+ private static func loadCredentials() throws -> StoredCredentials {
+ if let data = try readFileCredentials() {
+ return try parseCredentials(data: sanitizeKeychainData(data))
+ }
+ if let creds = try readKeychainCredentials() {
+ return creds
+ }
+ throw SubscriptionError.noCredentials
+ }
+
+ private static func readFileCredentials() throws -> Data? {
+ let url = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(credentialsRelativePath)
+ guard FileManager.default.fileExists(atPath: url.path) else { return nil }
+ // SafeFile refuses to follow symlinks and caps the read, so a 6 GB /dev/urandom
+ // masquerading as the creds file can't blow up the app.
+ return try SafeFile.read(from: url.path, maxBytes: maxCredentialBytes)
+ }
+
+ /// Two-phase keychain enumeration: (1) list persistent refs + accounts, (2) fetch each
+ /// item's data by ref. The combination kSecMatchLimitAll + kSecReturnData errors with -50,
+ /// so the data fetch has to be per-item.
+ private static func readKeychainCredentials() throws -> StoredCredentials? {
+ let listQuery: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: keychainService,
+ kSecMatchLimit as String: kSecMatchLimitAll,
+ kSecReturnAttributes as String: true,
+ kSecReturnPersistentRef as String: true,
+ ]
+ var listResult: CFTypeRef?
+ let listStatus = SecItemCopyMatching(listQuery as CFDictionary, &listResult)
+ if listStatus == errSecItemNotFound {
+ NSLog("CodeBurn: keychain query found no items for service \(keychainService)")
+ return nil
+ }
+ guard listStatus == errSecSuccess, let rows = listResult as? [[String: Any]] else {
+ NSLog("CodeBurn: keychain enumerate failed status=\(listStatus)")
+ return nil
+ }
+
+ var best: StoredCredentials? = nil
+ for row in rows {
+ guard let ref = row[kSecValuePersistentRef as String] as? Data else { continue }
+ let account = (row[kSecAttrAccount as String] as? String) ?? ""
+ // Ignore rows whose account doesn't match Claude Code's known writer. Stops another
+ // app's item (or a legacy install with an unexpected account) from being accepted.
+ guard expectedKeychainAccounts.contains(account) else { continue }
+ let dataQuery: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecValuePersistentRef as String: ref,
+ kSecMatchLimit as String: kSecMatchLimitOne,
+ kSecReturnData as String: true,
+ ]
+ var dataResult: CFTypeRef?
+ let dataStatus = SecItemCopyMatching(dataQuery as CFDictionary, &dataResult)
+ guard dataStatus == errSecSuccess, let data = dataResult as? Data else { continue }
+ let sanitized = sanitizeKeychainData(data)
+ guard let parsed = try? parseCredentials(data: sanitized) else { continue }
+ if let current = best {
+ if (parsed.expiresAt ?? .distantPast) > (current.expiresAt ?? .distantPast) {
+ best = parsed
+ }
+ } else {
+ best = parsed
+ }
+ }
+ return best
+ }
+
+ /// Claude Code's keychain writer line-wraps long string values (newline + leading spaces)
+ /// mid-token, producing JSON with literal control chars and stray spaces inside string
+ /// values. Replace every newline (CR/LF) plus the run of spaces/tabs that follows it.
+ /// Drops both the wrapping in tokens AND pretty-print indentation between fields (both
+ /// produce valid, compact JSON afterward).
+ private static func sanitizeKeychainData(_ data: Data) -> Data {
+ guard var s = String(data: data, encoding: .utf8) else { return data }
+ s = s.replacingOccurrences(of: "\r", with: "")
+ let regex = try? NSRegularExpression(pattern: "\\n[ \\t]*", options: [])
+ if let regex {
+ let range = NSRange(s.startIndex.. StoredCredentials {
+ do {
+ let root = try JSONDecoder().decode(CredentialsRoot.self, from: data)
+ guard let oauth = root.claudeAiOauth else { throw SubscriptionError.credentialsInvalid }
+ let token = oauth.accessToken?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+ guard !token.isEmpty else { throw SubscriptionError.credentialsInvalid }
+ let expiresAt = oauth.expiresAt.map { Date(timeIntervalSince1970: $0 / 1000.0) }
+ return StoredCredentials(
+ accessToken: token,
+ refreshToken: oauth.refreshToken,
+ expiresAt: expiresAt,
+ rateLimitTier: oauth.rateLimitTier
+ )
+ } catch let err as SubscriptionError {
+ throw err
+ } catch {
+ throw SubscriptionError.decodeFailed(error)
+ }
+ }
+
+ // MARK: - Refresh
+
+ private static func refreshAccessToken(refreshToken: String) async throws -> String {
+ var request = URLRequest(url: refreshURL)
+ request.httpMethod = "POST"
+ request.timeoutInterval = requestTimeout
+ request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
+ request.setValue("application/json", forHTTPHeaderField: "Accept")
+ var components = URLComponents()
+ components.queryItems = [
+ URLQueryItem(name: "grant_type", value: "refresh_token"),
+ URLQueryItem(name: "refresh_token", value: refreshToken),
+ URLQueryItem(name: "client_id", value: oauthClientID),
+ ]
+ request.httpBody = (components.percentEncodedQuery ?? "").data(using: .utf8)
+
+ let (data, response) = try await URLSession.shared.data(for: request)
+ guard let http = response as? HTTPURLResponse else {
+ throw SubscriptionError.refreshFailed(-1, nil)
+ }
+ guard http.statusCode == 200 else {
+ let body = String(data: data, encoding: .utf8)
+ throw SubscriptionError.refreshFailed(http.statusCode, body)
+ }
+ do {
+ let decoded = try JSONDecoder().decode(TokenRefreshResponse.self, from: data)
+ return decoded.accessToken
+ } catch {
+ throw SubscriptionError.decodeFailed(error)
+ }
+ }
+
+ // MARK: - Usage fetch
+
+ private static func fetchUsage(token: String) async throws -> UsageResponse {
+ var request = URLRequest(url: usageURL)
+ request.httpMethod = "GET"
+ request.timeoutInterval = requestTimeout
+ request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
+ request.setValue("application/json", forHTTPHeaderField: "Accept")
+ request.setValue(betaHeader, forHTTPHeaderField: "anthropic-beta")
+ request.setValue(userAgent, forHTTPHeaderField: "User-Agent")
+
+ let (data, response) = try await URLSession.shared.data(for: request)
+ guard let http = response as? HTTPURLResponse else {
+ throw SubscriptionError.usageFetchFailed(-1, nil)
+ }
+ guard http.statusCode == 200 else {
+ let body = String(data: data, encoding: .utf8)
+ throw SubscriptionError.usageFetchFailed(http.statusCode, body)
+ }
+ do {
+ return try JSONDecoder().decode(UsageResponse.self, from: data)
+ } catch {
+ throw SubscriptionError.decodeFailed(error)
+ }
+ }
+
+ // MARK: - Mapping
+
+ private static func mapResponse(_ r: UsageResponse, rawTier: String?) -> SubscriptionUsage {
+ SubscriptionUsage(
+ tier: SubscriptionUsage.tier(from: rawTier),
+ rawTier: rawTier,
+ fiveHourPercent: r.fiveHour?.utilization,
+ fiveHourResetsAt: parseDate(r.fiveHour?.resetsAt),
+ sevenDayPercent: r.sevenDay?.utilization,
+ sevenDayResetsAt: parseDate(r.sevenDay?.resetsAt),
+ sevenDayOpusPercent: r.sevenDayOpus?.utilization,
+ sevenDayOpusResetsAt: parseDate(r.sevenDayOpus?.resetsAt),
+ sevenDaySonnetPercent: r.sevenDaySonnet?.utilization,
+ sevenDaySonnetResetsAt: parseDate(r.sevenDaySonnet?.resetsAt),
+ fetchedAt: Date()
+ )
+ }
+
+ private static func parseDate(_ s: String?) -> Date? {
+ guard let s, !s.isEmpty else { return nil }
+ let f = ISO8601DateFormatter()
+ f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+ if let d = f.date(from: s) { return d }
+ f.formatOptions = [.withInternetDateTime]
+ return f.date(from: s)
+ }
+}
+
+// MARK: - Internal models
+
+private struct StoredCredentials {
+ let accessToken: String
+ let refreshToken: String?
+ let expiresAt: Date?
+ let rateLimitTier: String?
+}
+
+private struct CredentialsRoot: Decodable {
+ let claudeAiOauth: OAuthBlock?
+}
+
+private struct OAuthBlock: Decodable {
+ let accessToken: String?
+ let refreshToken: String?
+ let expiresAt: Double?
+ let rateLimitTier: String?
+}
+
+private struct TokenRefreshResponse: Decodable {
+ let accessToken: String
+ let refreshToken: String?
+ let expiresIn: Int?
+
+ enum CodingKeys: String, CodingKey {
+ case accessToken = "access_token"
+ case refreshToken = "refresh_token"
+ case expiresIn = "expires_in"
+ }
+}
+
+private struct UsageResponse: Decodable {
+ let fiveHour: Window?
+ let sevenDay: Window?
+ let sevenDayOpus: Window?
+ let sevenDaySonnet: Window?
+
+ enum CodingKeys: String, CodingKey {
+ case fiveHour = "five_hour"
+ case sevenDay = "seven_day"
+ case sevenDayOpus = "seven_day_opus"
+ case sevenDaySonnet = "seven_day_sonnet"
+ }
+}
+
+private struct Window: Decodable {
+ let utilization: Double?
+ let resetsAt: String?
+
+ enum CodingKeys: String, CodingKey {
+ case utilization
+ case resetsAt = "resets_at"
+ }
+}
diff --git a/mac/Sources/CodeBurnMenubar/Data/SubscriptionSnapshotStore.swift b/mac/Sources/CodeBurnMenubar/Data/SubscriptionSnapshotStore.swift
new file mode 100644
index 0000000..931154a
--- /dev/null
+++ b/mac/Sources/CodeBurnMenubar/Data/SubscriptionSnapshotStore.swift
@@ -0,0 +1,102 @@
+import Foundation
+
+/// Persisted snapshot of a single utilization reading. We capture one per window every time
+/// SubscriptionClient.fetch() succeeds so we can answer "what did the prior 7-day cycle finish at?"
+/// when the current window has no usable data yet (just reset).
+struct SubscriptionSnapshot: Codable, Sendable {
+ let windowKey: String // "five_hour", "seven_day", "seven_day_opus", "seven_day_sonnet"
+ let percent: Double // 0..100
+ let resetsAt: Date // resets_at active at capture (identifies which window cycle this belongs to)
+ let capturedAt: Date // when the snapshot was recorded
+ let effectiveTokens: Double? // tokens consumed in window at capture (nil if not computed)
+}
+
+private let snapshotFilename = "subscription-snapshots.json"
+private let pruneOlderThanSeconds: TimeInterval = 30 * 24 * 3600
+
+private func snapshotsCacheDir() -> String {
+ return ProcessInfo.processInfo.environment["CODEBURN_CACHE_DIR"]
+ ?? (NSHomeDirectory() as NSString).appendingPathComponent(".cache/codeburn")
+}
+
+private func snapshotsPath() -> String {
+ return (snapshotsCacheDir() as NSString).appendingPathComponent(snapshotFilename)
+}
+
+private actor SnapshotLock {
+ static let shared = SnapshotLock()
+ func run(_ fn: () throws -> T) rethrows -> T { try fn() }
+}
+
+enum SubscriptionSnapshotStore {
+ /// Append a snapshot. Auto-prunes entries older than 30 days. Idempotent: if a snapshot
+ /// with the same windowKey + resetsAt already exists, only update percent if new is higher
+ /// (so "final" reading near reset is preserved).
+ static func record(_ snapshot: SubscriptionSnapshot) async {
+ await SnapshotLock.shared.run {
+ do {
+ var all = loadAll()
+ let key = "\(snapshot.windowKey)|\(snapshot.resetsAt.timeIntervalSince1970)"
+ if let idx = all.firstIndex(where: { "\($0.windowKey)|\($0.resetsAt.timeIntervalSince1970)" == key }) {
+ if snapshot.percent > all[idx].percent {
+ all[idx] = snapshot
+ }
+ } else {
+ all.append(snapshot)
+ }
+ let cutoff = Date().addingTimeInterval(-pruneOlderThanSeconds)
+ all = all.filter { $0.capturedAt >= cutoff }
+ try save(all)
+ } catch {
+ NSLog("CodeBurn: snapshot record failed: \(error)")
+ }
+ }
+ }
+
+ /// Returns the final percent of the immediately-prior cycle for this window, or nil if no
+ /// prior data is available. Logic: among snapshots whose resetsAt < currentResetsAt, pick
+ /// the group with the largest resetsAt (most recent prior cycle), then return the max
+ /// percent in that group (the closest-to-final reading we have).
+ static func previousWindowFinal(windowKey: String, currentResetsAt: Date) async -> Double? {
+ await SnapshotLock.shared.run {
+ let all = loadAll()
+ let priors = all.filter { $0.windowKey == windowKey && $0.resetsAt < currentResetsAt }
+ guard let mostRecentPriorReset = priors.map({ $0.resetsAt }).max() else { return nil }
+ let priorWindow = priors.filter { $0.resetsAt == mostRecentPriorReset }
+ return priorWindow.map(\.percent).max()
+ }
+ }
+
+ /// Return all snapshots for a given window key, useful for capacity estimation.
+ static func snapshots(for windowKey: String) async -> [SubscriptionSnapshot] {
+ await SnapshotLock.shared.run {
+ loadAll().filter { $0.windowKey == windowKey }
+ }
+ }
+
+ /// Test seam: clear all snapshots.
+ static func resetForTesting() async {
+ await SnapshotLock.shared.run {
+ try? FileManager.default.removeItem(atPath: snapshotsPath())
+ }
+ }
+
+ // MARK: - Internals
+
+ private static func loadAll() -> [SubscriptionSnapshot] {
+ let path = snapshotsPath()
+ guard FileManager.default.fileExists(atPath: path) else { return [] }
+ guard let data = try? SafeFile.read(from: path) else { return [] }
+ let decoder = JSONDecoder()
+ decoder.dateDecodingStrategy = .iso8601
+ return (try? decoder.decode([SubscriptionSnapshot].self, from: data)) ?? []
+ }
+
+ private static func save(_ snapshots: [SubscriptionSnapshot]) throws {
+ let encoder = JSONEncoder()
+ encoder.dateEncodingStrategy = .iso8601
+ let data = try encoder.encode(snapshots)
+ // SafeFile.write refuses symlinked targets and does the tmp+rename atomic dance.
+ try SafeFile.write(data, to: snapshotsPath(), mode: 0o600)
+ }
+}
diff --git a/mac/Sources/CodeBurnMenubar/Data/SubscriptionUsage.swift b/mac/Sources/CodeBurnMenubar/Data/SubscriptionUsage.swift
new file mode 100644
index 0000000..350983f
--- /dev/null
+++ b/mac/Sources/CodeBurnMenubar/Data/SubscriptionUsage.swift
@@ -0,0 +1,46 @@
+import Foundation
+
+struct SubscriptionUsage: Sendable, Equatable {
+ enum Tier: String, Sendable, Equatable {
+ case pro
+ case max5x
+ case max20x
+ case team
+ case enterprise
+ case unknown
+
+ var displayName: String {
+ switch self {
+ case .pro: "Pro"
+ case .max5x: "Max 5x"
+ case .max20x: "Max 20x"
+ case .team: "Team"
+ case .enterprise: "Enterprise"
+ case .unknown: "Subscription"
+ }
+ }
+ }
+
+ let tier: Tier
+ let rawTier: String?
+ let fiveHourPercent: Double?
+ let fiveHourResetsAt: Date?
+ let sevenDayPercent: Double?
+ let sevenDayResetsAt: Date?
+ let sevenDayOpusPercent: Double?
+ let sevenDayOpusResetsAt: Date?
+ let sevenDaySonnetPercent: Double?
+ let sevenDaySonnetResetsAt: Date?
+ let fetchedAt: Date
+
+ static func tier(from raw: String?) -> Tier {
+ guard let raw = raw?.lowercased() else { return .unknown }
+ if raw.contains("max_20x") || raw.contains("max20x") || raw.contains("max-20x") { return .max20x }
+ if raw.contains("max_5x") || raw.contains("max5x") || raw.contains("max-5x") { return .max5x }
+ if raw.contains("max") { return .max5x }
+ if raw.contains("pro") { return .pro }
+ if raw.contains("team") { return .team }
+ if raw.contains("enterprise") { return .enterprise }
+ return .unknown
+ }
+}
diff --git a/mac/Sources/CodeBurnMenubar/Security/CodeburnCLI.swift b/mac/Sources/CodeBurnMenubar/Security/CodeburnCLI.swift
new file mode 100644
index 0000000..d86987a
--- /dev/null
+++ b/mac/Sources/CodeBurnMenubar/Security/CodeburnCLI.swift
@@ -0,0 +1,59 @@
+import Foundation
+
+/// Single entry point for spawning the `codeburn` CLI. All callers route through here so the
+/// binary argv is validated once and no code path ever passes user-influenced strings through
+/// a shell (`/bin/zsh -c`, `open --args`, AppleScript). This closes the shell-injection attack
+/// surface end-to-end.
+enum CodeburnCLI {
+ /// Matches a plain file path / program name: alphanumerics, dot, underscore, slash, hyphen,
+ /// space. Deliberately excludes shell metacharacters (`$`, `;`, `&`, `|`, quotes, backticks,
+ /// newlines) so a malicious `CODEBURN_BIN="codeburn; rm -rf ~"` can't slip through.
+ private static let safeArgPattern = try! NSRegularExpression(pattern: "^[A-Za-z0-9 ._/\\-]+$")
+
+ /// PATH additions for GUI-launched apps, which otherwise get a minimal PATH that misses
+ /// Homebrew and npm global installs.
+ private static let additionalPathEntries = ["/opt/homebrew/bin", "/usr/local/bin"]
+
+ /// Returns the argv that launches the CLI. Dev override via `CODEBURN_BIN` is honoured only
+ /// if every whitespace-delimited token passes `safeArgPattern`. Otherwise falls back to the
+ /// plain `codeburn` name (resolved via PATH).
+ static func baseArgv() -> [String] {
+ guard let raw = ProcessInfo.processInfo.environment["CODEBURN_BIN"], !raw.isEmpty else {
+ return ["codeburn"]
+ }
+ let parts = raw.split(separator: " ", omittingEmptySubsequences: true).map(String.init)
+ guard parts.allSatisfy(isSafe) else {
+ NSLog("CodeBurn: refusing unsafe CODEBURN_BIN; using default 'codeburn'")
+ return ["codeburn"]
+ }
+ return parts
+ }
+
+ /// Builds a `Process` that runs the CLI with the given subcommand args. Uses `/usr/bin/env`
+ /// so PATH lookup happens without involving a shell, and augments PATH with Homebrew
+ /// defaults. Caller sets stdout/stderr pipes and calls `run()`.
+ static func makeProcess(subcommand: [String]) -> Process {
+ let process = Process()
+ process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
+ var environment = ProcessInfo.processInfo.environment
+ environment["PATH"] = augmentedPath(environment["PATH"] ?? "")
+ process.environment = environment
+ // `env --` treats everything following as argv, not VAR=val pairs -- guards against an
+ // argument accidentally resembling an env assignment.
+ process.arguments = ["--"] + baseArgv() + subcommand
+ return process
+ }
+
+ static func isSafe(_ s: String) -> Bool {
+ let range = NSRange(s.startIndex.. String {
+ var parts = existing.split(separator: ":", omittingEmptySubsequences: true).map(String.init)
+ for extra in additionalPathEntries where !parts.contains(extra) {
+ parts.append(extra)
+ }
+ return parts.joined(separator: ":")
+ }
+}
diff --git a/mac/Sources/CodeBurnMenubar/Security/SafeFile.swift b/mac/Sources/CodeBurnMenubar/Security/SafeFile.swift
new file mode 100644
index 0000000..3d6bda5
--- /dev/null
+++ b/mac/Sources/CodeBurnMenubar/Security/SafeFile.swift
@@ -0,0 +1,128 @@
+import Foundation
+
+/// Symlink-safe file I/O with atomic writes and optional cross-process flock.
+///
+/// Every cache file we touch (`~/Library/Caches/codeburn-mac/fx-rates.json`,
+/// `~/.cache/codeburn/subscription-snapshots.json`, `~/.config/codeburn/config.json`) is a
+/// legitimate target for a local-symlink attack: if an attacker plants a symlink from one of
+/// those paths to, say, `~/.ssh/config`, a naive `Data.write(to:)` blindly follows the link and
+/// clobbers the real file. `O_NOFOLLOW` on the write() refuses the operation instead.
+enum SafeFile {
+ enum Error: Swift.Error {
+ case symlinkDetected(String)
+ case openFailed(String, Int32)
+ case writeFailed(String, Int32)
+ case renameFailed(String, Int32)
+ case readFailed(String, Int32)
+ case sizeLimitExceeded(String, Int)
+ }
+
+ /// Default max bytes when reading untrusted cache files. Prevents a malicious cache file
+ /// from exhausting memory in the Swift process.
+ static let defaultReadLimit = 8 * 1024 * 1024
+
+ /// Refuses to follow symlinks and writes atomically via a tmp file + rename. `mode` is the
+ /// final file permission (0o600 by default so cache files stay user-private).
+ static func write(_ data: Data, to path: String, mode: mode_t = 0o600) throws {
+ let parent = (path as NSString).deletingLastPathComponent
+ try FileManager.default.createDirectory(
+ atPath: parent,
+ withIntermediateDirectories: true,
+ attributes: [.posixPermissions: NSNumber(value: 0o700)]
+ )
+
+ // Reject if the existing file is a symlink. We use lstat so the link itself is
+ // inspected, not its target.
+ var linkInfo = stat()
+ if lstat(path, &linkInfo) == 0, (linkInfo.st_mode & S_IFMT) == S_IFLNK {
+ throw Error.symlinkDetected(path)
+ }
+
+ let tmpPath = parent + "/.codeburn-" + UUID().uuidString + ".tmp"
+ let flags: Int32 = O_CREAT | O_WRONLY | O_EXCL | O_NOFOLLOW
+ let fd = Darwin.open(tmpPath, flags, mode)
+ guard fd >= 0 else {
+ throw Error.openFailed(tmpPath, errno)
+ }
+
+ let writeResult: Int = data.withUnsafeBytes { buffer -> Int in
+ guard let base = buffer.baseAddress else { return 0 }
+ return Darwin.write(fd, base, buffer.count)
+ }
+ let writeErrno = errno
+ fsync(fd)
+ Darwin.close(fd)
+
+ guard writeResult == data.count else {
+ unlink(tmpPath)
+ throw Error.writeFailed(tmpPath, writeErrno)
+ }
+
+ if rename(tmpPath, path) != 0 {
+ let renameErrno = errno
+ unlink(tmpPath)
+ throw Error.renameFailed(path, renameErrno)
+ }
+ }
+
+ /// Refuses to read through a symlink. `maxBytes` bounds the read so a tampered cache file
+ /// can't balloon the process.
+ static func read(from path: String, maxBytes: Int = defaultReadLimit) throws -> Data {
+ var linkInfo = stat()
+ guard lstat(path, &linkInfo) == 0 else {
+ throw Error.readFailed(path, errno)
+ }
+ if (linkInfo.st_mode & S_IFMT) == S_IFLNK {
+ throw Error.symlinkDetected(path)
+ }
+
+ let fd = Darwin.open(path, O_RDONLY | O_NOFOLLOW)
+ guard fd >= 0 else {
+ throw Error.readFailed(path, errno)
+ }
+ defer { Darwin.close(fd) }
+
+ let size = Int(linkInfo.st_size)
+ if size > maxBytes {
+ throw Error.sizeLimitExceeded(path, size)
+ }
+
+ var data = Data(count: size)
+ let readBytes: Int = data.withUnsafeMutableBytes { buffer -> Int in
+ guard let base = buffer.baseAddress else { return 0 }
+ return Darwin.read(fd, base, buffer.count)
+ }
+ guard readBytes >= 0 else {
+ throw Error.readFailed(path, errno)
+ }
+ if readBytes < size {
+ data = data.prefix(readBytes)
+ }
+ return data
+ }
+
+ /// Runs `body` while holding an exclusive POSIX advisory lock on `path`. The lock file is
+ /// created if missing (with 0o600 permissions) and released on scope exit, so other
+ /// codeburn processes (the CLI running in a terminal, say) block on the same file instead
+ /// of racing on a shared config.
+ static func withExclusiveLock(at path: String, body: () throws -> T) throws -> T {
+ let parent = (path as NSString).deletingLastPathComponent
+ try FileManager.default.createDirectory(
+ atPath: parent,
+ withIntermediateDirectories: true,
+ attributes: [.posixPermissions: NSNumber(value: 0o700)]
+ )
+ let fd = Darwin.open(path, O_CREAT | O_RDWR | O_NOFOLLOW, 0o600)
+ guard fd >= 0 else {
+ throw Error.openFailed(path, errno)
+ }
+ defer { Darwin.close(fd) }
+
+ guard flock(fd, LOCK_EX) == 0 else {
+ throw Error.openFailed(path, errno)
+ }
+ defer { _ = flock(fd, LOCK_UN) }
+
+ return try body()
+ }
+}
diff --git a/mac/Sources/CodeBurnMenubar/Security/TerminalLauncher.swift b/mac/Sources/CodeBurnMenubar/Security/TerminalLauncher.swift
new file mode 100644
index 0000000..09f8dad
--- /dev/null
+++ b/mac/Sources/CodeBurnMenubar/Security/TerminalLauncher.swift
@@ -0,0 +1,40 @@
+import AppKit
+import Foundation
+
+/// Opens a codeburn subcommand in the user's Terminal. The argv is validated through
+/// `CodeburnCLI.isSafe` before it's interpolated into AppleScript so there's no path for a
+/// rogue environment variable to smuggle shell metacharacters into the `do script` call.
+/// Falls back to a detached headless spawn on machines without Terminal.app (iTerm/Ghostty/Warp
+/// users) so the subcommand still runs.
+enum TerminalLauncher {
+ private static let terminalPaths = [
+ "/System/Applications/Utilities/Terminal.app",
+ "/Applications/Utilities/Terminal.app",
+ ]
+
+ static func open(subcommand: [String]) {
+ let argv = CodeburnCLI.baseArgv() + subcommand
+ guard argv.allSatisfy(CodeburnCLI.isSafe) else {
+ NSLog("CodeBurn: refusing to open terminal with unsafe argv")
+ return
+ }
+ let command = argv.joined(separator: " ")
+
+ if terminalPaths.contains(where: FileManager.default.fileExists(atPath:)) {
+ let script = """
+ tell application "Terminal"
+ activate
+ do script "\(command)"
+ end tell
+ """
+ let process = Process()
+ process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
+ process.arguments = ["-e", script]
+ try? process.run()
+ return
+ }
+
+ let headless = CodeburnCLI.makeProcess(subcommand: subcommand)
+ try? headless.run()
+ }
+}
diff --git a/mac/Sources/CodeBurnMenubar/Theme/Theme.swift b/mac/Sources/CodeBurnMenubar/Theme/Theme.swift
new file mode 100644
index 0000000..de79860
--- /dev/null
+++ b/mac/Sources/CodeBurnMenubar/Theme/Theme.swift
@@ -0,0 +1,32 @@
+import SwiftUI
+
+/// Design tokens. Warm terracotta-ember palette, not generic orange.
+enum Theme {
+ static let brandAccent = Color(red: 0xC9/255.0, green: 0x52/255.0, blue: 0x1D/255.0)
+ static let brandAccentDark = Color(red: 0xE8/255.0, green: 0x77/255.0, blue: 0x4A/255.0)
+ static let brandEmberDeep = Color(red: 0x8B/255.0, green: 0x3E/255.0, blue: 0x13/255.0)
+ static let brandEmberGlow = Color(red: 0xF0/255.0, green: 0xA0/255.0, blue: 0x70/255.0)
+
+ static let warmSurface = Color(red: 0xFA/255.0, green: 0xF7/255.0, blue: 0xF3/255.0)
+ static let warmSurfaceDark = Color(red: 0x1C/255.0, green: 0x18/255.0, blue: 0x16/255.0)
+
+ static let categoricalClaude = Color(red: 0xC9/255.0, green: 0x52/255.0, blue: 0x1D/255.0)
+ static let categoricalCursor = Color(red: 0x3F/255.0, green: 0x6B/255.0, blue: 0x8C/255.0)
+ static let categoricalCodex = Color(red: 0x4A/255.0, green: 0x7D/255.0, blue: 0x5C/255.0)
+
+ static let oneShotGood = Color(red: 0x30/255.0, green: 0xD1/255.0, blue: 0x58/255.0)
+ static let oneShotMid = Color(red: 0xFF/255.0, green: 0x9F/255.0, blue: 0x0A/255.0)
+ static let oneShotLow = Color(red: 0xFF/255.0, green: 0x45/255.0, blue: 0x3A/255.0)
+
+ // Semantic colors -- tuned to sit alongside the terracotta accent without clashing.
+ static let semanticDanger = Color(red: 0xC8/255.0, green: 0x3F/255.0, blue: 0x2C/255.0) // brick-red, terracotta-leaning
+ static let semanticWarning = Color(red: 0xD9/255.0, green: 0x8F/255.0, blue: 0x29/255.0) // amber, warmer than vanilla
+ static let semanticSuccess = Color(red: 0x4E/255.0, green: 0xA8/255.0, blue: 0x65/255.0) // muted green that holds against terracotta
+}
+
+extension Font {
+ /// SF Mono for currency values -- developer-tool identity.
+ static func codeMono(size: CGFloat, weight: Font.Weight = .regular) -> Font {
+ .system(size: size, weight: weight, design: .monospaced)
+ }
+}
diff --git a/mac/Sources/CodeBurnMenubar/Views/ActivitySection.swift b/mac/Sources/CodeBurnMenubar/Views/ActivitySection.swift
new file mode 100644
index 0000000..9803387
--- /dev/null
+++ b/mac/Sources/CodeBurnMenubar/Views/ActivitySection.swift
@@ -0,0 +1,87 @@
+import SwiftUI
+
+struct ActivitySection: View {
+ @Environment(AppStore.self) private var store
+ @State private var isExpanded: Bool = true
+
+ var body: some View {
+ CollapsibleSection(
+ caption: "Activity",
+ isExpanded: $isExpanded,
+ trailing: {
+ HStack(spacing: 8) {
+ Text("Cost").frame(minWidth: 54, alignment: .trailing)
+ Text("Turns").frame(minWidth: 52, alignment: .trailing)
+ Text("1-shot").frame(minWidth: 44, alignment: .trailing)
+ }
+ .font(.system(size: 10, weight: .medium))
+ .foregroundStyle(.tertiary)
+ .tracking(-0.05)
+ }
+ ) {
+ VStack(alignment: .leading, spacing: 7) {
+ let maxCost = store.payload.current.topActivities.map(\.cost).max() ?? 1
+ ForEach(store.payload.current.topActivities, id: \.name) { activity in
+ ActivityRow(activity: activity, maxCost: maxCost)
+ }
+ }
+ }
+ }
+}
+
+struct ActivityRow: View {
+ let activity: ActivityEntry
+ let maxCost: Double
+
+ var body: some View {
+ HStack(spacing: 8) {
+ FixedBar(fraction: activity.cost / maxCost)
+ .frame(width: 56, height: 6)
+
+ Text(activity.name)
+ .font(.system(size: 12.5, weight: .medium))
+ .frame(maxWidth: .infinity, alignment: .leading)
+
+ Text(activity.cost.asCompactCurrency())
+ .font(.codeMono(size: 12, weight: .medium))
+ .tracking(-0.2)
+ .frame(minWidth: 54, alignment: .trailing)
+
+ Text("\(activity.turns)")
+ .font(.system(size: 11))
+ .monospacedDigit()
+ .foregroundStyle(.secondary)
+ .frame(minWidth: 52, alignment: .trailing)
+
+ Text(oneShotText)
+ .font(.system(size: 10.5))
+ .monospacedDigit()
+ .foregroundStyle(.secondary)
+ .frame(minWidth: 44, alignment: .trailing)
+ }
+ .padding(.horizontal, 2)
+ .padding(.vertical, 1)
+ }
+
+ private var oneShotText: String {
+ guard let rate = activity.oneShotRate else { return "—" }
+ return "\(Int(rate * 100))%"
+ }
+}
+
+/// Fixed-width horizontal bar that shows a fill fraction.
+struct FixedBar: View {
+ let fraction: Double
+
+ var body: some View {
+ GeometryReader { geo in
+ ZStack(alignment: .leading) {
+ RoundedRectangle(cornerRadius: 2)
+ .fill(.secondary.opacity(0.15))
+ RoundedRectangle(cornerRadius: 2)
+ .fill(Theme.brandAccent)
+ .frame(width: max(0, min(geo.size.width, geo.size.width * CGFloat(fraction))))
+ }
+ }
+ }
+}
diff --git a/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift b/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift
new file mode 100644
index 0000000..4feb180
--- /dev/null
+++ b/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift
@@ -0,0 +1,92 @@
+import SwiftUI
+
+struct AgentTabStrip: View {
+ @Environment(AppStore.self) private var store
+
+ var body: some View {
+ ScrollView(.horizontal, showsIndicators: false) {
+ HStack(spacing: 5) {
+ ForEach(visibleFilters) { filter in
+ Button {
+ Task { await store.switchTo(provider: filter) }
+ } label: {
+ AgentTab(
+ filter: filter,
+ cost: cost(for: filter),
+ isActive: store.selectedProvider == filter
+ )
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ .padding(.horizontal, 12)
+ .padding(.top, 8)
+ .padding(.bottom, 4)
+ }
+ }
+
+ /// Drive tab visibility and per-tab cost labels from the *all-provider* payload (today),
+ /// not the currently selected provider's payload. Without this, switching to Codex (which
+ /// has no data) would hide every other tab including Claude.
+ private var allProvidersToday: MenubarPayload {
+ store.todayPayload ?? store.payload
+ }
+
+ private var visibleFilters: [ProviderFilter] {
+ let activeKeys = Set(allProvidersToday.current.providers.keys.map { $0.lowercased() })
+ return ProviderFilter.allCases.filter { filter in
+ if filter == .all { return true }
+ return activeKeys.contains(filter.rawValue.lowercased())
+ }
+ }
+
+ private func cost(for filter: ProviderFilter) -> Double? {
+ switch filter {
+ case .all:
+ return allProvidersToday.current.cost
+ default:
+ let key = filter.rawValue.lowercased()
+ return allProvidersToday.current.providers[key]
+ }
+ }
+}
+
+private struct AgentTab: View {
+ let filter: ProviderFilter
+ let cost: Double?
+ let isActive: Bool
+
+ var body: some View {
+ HStack(spacing: 5) {
+ Text(filter.rawValue)
+ .font(.system(size: 11.5, weight: .medium))
+ .tracking(-0.05)
+ if let cost, cost > 0 {
+ Text(cost.asCompactCurrency())
+ .font(.codeMono(size: 10.5, weight: .medium))
+ .foregroundStyle(isActive ? AnyShapeStyle(.white.opacity(0.8)) : AnyShapeStyle(.secondary))
+ .tracking(-0.2)
+ }
+ }
+ .padding(.horizontal, 10)
+ .padding(.vertical, 4)
+ .background(
+ RoundedRectangle(cornerRadius: 6)
+ .fill(isActive ? AnyShapeStyle(Theme.brandAccent) : AnyShapeStyle(Color.secondary.opacity(0.08)))
+ )
+ .foregroundStyle(isActive ? AnyShapeStyle(.white) : AnyShapeStyle(.secondary))
+ .contentShape(Rectangle())
+ }
+}
+
+extension ProviderFilter {
+ var color: Color {
+ switch self {
+ case .all: return Theme.brandAccent
+ case .claude: return Theme.categoricalClaude
+ case .codex: return Theme.categoricalCodex
+ case .cursor: return Theme.categoricalCursor
+ case .copilot: return Color(red: 0x6D/255.0, green: 0x8F/255.0, blue: 0xA6/255.0)
+ }
+ }
+}
diff --git a/mac/Sources/CodeBurnMenubar/Views/FindingsSection.swift b/mac/Sources/CodeBurnMenubar/Views/FindingsSection.swift
new file mode 100644
index 0000000..3b31e76
--- /dev/null
+++ b/mac/Sources/CodeBurnMenubar/Views/FindingsSection.swift
@@ -0,0 +1,290 @@
+import SwiftUI
+
+private let winColor = Theme.brandAccent
+private let riskColor = Theme.brandAccent
+private let improveColor = Theme.brandAccent
+
+/// Three-category insights panel: wins, improvements, risks.
+/// Wins/risks are derived from current + history; improvements come from the optimize findings.
+struct FindingsSection: View {
+ @Environment(AppStore.self) private var store
+ @State private var isExpanded: Bool = true
+
+ var body: some View {
+ let groups = computeTipGroups(payload: store.payload)
+ if groups.allSatisfy({ $0.items.isEmpty }) { return AnyView(EmptyView()) }
+
+ return AnyView(
+ VStack(alignment: .leading, spacing: 8) {
+ Button {
+ withAnimation(.easeInOut(duration: 0.18)) { isExpanded.toggle() }
+ } label: {
+ HStack(alignment: .firstTextBaseline) {
+ HStack(spacing: 6) {
+ Image(systemName: "lightbulb.fill")
+ .font(.system(size: 11, weight: .semibold))
+ .foregroundStyle(Theme.brandAccent)
+ Text("Tips for you")
+ .font(.system(size: 12.5, weight: .semibold))
+ .foregroundStyle(.primary)
+ }
+ Spacer()
+ Text("\(groups.flatMap { $0.items }.count) signals")
+ .font(.system(size: 10.5))
+ .foregroundStyle(.secondary)
+ Image(systemName: "chevron.right")
+ .font(.system(size: 9, weight: .semibold))
+ .rotationEffect(.degrees(isExpanded ? 90 : 0))
+ .opacity(0.55)
+ .foregroundStyle(.secondary)
+ }
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+
+ if isExpanded {
+ VStack(alignment: .leading, spacing: 10) {
+ ForEach(groups) { group in
+ if !group.items.isEmpty {
+ TipsGroup(group: group)
+ }
+ }
+
+ if store.payload.optimize.findingCount > 0 {
+ Button {
+ openOptimize()
+ } label: {
+ HStack(spacing: 4) {
+ Text("Open Full Optimize")
+ .font(.system(size: 11.5, weight: .semibold))
+ Image(systemName: "arrow.forward")
+ .font(.system(size: 9, weight: .semibold))
+ }
+ .foregroundStyle(Theme.brandAccent)
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ .transition(.opacity)
+ }
+ }
+ .padding(12)
+ .background(
+ RoundedRectangle(cornerRadius: 8)
+ .fill(Color.secondary.opacity(0.06))
+ )
+ .padding(.horizontal, 14)
+ .padding(.vertical, 8)
+ )
+ }
+
+ private func openOptimize() {
+ TerminalLauncher.open(subcommand: ["optimize"])
+ }
+}
+
+private struct TipsGroup: View {
+ let group: TipGroup
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 5) {
+ HStack(spacing: 5) {
+ Image(systemName: group.icon)
+ .font(.system(size: 10, weight: .bold))
+ .foregroundStyle(group.color)
+ Text(group.label)
+ .font(.system(size: 10.5, weight: .semibold))
+ .foregroundStyle(group.color)
+ .textCase(.uppercase)
+ .tracking(0.4)
+ }
+ VStack(alignment: .leading, spacing: 4) {
+ ForEach(group.items) { item in
+ HStack(alignment: .firstTextBaseline, spacing: 6) {
+ Circle().fill(group.color).frame(width: 3, height: 3).padding(.top, 4)
+ Text(item.text)
+ .font(.system(size: 11.5))
+ .foregroundStyle(.primary)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ if let trailing = item.trailing {
+ Text(trailing)
+ .font(.codeMono(size: 11, weight: .medium))
+ .foregroundStyle(.secondary)
+ .tracking(-0.2)
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+private struct TipGroup: Identifiable {
+ let id = UUID()
+ let label: String
+ let icon: String
+ let color: Color
+ let items: [TipItem]
+}
+
+private struct TipItem: Identifiable {
+ let id = UUID()
+ let text: String
+ let trailing: String?
+}
+
+private func computeTipGroups(payload: MenubarPayload) -> [TipGroup] {
+ let stats = computeHistoryStats(history: payload.history.daily)
+
+ // What's working
+ var wins: [TipItem] = []
+ let cacheHit = payload.current.cacheHitPercent
+ if cacheHit >= 80 {
+ wins.append(TipItem(
+ text: "Cache hit at \(Int(cacheHit))% — most prompts reuse cache",
+ trailing: nil
+ ))
+ }
+ if let oneShot = payload.current.oneShotRate, oneShot >= 0.75 {
+ wins.append(TipItem(
+ text: "\(Int(oneShot * 100))% one-shot — edits landing first try",
+ trailing: nil
+ ))
+ }
+ if let delta = stats.weekDeltaPercent, delta < -10 {
+ wins.append(TipItem(
+ text: "Spend down \(Int(abs(delta)))% vs last 7 days",
+ trailing: nil
+ ))
+ }
+ if stats.activeStreakDays >= 5 {
+ wins.append(TipItem(
+ text: "\(stats.activeStreakDays)-day usage streak",
+ trailing: nil
+ ))
+ }
+
+ // What to improve (existing optimize findings)
+ var improvements: [TipItem] = []
+ for finding in payload.optimize.topFindings.prefix(3) {
+ improvements.append(TipItem(
+ text: finding.title,
+ trailing: finding.savingsUSD.asCompactCurrency()
+ ))
+ }
+
+ // Risks
+ var risks: [TipItem] = []
+ if let delta = stats.weekDeltaPercent, delta > 25 {
+ risks.append(TipItem(
+ text: "Spend up \(Int(delta))% vs prior 7 days",
+ trailing: nil
+ ))
+ }
+ if cacheHit > 0 && cacheHit < 50 {
+ risks.append(TipItem(
+ text: "Cache hit only \(Int(cacheHit))% — paying for cold prompts",
+ trailing: nil
+ ))
+ }
+ if let oneShot = payload.current.oneShotRate, oneShot < 0.5 {
+ risks.append(TipItem(
+ text: "\(Int(oneShot * 100))% one-shot — lots of iteration",
+ trailing: nil
+ ))
+ }
+ if let projected = stats.projectedMonth, let prevMonth = stats.previousMonthTotal, projected > prevMonth * 1.3 {
+ risks.append(TipItem(
+ text: "On pace for \(projected.asCompactCurrency()) this month (+\(Int(((projected - prevMonth) / prevMonth) * 100))% vs last)",
+ trailing: nil
+ ))
+ }
+
+ return [
+ TipGroup(label: "What's working", icon: "checkmark.circle.fill", color: winColor, items: wins),
+ TipGroup(label: "What to improve", icon: "arrow.up.right.circle.fill", color: improveColor, items: improvements),
+ TipGroup(label: "Risks", icon: "exclamationmark.triangle.fill", color: riskColor, items: risks),
+ ]
+}
+
+private struct HistoryStats {
+ let weekDeltaPercent: Double?
+ let activeStreakDays: Int
+ let projectedMonth: Double?
+ let previousMonthTotal: Double?
+}
+
+private func computeHistoryStats(history: [DailyHistoryEntry]) -> HistoryStats {
+ var calendar = Calendar(identifier: .gregorian)
+ calendar.timeZone = TimeZone(identifier: "UTC")!
+ let formatter: DateFormatter = {
+ let f = DateFormatter()
+ f.dateFormat = "yyyy-MM-dd"
+ f.timeZone = TimeZone(identifier: "UTC")
+ return f
+ }()
+ let now = Date()
+ let today = calendar.startOfDay(for: now)
+ let costByDate = Dictionary(uniqueKeysWithValues: history.map { ($0.date, $0.cost) })
+
+ let lastWeekStart = calendar.date(byAdding: .day, value: -6, to: today)
+ let priorWeekStart = calendar.date(byAdding: .day, value: -13, to: today)
+ let priorWeekEnd = calendar.date(byAdding: .day, value: -7, to: today)
+ var weekDeltaPercent: Double? = nil
+ if let lws = lastWeekStart, let pws = priorWeekStart, let pwe = priorWeekEnd {
+ let lwsStr = formatter.string(from: lws)
+ let pwsStr = formatter.string(from: pws)
+ let pweStr = formatter.string(from: pwe)
+ let thisWeek = history.filter { $0.date >= lwsStr }.reduce(0.0) { $0 + $1.cost }
+ let prior = history.filter { $0.date >= pwsStr && $0.date <= pweStr }.reduce(0.0) { $0 + $1.cost }
+ if prior > 0 {
+ weekDeltaPercent = ((thisWeek - prior) / prior) * 100
+ }
+ }
+
+ var streak = 0
+ for offset in 0..<60 {
+ guard let d = calendar.date(byAdding: .day, value: -offset, to: today) else { break }
+ let key = formatter.string(from: d)
+ if (costByDate[key] ?? 0) > 0 { streak += 1 } else { break }
+ }
+
+ var projectedMonth: Double? = nil
+ var previousMonthTotal: Double? = nil
+ let comps = calendar.dateComponents([.year, .month, .day], from: now)
+ if
+ let firstOfMonth = calendar.date(from: DateComponents(year: comps.year, month: comps.month, day: 1)),
+ let rangeOfMonth = calendar.range(of: .day, in: .month, for: firstOfMonth)
+ {
+ let firstStr = formatter.string(from: firstOfMonth)
+ let mtd = history.filter { $0.date >= firstStr }.reduce(0.0) { $0 + $1.cost }
+ let dayOfMonth = comps.day ?? 1
+ if dayOfMonth > 0 {
+ projectedMonth = (mtd / Double(dayOfMonth)) * Double(rangeOfMonth.count)
+ }
+
+ if
+ let prevMonth = calendar.date(byAdding: .month, value: -1, to: firstOfMonth),
+ let prevRange = calendar.range(of: .day, in: .month, for: prevMonth),
+ let prevFirst = calendar.date(from: DateComponents(
+ year: calendar.component(.year, from: prevMonth),
+ month: calendar.component(.month, from: prevMonth),
+ day: 1
+ )),
+ let prevLast = calendar.date(byAdding: .day, value: prevRange.count - 1, to: prevFirst)
+ {
+ let prevFirstStr = formatter.string(from: prevFirst)
+ let prevLastStr = formatter.string(from: prevLast)
+ let prevTotal = history.filter { $0.date >= prevFirstStr && $0.date <= prevLastStr }
+ .reduce(0.0) { $0 + $1.cost }
+ if prevTotal > 0 { previousMonthTotal = prevTotal }
+ }
+ }
+
+ return HistoryStats(
+ weekDeltaPercent: weekDeltaPercent,
+ activeStreakDays: streak,
+ projectedMonth: projectedMonth,
+ previousMonthTotal: previousMonthTotal
+ )
+}
diff --git a/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift b/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift
new file mode 100644
index 0000000..6466ad0
--- /dev/null
+++ b/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift
@@ -0,0 +1,1219 @@
+import SwiftUI
+
+private let trendDays = 19
+private let trendBarWidth: CGFloat = 13
+private let trendBarGap: CGFloat = 4
+private let trendChartHeight: CGFloat = 90
+
+/// Three switchable insight visualizations: Calendar (this month), Forecast (burn rate),
+/// Pulse (efficiency KPIs). Pills at top toggle between them.
+struct HeatmapSection: View {
+ @Environment(AppStore.self) private var store
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 10) {
+ InsightPillSwitcher(selected: bindingMode, visibleModes: visibleModes)
+ content
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .onAppear { ensureValidSelection() }
+ .onChange(of: store.selectedProvider) { _, _ in ensureValidSelection() }
+ }
+
+ private var bindingMode: Binding {
+ Binding(get: { store.selectedInsight }, set: { store.selectedInsight = $0 })
+ }
+
+ private var visibleModes: [InsightMode] {
+ // Plan sources from Claude's OAuth usage endpoint, so it only makes sense when the
+ // Claude provider tab is selected. Hidden on All/Cursor/Codex/etc.
+ InsightMode.allCases.filter { mode in
+ if mode == .plan { return store.selectedProvider == .claude }
+ return true
+ }
+ }
+
+ private func ensureValidSelection() {
+ if !visibleModes.contains(store.selectedInsight) {
+ store.selectedInsight = visibleModes.first ?? .trend
+ }
+ }
+
+ @ViewBuilder
+ private var content: some View {
+ switch store.selectedInsight {
+ case .plan: PlanInsight(usage: store.subscription)
+ case .trend: TrendInsight(days: store.payload.history.daily)
+ case .forecast: ForecastInsight(days: store.payload.history.daily)
+ case .pulse: PulseInsight(payload: store.payload)
+ case .stats: StatsInsight(payload: store.payload)
+ }
+ }
+}
+
+// MARK: - Pill Switcher
+
+private struct InsightPillSwitcher: View {
+ @Binding var selected: InsightMode
+ let visibleModes: [InsightMode]
+
+ var body: some View {
+ HStack(spacing: 4) {
+ ForEach(visibleModes) { mode in
+ Button {
+ selected = mode
+ } label: {
+ Text(mode.rawValue)
+ .font(.system(size: 11, weight: .medium))
+ .foregroundStyle(selected == mode ? AnyShapeStyle(.white) : AnyShapeStyle(.secondary))
+ .padding(.horizontal, 10)
+ .padding(.vertical, 4)
+ .background(
+ RoundedRectangle(cornerRadius: 6)
+ .fill(selected == mode ? AnyShapeStyle(Theme.brandAccent) : AnyShapeStyle(Color.secondary.opacity(0.10)))
+ )
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ }
+}
+
+// MARK: - Trend (14-day bar chart with peak + average)
+
+private struct TrendInsight: View {
+ let days: [DailyHistoryEntry]
+
+ var body: some View {
+ let bars = buildTrendBars(from: days)
+ let stats = computeTrendStats(bars: bars, allDays: days)
+ // Tokens are real for the .all-providers view; per-provider history doesn't carry
+ // token breakdown yet, so fall back to $ when no tokens are present.
+ let totalTokens = bars.reduce(0.0) { $0 + $1.tokens }
+ let useTokens = totalTokens > 0
+ let metric: (TrendBar) -> Double = useTokens ? { $0.tokens } : { $0.cost }
+ let maxValue = max(bars.map(metric).max() ?? 1, 0.01)
+ let avgValue = bars.isEmpty ? 0 : bars.map(metric).reduce(0, +) / Double(bars.count)
+ let peakValue = bars.filter({ metric($0) > 0 }).max(by: { metric($0) < metric($1) })
+ let yesterdayValue = stats.yesterdayBar.map(metric)
+
+ VStack(alignment: .leading, spacing: 10) {
+ HStack(alignment: .firstTextBaseline) {
+ VStack(alignment: .leading, spacing: 1) {
+ Text("Last \(trendDays) days")
+ .font(.system(size: 10, weight: .medium))
+ .foregroundStyle(.tertiary)
+ Text(formatHero(useTokens: useTokens, tokens: totalTokens, dollars: stats.totalThisWindow))
+ .font(.system(size: 18, weight: .semibold, design: .rounded))
+ .monospacedDigit()
+ .foregroundStyle(.primary)
+ }
+ Spacer()
+ if let delta = stats.deltaPercent {
+ HStack(spacing: 3) {
+ Image(systemName: delta >= 0 ? "arrow.up.right" : "arrow.down.right")
+ .font(.system(size: 9, weight: .bold))
+ Text("\(delta >= 0 ? "+" : "")\(String(format: "%.0f", delta))% vs prior \(trendDays)d")
+ .font(.system(size: 10.5))
+ .monospacedDigit()
+ }
+ .foregroundStyle(Theme.brandAccent)
+ }
+ }
+
+ TrendChart(
+ bars: bars,
+ maxValue: maxValue,
+ avgValue: avgValue,
+ metric: metric,
+ formatValue: { formatValue($0, useTokens: useTokens) }
+ )
+ .zIndex(1)
+
+ HStack(spacing: 14) {
+ MiniStat(label: "Avg/day", value: formatValue(avgValue, useTokens: useTokens))
+ MiniStat(label: "Peak", value: peakLabel(peakValue, metric: metric, useTokens: useTokens))
+ MiniStat(label: "Yesterday", value: yesterdayValue.map { formatValue($0, useTokens: useTokens) } ?? "—")
+ }
+ }
+ }
+
+ private func formatHero(useTokens: Bool, tokens: Double, dollars: Double) -> String {
+ useTokens ? "\(formatTokens(tokens)) tokens" : dollars.asCurrency()
+ }
+
+ private func formatValue(_ v: Double, useTokens: Bool) -> String {
+ useTokens ? "\(formatTokens(v)) tok" : v.asCompactCurrency()
+ }
+
+ private func peakLabel(_ peak: TrendBar?, metric: (TrendBar) -> Double, useTokens: Bool) -> String {
+ guard let peak, metric(peak) > 0 else { return "—" }
+ return "\(formatValue(metric(peak), useTokens: useTokens)) on \(shortDate(peak.date))"
+ }
+
+ private func formatTokens(_ n: Double) -> String {
+ if n >= 1_000_000 { return String(format: "%.1fM", n / 1_000_000) }
+ if n >= 1_000 { return String(format: "%.0fK", n / 1_000) }
+ return String(format: "%.0f", n)
+ }
+
+ private func shortDate(_ ymd: String) -> String {
+ let parts = ymd.split(separator: "-")
+ guard parts.count == 3 else { return ymd }
+ return "\(parts[1])/\(parts[2])"
+ }
+}
+
+private struct TrendChart: View {
+ let bars: [TrendBar]
+ let maxValue: Double
+ let avgValue: Double
+ let metric: (TrendBar) -> Double
+ let formatValue: (Double) -> String
+
+ @State private var hoveredBarID: TrendBar.ID?
+
+ var body: some View {
+ let avgFraction = maxValue > 0 ? CGFloat(min(avgValue / maxValue, 1.0)) : 0
+
+ ZStack(alignment: .bottomLeading) {
+ HStack(alignment: .bottom, spacing: trendBarGap) {
+ ForEach(bars) { bar in
+ BarColumn(
+ bar: bar,
+ value: metric(bar),
+ maxValue: maxValue,
+ isHovered: hoveredBarID == bar.id
+ )
+ .onHover { hovering in
+ hoveredBarID = hovering ? bar.id : (hoveredBarID == bar.id ? nil : hoveredBarID)
+ }
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .frame(height: trendChartHeight, alignment: .bottom)
+
+ GeometryReader { geo in
+ Path { p in
+ let y = geo.size.height - (geo.size.height * avgFraction)
+ p.move(to: CGPoint(x: 0, y: y))
+ p.addLine(to: CGPoint(x: geo.size.width, y: y))
+ }
+ .stroke(Color.secondary.opacity(0.5), style: StrokeStyle(lineWidth: 1, dash: [3, 3]))
+ }
+ .frame(height: trendChartHeight)
+ .allowsHitTesting(false)
+ }
+ .frame(height: trendChartHeight)
+ .overlay(alignment: .bottomLeading) {
+ // Floats below the chart without taking layout space. Opaque dark card hides
+ // whatever sits beneath it (mini stats, activity rows).
+ if let hoveredBar {
+ BarTooltipCard(bar: hoveredBar, formatValue: formatValue)
+ .padding(.top, 6)
+ .offset(y: 92)
+ .transition(.opacity)
+ .allowsHitTesting(false)
+ .zIndex(10)
+ }
+ }
+ .animation(.easeInOut(duration: 0.12), value: hoveredBarID)
+ }
+
+ private var hoveredBar: TrendBar? {
+ guard let id = hoveredBarID else { return nil }
+ return bars.first { $0.id == id }
+ }
+}
+
+private struct BarColumn: View {
+ let bar: TrendBar
+ let value: Double
+ let maxValue: Double
+ let isHovered: Bool
+
+ var body: some View {
+ let fraction = maxValue > 0 ? CGFloat(value / maxValue) : 0
+ let height = max(2, trendChartHeight * fraction)
+
+ VStack(spacing: 2) {
+ Spacer(minLength: 0)
+ RoundedRectangle(cornerRadius: 2)
+ .fill(barColor)
+ .frame(width: trendBarWidth, height: height)
+ .overlay(
+ RoundedRectangle(cornerRadius: 2)
+ .stroke(Theme.brandAccent.opacity(isHovered ? 0.9 : 0), lineWidth: 1)
+ )
+ .scaleEffect(x: isHovered ? 1.08 : 1.0, y: 1.0, anchor: .bottom)
+ .animation(.easeOut(duration: 0.12), value: isHovered)
+ }
+ .contentShape(Rectangle())
+ }
+
+ private var barColor: Color {
+ if bar.isToday { return Theme.brandAccent }
+ if value <= 0 { return Color.secondary.opacity(0.15) }
+ return isHovered ? Theme.brandAccent.opacity(0.85) : Theme.brandAccent.opacity(0.55)
+ }
+}
+
+private struct BarTooltipCard: View {
+ let bar: TrendBar
+ let formatValue: (Double) -> String
+ @Environment(\.colorScheme) private var colorScheme
+
+ private var backgroundFill: Color {
+ colorScheme == .dark ? Color.white : Color.black
+ }
+
+ private var primaryText: Color {
+ colorScheme == .dark ? Color.black : Color.white
+ }
+
+ private var secondaryText: Color {
+ colorScheme == .dark ? Color.black.opacity(0.7) : Color.white.opacity(0.72)
+ }
+
+ private var tertiaryText: Color {
+ colorScheme == .dark ? Color.black.opacity(0.5) : Color.white.opacity(0.52)
+ }
+
+ private var borderStroke: Color {
+ colorScheme == .dark ? Color.black.opacity(0.12) : Color.white.opacity(0.12)
+ }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 5) {
+ HStack(alignment: .firstTextBaseline) {
+ Text(prettyDate(bar.date))
+ .font(.system(size: 11, weight: .semibold))
+ .foregroundStyle(primaryText)
+ Spacer()
+ Text("\(formatValue(bar.tokens))")
+ .font(.codeMono(size: 10.5, weight: .semibold))
+ .foregroundStyle(Theme.brandAccent)
+ }
+
+ if !bar.topModels.isEmpty {
+ VStack(alignment: .leading, spacing: 3) {
+ ForEach(bar.topModels.prefix(4), id: \.name) { m in
+ HStack(spacing: 6) {
+ Circle().fill(Theme.brandAccent.opacity(0.7)).frame(width: 4, height: 4)
+ Text(m.name)
+ .font(.system(size: 10, weight: .medium))
+ .foregroundStyle(primaryText)
+ Spacer()
+ Text("\(formatTokensCompact(Double(m.totalTokens))) tok")
+ .font(.codeMono(size: 9.5, weight: .medium))
+ .foregroundStyle(secondaryText)
+ Text("(\(formatTokensCompact(Double(m.inputTokens)))/\(formatTokensCompact(Double(m.outputTokens))))")
+ .font(.codeMono(size: 9, weight: .regular))
+ .foregroundStyle(tertiaryText)
+ }
+ }
+ }
+ } else {
+ Text("No model breakdown available")
+ .font(.system(size: 10))
+ .foregroundStyle(tertiaryText)
+ }
+ }
+ .padding(11)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(
+ RoundedRectangle(cornerRadius: 8)
+ .fill(backgroundFill)
+ )
+ .overlay(
+ RoundedRectangle(cornerRadius: 8)
+ .stroke(borderStroke, lineWidth: 0.5)
+ )
+ .shadow(color: Color.black.opacity(0.35), radius: 10, y: 4)
+ }
+
+ private func formatTokensCompact(_ n: Double) -> String {
+ if n >= 1_000_000 { return String(format: "%.1fM", n / 1_000_000) }
+ if n >= 1_000 { return String(format: "%.0fK", n / 1_000) }
+ return String(format: "%.0f", n)
+ }
+}
+
+private func prettyDate(_ ymd: String) -> String {
+ let parser = DateFormatter()
+ parser.dateFormat = "yyyy-MM-dd"
+ parser.timeZone = TimeZone(identifier: "UTC")
+ guard let date = parser.date(from: ymd) else { return ymd }
+ let display = DateFormatter()
+ display.dateFormat = "EEE MMM d"
+ return display.string(from: date)
+}
+
+private struct MiniStat: View {
+ let label: String
+ let value: String
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 1) {
+ Text(label)
+ .font(.system(size: 9.5, weight: .medium))
+ .foregroundStyle(.tertiary)
+ Text(value)
+ .font(.system(size: 11.5, weight: .semibold))
+ .monospacedDigit()
+ .foregroundStyle(.primary)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+}
+
+private struct TrendBar: Identifiable {
+ let id = UUID()
+ let date: String
+ let cost: Double
+ let inputTokens: Double
+ let outputTokens: Double
+ let isToday: Bool
+ let topModels: [DailyModelBreakdown]
+
+ var tokens: Double { inputTokens + outputTokens }
+}
+
+private struct TrendStats {
+ let totalThisWindow: Double
+ let avgPerDay: Double
+ let peak: TrendBar?
+ let activeDays: Int
+ let deltaPercent: Double?
+ let yesterdayBar: TrendBar?
+}
+
+private func buildTrendBars(from days: [DailyHistoryEntry]) -> [TrendBar] {
+ var calendar = Calendar(identifier: .gregorian)
+ calendar.timeZone = TimeZone(identifier: "UTC")!
+ let formatter: DateFormatter = {
+ let f = DateFormatter()
+ f.dateFormat = "yyyy-MM-dd"
+ f.timeZone = TimeZone(identifier: "UTC")
+ return f
+ }()
+ let entryByDate = Dictionary(uniqueKeysWithValues: days.map { ($0.date, $0) })
+ let today = calendar.startOfDay(for: Date())
+ let todayKey = formatter.string(from: today)
+
+ var bars: [TrendBar] = []
+ for offset in (0.. TrendStats {
+ let total = bars.reduce(0.0) { $0 + $1.cost }
+ let active = bars.filter { $0.cost > 0 }.count
+ let avg = bars.isEmpty ? 0 : total / Double(bars.count)
+ let peak = bars.filter { $0.cost > 0 }.max(by: { $0.cost < $1.cost })
+
+ var calendar = Calendar(identifier: .gregorian)
+ calendar.timeZone = TimeZone(identifier: "UTC")!
+ let formatter: DateFormatter = {
+ let f = DateFormatter()
+ f.dateFormat = "yyyy-MM-dd"
+ f.timeZone = TimeZone(identifier: "UTC")
+ return f
+ }()
+ let today = calendar.startOfDay(for: Date())
+ let priorWindowStart = calendar.date(byAdding: .day, value: -(2 * trendDays - 1), to: today)
+ let thisWindowStart = calendar.date(byAdding: .day, value: -(trendDays - 1), to: today)
+ var deltaPercent: Double? = nil
+ if let priorStart = priorWindowStart, let thisStart = thisWindowStart {
+ let priorStartStr = formatter.string(from: priorStart)
+ let thisStartStr = formatter.string(from: thisStart)
+ let priorTotal = allDays
+ .filter { $0.date >= priorStartStr && $0.date < thisStartStr }
+ .reduce(0.0) { $0 + $1.cost }
+ if priorTotal > 0 {
+ deltaPercent = ((total - priorTotal) / priorTotal) * 100
+ }
+ }
+
+ let yesterdayDate = calendar.date(byAdding: .day, value: -1, to: today)
+ let yesterdayKey = yesterdayDate.map { formatter.string(from: $0) }
+ let yesterdayBar = bars.first(where: { $0.date == yesterdayKey })
+
+ return TrendStats(
+ totalThisWindow: total,
+ avgPerDay: avg,
+ peak: peak,
+ activeDays: active,
+ deltaPercent: deltaPercent,
+ yesterdayBar: yesterdayBar
+ )
+}
+
+// MARK: - Forecast
+
+private struct ForecastInsight: View {
+ let days: [DailyHistoryEntry]
+
+ var body: some View {
+ let stats = computeForecast(days: days)
+ VStack(alignment: .leading, spacing: 10) {
+ HStack(alignment: .firstTextBaseline) {
+ VStack(alignment: .leading, spacing: 2) {
+ Text("Month-to-date")
+ .font(.system(size: 10, weight: .medium))
+ .foregroundStyle(.tertiary)
+ Text(stats.mtd.asCurrency())
+ .font(.system(size: 22, weight: .semibold, design: .rounded))
+ .monospacedDigit()
+ .foregroundStyle(Theme.brandAccent)
+ }
+ Spacer()
+ VStack(alignment: .trailing, spacing: 2) {
+ Text("On pace for")
+ .font(.system(size: 10, weight: .medium))
+ .foregroundStyle(.tertiary)
+ Text(stats.projection.asCurrency())
+ .font(.system(size: 16, weight: .semibold))
+ .monospacedDigit()
+ }
+ }
+
+ HStack(spacing: 14) {
+ ForecastStat(label: "Avg/day (this wk)", value: stats.weekAvg.asCompactCurrency())
+ ForecastStat(label: "Yesterday", value: stats.yesterday.asCompactCurrency())
+ ForecastStat(label: "Last 7d", value: stats.weekTotal.asCompactCurrency())
+ }
+
+ if let prevTotal = stats.previousMonthTotal {
+ HStack(spacing: 4) {
+ Image(systemName: stats.projection > prevTotal ? "arrow.up.right" : "arrow.down.right")
+ .font(.system(size: 9, weight: .bold))
+ Text(comparisonText(projection: stats.projection, previous: prevTotal))
+ .font(.system(size: 10.5))
+ .monospacedDigit()
+ }
+ .foregroundStyle(Theme.brandAccent)
+ }
+ }
+ }
+
+ private func comparisonText(projection: Double, previous: Double) -> String {
+ guard previous > 0 else { return "no prior month" }
+ let diff = ((projection - previous) / previous) * 100
+ let sign = diff >= 0 ? "+" : ""
+ return "\(sign)\(String(format: "%.0f", diff))% vs last month ($\(String(format: "%.0f", previous)))"
+ }
+}
+
+private struct ForecastStat: View {
+ let label: String
+ let value: String
+ var body: some View {
+ VStack(alignment: .leading, spacing: 1) {
+ Text(label)
+ .font(.system(size: 9.5, weight: .medium))
+ .foregroundStyle(.tertiary)
+ Text(value)
+ .font(.system(size: 12, weight: .semibold))
+ .monospacedDigit()
+ .foregroundStyle(.primary)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+}
+
+private struct ForecastStats {
+ let mtd: Double
+ let projection: Double
+ let weekAvg: Double
+ let weekTotal: Double
+ let yesterday: Double
+ let previousMonthTotal: Double?
+}
+
+private func computeForecast(days: [DailyHistoryEntry]) -> ForecastStats {
+ var calendar = Calendar(identifier: .gregorian)
+ calendar.timeZone = TimeZone(identifier: "UTC")!
+ let formatter: DateFormatter = {
+ let f = DateFormatter()
+ f.dateFormat = "yyyy-MM-dd"
+ f.timeZone = TimeZone(identifier: "UTC")
+ return f
+ }()
+ let now = Date()
+ let comps = calendar.dateComponents([.year, .month, .day], from: now)
+ guard
+ let firstOfMonth = calendar.date(from: DateComponents(year: comps.year, month: comps.month, day: 1)),
+ let rangeOfMonth = calendar.range(of: .day, in: .month, for: firstOfMonth)
+ else {
+ return ForecastStats(mtd: 0, projection: 0, weekAvg: 0, weekTotal: 0, yesterday: 0, previousMonthTotal: nil)
+ }
+
+ let firstStr = formatter.string(from: firstOfMonth)
+ let totalDays = rangeOfMonth.count
+ let dayOfMonth = comps.day ?? 1
+
+ let mtdEntries = days.filter { $0.date >= firstStr }
+ let mtd = mtdEntries.reduce(0.0) { $0 + $1.cost }
+ let avgPerElapsedDay = dayOfMonth > 0 ? mtd / Double(dayOfMonth) : 0
+ let projection = avgPerElapsedDay * Double(totalDays)
+
+ let weekStart = calendar.date(byAdding: .day, value: -6, to: calendar.startOfDay(for: now))
+ let weekStartStr = weekStart.map { formatter.string(from: $0) } ?? ""
+ let weekEntries = days.filter { $0.date >= weekStartStr }
+ let weekTotal = weekEntries.reduce(0.0) { $0 + $1.cost }
+ let weekAvg = weekTotal / 7.0
+
+ let yesterdayDate = calendar.date(byAdding: .day, value: -1, to: calendar.startOfDay(for: now))
+ let yesterdayStr = yesterdayDate.map { formatter.string(from: $0) } ?? ""
+ let yesterday = days.first(where: { $0.date == yesterdayStr })?.cost ?? 0
+
+ var previousMonthTotal: Double? = nil
+ if
+ let prevMonthDate = calendar.date(byAdding: .month, value: -1, to: firstOfMonth),
+ let prevRange = calendar.range(of: .day, in: .month, for: prevMonthDate),
+ let prevFirst = calendar.date(from: DateComponents(year: calendar.component(.year, from: prevMonthDate), month: calendar.component(.month, from: prevMonthDate), day: 1)),
+ let prevLast = calendar.date(byAdding: .day, value: prevRange.count - 1, to: prevFirst)
+ {
+ let prevFirstStr = formatter.string(from: prevFirst)
+ let prevLastStr = formatter.string(from: prevLast)
+ let prevEntries = days.filter { $0.date >= prevFirstStr && $0.date <= prevLastStr }
+ if !prevEntries.isEmpty {
+ previousMonthTotal = prevEntries.reduce(0.0) { $0 + $1.cost }
+ }
+ }
+
+ return ForecastStats(
+ mtd: mtd,
+ projection: projection,
+ weekAvg: weekAvg,
+ weekTotal: weekTotal,
+ yesterday: yesterday,
+ previousMonthTotal: previousMonthTotal
+ )
+}
+
+// MARK: - Pulse
+
+private struct PulseInsight: View {
+ let payload: MenubarPayload
+
+ var body: some View {
+ HStack(spacing: 10) {
+ PulseTile(label: "Cache hit", value: cacheHitText, color: Theme.brandAccent)
+ PulseTile(label: "1-shot", value: oneShotText, color: oneShotColor)
+ PulseTile(
+ label: "Cost / session",
+ value: payload.current.sessions > 0
+ ? (payload.current.cost / Double(payload.current.sessions)).asCompactCurrency()
+ : "—",
+ color: .secondary
+ )
+ }
+ }
+
+ private var cacheHitText: String {
+ let v = payload.current.cacheHitPercent
+ return v <= 0 ? "—" : String(format: "%.0f%%", v)
+ }
+
+ private var oneShotText: String {
+ guard let r = payload.current.oneShotRate else { return "—" }
+ return String(format: "%.0f%%", r * 100)
+ }
+
+ private var oneShotColor: Color {
+ payload.current.oneShotRate == nil ? .secondary : Theme.brandAccent
+ }
+}
+
+private struct PulseTile: View {
+ let label: String
+ let value: String
+ let color: Color
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 3) {
+ Text(label)
+ .font(.system(size: 10, weight: .medium))
+ .foregroundStyle(.tertiary)
+ Text(value)
+ .font(.system(size: 18, weight: .semibold, design: .rounded))
+ .monospacedDigit()
+ .foregroundStyle(color)
+ }
+ .padding(.horizontal, 10)
+ .padding(.vertical, 8)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(
+ RoundedRectangle(cornerRadius: 6)
+ .fill(Color.secondary.opacity(0.06))
+ )
+ }
+}
+
+/// Connects optimize findings directly to plan utilization: "address N findings to recover X
+/// tokens" framed as the same currency the rest of the Plan view uses (effective tokens).
+/// Scoped to whatever period the user selected (today / 7d / 30d / month / all).
+private struct OptimizeSavingsBadge: View {
+ let payload: MenubarPayload
+
+ var body: some View {
+ let findingCount = payload.optimize.findingCount
+ let savingsUSD = payload.optimize.savingsUSD
+ if findingCount == 0 || savingsUSD <= 0 {
+ EmptyView()
+ } else {
+ Button { openOptimize() } label: {
+ HStack(spacing: 6) {
+ Image(systemName: "lightbulb.fill")
+ .font(.system(size: 10, weight: .semibold))
+ .foregroundStyle(Theme.brandAccent)
+ Text(captionText(findingCount: findingCount, savingsUSD: savingsUSD))
+ .font(.system(size: 11, weight: .medium))
+ .foregroundStyle(.primary)
+ Spacer()
+ Image(systemName: "chevron.right")
+ .font(.system(size: 8, weight: .semibold))
+ .foregroundStyle(.tertiary)
+ }
+ .padding(.horizontal, 10)
+ .padding(.vertical, 7)
+ .background(
+ RoundedRectangle(cornerRadius: 6)
+ .fill(Theme.brandAccent.opacity(0.10))
+ )
+ }
+ .buttonStyle(.plain)
+ .padding(.top, 2)
+ }
+ }
+
+ private func captionText(findingCount: Int, savingsUSD: Double) -> String {
+ let tokens = savingsUSD / 9.0 * 1_000_000 // ~$9/M effective tokens (Sonnet-weighted approx)
+ let tokensLabel = formatTokens(tokens)
+ let plural = findingCount == 1 ? "finding" : "findings"
+ return "Save ~\(savingsUSD.asCompactCurrency()) / ~\(tokensLabel) tokens · \(findingCount) \(plural)"
+ }
+
+ private func openOptimize() {
+ TerminalLauncher.open(subcommand: ["optimize"])
+ }
+
+ private func formatTokens(_ n: Double) -> String {
+ if n >= 1_000_000 { return String(format: "%.1fM", n / 1_000_000) }
+ if n >= 1_000 { return String(format: "%.0fK", n / 1_000) }
+ return String(format: "%.0f", n)
+ }
+}
+
+// MARK: - Stats
+
+private struct StatsInsight: View {
+ let payload: MenubarPayload
+
+ var body: some View {
+ let stats = computeAllStats(payload: payload)
+
+ VStack(alignment: .leading, spacing: 10) {
+ HStack(alignment: .top, spacing: 14) {
+ VStack(alignment: .leading, spacing: 8) {
+ StatRow(label: "Favorite model", value: stats.favoriteModel)
+ StatRow(label: "Active days (month)", value: stats.activeDaysFraction)
+ StatRow(label: "Most active day", value: stats.mostActiveDay)
+ StatRow(label: "Peak day spend", value: stats.peakDaySpend)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+
+ VStack(alignment: .leading, spacing: 8) {
+ StatRow(label: "Sessions today", value: "\(payload.current.sessions)")
+ StatRow(label: "Calls today", value: payload.current.calls.asThousandsSeparated())
+ StatRow(label: "Current streak", value: stats.currentStreak)
+ StatRow(label: "Longest streak", value: stats.longestStreak)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+
+ if let lifetime = stats.lifetimeTotal {
+ Divider().opacity(0.5)
+ HStack {
+ Text("Tracked spend (last \(stats.historyDayCount) days)")
+ .font(.system(size: 10.5, weight: .medium))
+ .foregroundStyle(.tertiary)
+ Spacer()
+ Text(lifetime.asCurrency())
+ .font(.system(size: 13, weight: .semibold, design: .rounded))
+ .monospacedDigit()
+ .foregroundStyle(Theme.brandAccent)
+ }
+ }
+ }
+ }
+}
+
+private struct StatRow: View {
+ let label: String
+ let value: String
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 1) {
+ Text(label)
+ .font(.system(size: 9.5, weight: .medium))
+ .foregroundStyle(.tertiary)
+ Text(value)
+ .font(.system(size: 12, weight: .semibold))
+ .monospacedDigit()
+ .foregroundStyle(.primary)
+ }
+ }
+}
+
+private struct AllStats {
+ let favoriteModel: String
+ let activeDaysFraction: String
+ let mostActiveDay: String
+ let peakDaySpend: String
+ let currentStreak: String
+ let longestStreak: String
+ let lifetimeTotal: Double?
+ let historyDayCount: Int
+}
+
+private func computeAllStats(payload: MenubarPayload) -> AllStats {
+ let history = payload.history.daily
+ let favoriteModel = payload.current.topModels.first?.name ?? "—"
+
+ var calendar = Calendar(identifier: .gregorian)
+ calendar.timeZone = TimeZone(identifier: "UTC")!
+ let formatter: DateFormatter = {
+ let f = DateFormatter()
+ f.dateFormat = "yyyy-MM-dd"
+ f.timeZone = TimeZone(identifier: "UTC")
+ return f
+ }()
+ let displayFormatter: DateFormatter = {
+ let f = DateFormatter()
+ f.dateFormat = "MMM d"
+ f.timeZone = TimeZone(identifier: "UTC")
+ return f
+ }()
+
+ let now = Date()
+ let today = calendar.startOfDay(for: now)
+ let comps = calendar.dateComponents([.year, .month, .day], from: now)
+
+ var activeDaysFraction = "—"
+ if
+ let firstOfMonth = calendar.date(from: DateComponents(year: comps.year, month: comps.month, day: 1)),
+ let rangeOfMonth = calendar.range(of: .day, in: .month, for: firstOfMonth)
+ {
+ let firstStr = formatter.string(from: firstOfMonth)
+ let mtdActive = history.filter { $0.date >= firstStr && $0.cost > 0 }.count
+ activeDaysFraction = "\(mtdActive)/\(rangeOfMonth.count)"
+ }
+
+ let peak = history.max(by: { $0.cost < $1.cost })
+ let mostActiveDay: String
+ let peakDaySpend: String
+ if let peak, peak.cost > 0, let date = formatter.date(from: peak.date) {
+ mostActiveDay = displayFormatter.string(from: date)
+ peakDaySpend = peak.cost.asCompactCurrency()
+ } else {
+ mostActiveDay = "—"
+ peakDaySpend = "—"
+ }
+
+ let costByDate = Dictionary(uniqueKeysWithValues: history.map { ($0.date, $0.cost) })
+
+ var currentStreak = 0
+ for offset in 0..<400 {
+ guard let d = calendar.date(byAdding: .day, value: -offset, to: today) else { break }
+ let key = formatter.string(from: d)
+ if (costByDate[key] ?? 0) > 0 { currentStreak += 1 } else { break }
+ }
+
+ var longestStreak = 0
+ var running = 0
+ let sortedDates = history.map(\.date).sorted()
+ for date in sortedDates {
+ if (costByDate[date] ?? 0) > 0 {
+ running += 1
+ longestStreak = max(longestStreak, running)
+ } else {
+ running = 0
+ }
+ }
+
+ let lifetimeTotal: Double? = history.isEmpty ? nil : history.reduce(0.0) { $0 + $1.cost }
+
+ return AllStats(
+ favoriteModel: favoriteModel,
+ activeDaysFraction: activeDaysFraction,
+ mostActiveDay: mostActiveDay,
+ peakDaySpend: peakDaySpend,
+ currentStreak: currentStreak == 0 ? "—" : "\(currentStreak) days",
+ longestStreak: longestStreak == 0 ? "—" : "\(longestStreak) days",
+ lifetimeTotal: lifetimeTotal,
+ historyDayCount: history.count
+ )
+}
+
+// MARK: - Plan (subscription)
+
+private struct PlanInsight: View {
+ @Environment(AppStore.self) private var store
+ let usage: SubscriptionUsage?
+
+ private static let fiveHourSeconds: TimeInterval = 5 * 3600
+ private static let sevenDaySeconds: TimeInterval = 7 * 86400
+ private static let freshWindowThreshold: Double = 0.05
+
+ @State private var projections: [String: WindowProjection] = [:]
+
+ var body: some View {
+ Group {
+ switch store.subscriptionLoadState {
+ case .idle:
+ PlanIdleView()
+ case .loading:
+ PlanLoadingView()
+ case .noCredentials:
+ PlanNoCredentialsView()
+ case .failed:
+ PlanFailedView(error: store.subscriptionError)
+ case .loaded:
+ if let usage {
+ loadedBody(usage: usage)
+ } else {
+ PlanNoCredentialsView()
+ }
+ }
+ }
+ .task {
+ // Lazy-trigger fetch the first time Plan is opened.
+ if store.subscriptionLoadState == .idle {
+ await store.refreshSubscription()
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func loadedBody(usage: SubscriptionUsage) -> some View {
+ VStack(alignment: .leading, spacing: 10) {
+ HStack(alignment: .firstTextBaseline) {
+ Text(usage.tier.displayName)
+ .font(.system(size: 13, weight: .semibold))
+ .foregroundStyle(Theme.brandAccent)
+ Spacer()
+ if let resets = headlineReset(usage: usage) {
+ Text("Resets \(resets)")
+ .font(.system(size: 10.5))
+ .foregroundStyle(.secondary)
+ }
+ }
+
+ VStack(spacing: 8) {
+ if let p = usage.fiveHourPercent {
+ UtilizationRow(label: "5-hour window", percent: p, resetsAt: usage.fiveHourResetsAt, projection: projections["five_hour"])
+ }
+ if let p = usage.sevenDayPercent {
+ UtilizationRow(label: "7-day total", percent: p, resetsAt: usage.sevenDayResetsAt, projection: projections["seven_day"])
+ }
+ if let p = usage.sevenDayOpusPercent {
+ UtilizationRow(label: "7-day Opus", percent: p, resetsAt: usage.sevenDayOpusResetsAt, projection: projections["seven_day_opus"])
+ }
+ if let p = usage.sevenDaySonnetPercent {
+ UtilizationRow(label: "7-day Sonnet", percent: p, resetsAt: usage.sevenDaySonnetResetsAt, projection: projections["seven_day_sonnet"])
+ }
+ }
+
+ OptimizeSavingsBadge(payload: store.payload)
+ }
+ .task(id: usage.fetchedAt) {
+ await recomputeProjections(usage: usage)
+ }
+ }
+
+ private func recomputeProjections(usage: SubscriptionUsage) async {
+ var result: [String: WindowProjection] = [:]
+ let inputs: [(String, Double?, Date?, TimeInterval)] = [
+ ("five_hour", usage.fiveHourPercent, usage.fiveHourResetsAt, Self.fiveHourSeconds),
+ ("seven_day", usage.sevenDayPercent, usage.sevenDayResetsAt, Self.sevenDaySeconds),
+ ("seven_day_opus", usage.sevenDayOpusPercent, usage.sevenDayOpusResetsAt, Self.sevenDaySeconds),
+ ("seven_day_sonnet", usage.sevenDaySonnetPercent, usage.sevenDaySonnetResetsAt, Self.sevenDaySeconds),
+ ]
+ for (key, percent, resetsAt, windowSeconds) in inputs {
+ if let projection = await project(key: key, percent: percent, resetsAt: resetsAt, windowSeconds: windowSeconds) {
+ result[key] = projection
+ }
+ }
+ projections = result
+ }
+
+ /// Linear extrapolation when window is past the freshness threshold; otherwise falls back to
+ /// the prior cycle's final percent from the snapshot store.
+ private func project(key: String, percent: Double?, resetsAt: Date?, windowSeconds: TimeInterval) async -> WindowProjection? {
+ guard let percent, let resetsAt else { return nil }
+ let windowStart = resetsAt.addingTimeInterval(-windowSeconds)
+ let elapsed = Date().timeIntervalSince(windowStart)
+ let elapsedFraction = elapsed / windowSeconds
+
+ if elapsedFraction > Self.freshWindowThreshold, percent > 0 {
+ let projectedPercent = percent / elapsedFraction
+ var hitDate: Date? = nil
+ if projectedPercent > 100, percent < 100 {
+ let remainingPercent = 100 - percent
+ let percentPerSecond = percent / elapsed
+ if percentPerSecond > 0 {
+ hitDate = Date().addingTimeInterval(remainingPercent / percentPerSecond)
+ }
+ }
+ return WindowProjection(percent: projectedPercent, willOverflow: projectedPercent > 100, hitsLimitAt: hitDate, source: .linear)
+ }
+
+ // Window too fresh OR percent exactly zero -- use the prior cycle's final reading.
+ if let prior = await SubscriptionSnapshotStore.previousWindowFinal(windowKey: key, currentResetsAt: resetsAt) {
+ return WindowProjection(percent: prior, willOverflow: prior > 100, hitsLimitAt: nil, source: .historicalBaseline)
+ }
+ return nil
+ }
+
+ private func headlineReset(usage: SubscriptionUsage) -> String? {
+ let candidates = [
+ usage.fiveHourResetsAt,
+ usage.sevenDayResetsAt,
+ usage.sevenDayOpusResetsAt,
+ usage.sevenDaySonnetResetsAt,
+ ].compactMap { $0 }
+ guard let earliest = candidates.min() else { return nil }
+ return relativeReset(earliest)
+ }
+}
+
+// MARK: - Plan empty/loading/failure states
+
+private struct PlanIdleView: View {
+ var body: some View {
+ VStack(spacing: 8) {
+ Image(systemName: "person.crop.circle.dashed")
+ .font(.system(size: 22))
+ .foregroundStyle(.tertiary)
+ Text("Loading your plan...")
+ .font(.system(size: 11.5, weight: .medium))
+ .foregroundStyle(.secondary)
+ Text("macOS may ask permission to read your Claude Code credentials.")
+ .font(.system(size: 10))
+ .foregroundStyle(.tertiary)
+ .multilineTextAlignment(.center)
+ .frame(maxWidth: 260)
+ }
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 16)
+ }
+}
+
+private struct PlanLoadingView: View {
+ var body: some View {
+ VStack(spacing: 8) {
+ ProgressView().scaleEffect(0.8)
+ Text("Reading Claude credentials...")
+ .font(.system(size: 11, weight: .medium))
+ .foregroundStyle(.secondary)
+ }
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 16)
+ }
+}
+
+private struct PlanNoCredentialsView: View {
+ @Environment(AppStore.self) private var store
+
+ var body: some View {
+ VStack(spacing: 8) {
+ Image(systemName: "key.slash")
+ .font(.system(size: 20))
+ .foregroundStyle(.tertiary)
+ Text("No Claude subscription connected")
+ .font(.system(size: 12, weight: .semibold))
+ .foregroundStyle(.primary)
+ Text("Run `claude login` in your terminal, then retry.")
+ .font(.system(size: 10.5))
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ .frame(maxWidth: 260)
+ Button("Retry") {
+ Task { await store.refreshSubscription() }
+ }
+ .controlSize(.small)
+ .buttonStyle(.bordered)
+ }
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 14)
+ }
+}
+
+private struct PlanFailedView: View {
+ @Environment(AppStore.self) private var store
+ let error: String?
+
+ var body: some View {
+ VStack(spacing: 8) {
+ Image(systemName: "exclamationmark.triangle")
+ .font(.system(size: 18))
+ .foregroundStyle(Theme.brandAccent)
+ Text("Couldn't load plan data")
+ .font(.system(size: 12, weight: .semibold))
+ .foregroundStyle(.primary)
+ if let error {
+ Text(error)
+ .font(.system(size: 10))
+ .foregroundStyle(.tertiary)
+ .multilineTextAlignment(.center)
+ .frame(maxWidth: 280)
+ .lineLimit(3)
+ }
+ Button("Retry") {
+ Task { await store.refreshSubscription() }
+ }
+ .controlSize(.small)
+ .buttonStyle(.bordered)
+ }
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 14)
+ }
+}
+
+private struct WindowProjection {
+ enum Source { case linear, historicalBaseline }
+ let percent: Double
+ let willOverflow: Bool
+ let hitsLimitAt: Date?
+ let source: Source
+}
+
+private struct UtilizationRow: View {
+ let label: String
+ /// API returns utilization as 0..100 (a percentage value, not a fraction).
+ let percent: Double
+ let resetsAt: Date?
+ let projection: WindowProjection?
+
+ var body: some View {
+ VStack(spacing: 3) {
+ HStack(alignment: .firstTextBaseline) {
+ Text(label)
+ .font(.system(size: 11, weight: .medium))
+ .foregroundStyle(.secondary)
+ Spacer()
+ Text(String(format: "%.0f%%", clampedPercent))
+ .font(.codeMono(size: 11, weight: .semibold))
+ .foregroundStyle(barColor)
+ .monospacedDigit()
+ }
+ UtilizationBar(
+ fraction: clampedPercent / 100,
+ color: barColor,
+ markerFraction: projection.map { min(max($0.percent, 0), 100) / 100 }
+ )
+ .frame(height: 6)
+ if let projection {
+ ProjectionCaption(projection: projection)
+ }
+ }
+ }
+
+ private var clampedPercent: Double { min(max(percent, 0), 100) }
+
+ /// Single-color brand palette decision (see session notes): the number is the signal, not
+ /// the color. Keeping this as a computed property so a future threshold-based palette
+ /// reintroduction stays scoped to one place.
+ private var barColor: Color { Theme.brandAccent }
+}
+
+private struct ProjectionCaption: View {
+ let projection: WindowProjection
+
+ var body: some View {
+ HStack(spacing: 3) {
+ if projection.willOverflow {
+ Image(systemName: "exclamationmark.triangle.fill")
+ .font(.system(size: 8, weight: .bold))
+ .foregroundStyle(Theme.brandAccent)
+ } else {
+ Image(systemName: "arrow.up.right")
+ .font(.system(size: 8, weight: .bold))
+ .foregroundStyle(.tertiary)
+ }
+ Text(captionText)
+ .font(.system(size: 9.5, weight: .medium))
+ .foregroundStyle(projection.willOverflow
+ ? AnyShapeStyle(Theme.brandAccent)
+ : AnyShapeStyle(.tertiary))
+ Spacer()
+ }
+ }
+
+ private var captionText: String {
+ let projected = String(format: "%.0f%%", projection.percent)
+ switch projection.source {
+ case .linear:
+ if projection.willOverflow, let hit = projection.hitsLimitAt {
+ return "On pace: \(projected) at reset · hits 100% \(relativeReset(hit))"
+ }
+ return "On pace: \(projected) at reset"
+ case .historicalBaseline:
+ return "Based on last cycle: \(projected)"
+ }
+ }
+}
+
+private struct UtilizationBar: View {
+ /// 0..1 fraction of the bar to fill.
+ let fraction: Double
+ let color: Color
+ /// Optional 0..1 marker position for projected utilization at reset.
+ let markerFraction: Double?
+
+ var body: some View {
+ GeometryReader { geo in
+ ZStack(alignment: .leading) {
+ RoundedRectangle(cornerRadius: 3).fill(Color.secondary.opacity(0.12))
+ RoundedRectangle(cornerRadius: 3)
+ .fill(color)
+ .frame(width: max(0, geo.size.width * CGFloat(fraction)))
+ if let m = markerFraction {
+ Rectangle()
+ .fill(Color.primary.opacity(0.55))
+ .frame(width: 1.5)
+ .offset(x: max(0, geo.size.width * CGFloat(m)) - 0.75)
+ }
+ }
+ }
+ }
+}
+
+private func relativeReset(_ date: Date) -> String {
+ let interval = date.timeIntervalSinceNow
+ if interval <= 0 { return "now" }
+ let hours = interval / 3600
+ if hours < 1 {
+ let minutes = Int(ceil(interval / 60))
+ return "in \(minutes)m"
+ }
+ if hours < 24 { return "in \(Int(ceil(hours)))h" }
+ let days = Int(ceil(hours / 24))
+ return "in \(days)d"
+}
+
diff --git a/mac/Sources/CodeBurnMenubar/Views/HeroSection.swift b/mac/Sources/CodeBurnMenubar/Views/HeroSection.swift
new file mode 100644
index 0000000..ca30cee
--- /dev/null
+++ b/mac/Sources/CodeBurnMenubar/Views/HeroSection.swift
@@ -0,0 +1,55 @@
+import SwiftUI
+
+struct HeroSection: View {
+ @Environment(AppStore.self) private var store
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ SectionCaption(text: caption)
+
+ HStack(alignment: .firstTextBaseline) {
+ Text(store.payload.current.cost.asCurrency())
+ .font(.system(size: 32, weight: .semibold, design: .rounded))
+ .monospacedDigit()
+ .tracking(-1)
+ .foregroundStyle(
+ LinearGradient(
+ colors: [Theme.brandAccent, Theme.brandEmberDeep],
+ startPoint: .top,
+ endPoint: .bottom
+ )
+ )
+
+ Spacer()
+
+ VStack(alignment: .trailing, spacing: 2) {
+ Text("\(store.payload.current.calls.asThousandsSeparated()) calls")
+ .font(.system(size: 11))
+ .monospacedDigit()
+ .foregroundStyle(.secondary)
+ Text("\(store.payload.current.sessions) sessions")
+ .font(.system(size: 10.5))
+ .monospacedDigit()
+ .foregroundStyle(.tertiary)
+ }
+ }
+ }
+ .padding(.horizontal, 14)
+ .padding(.top, 10)
+ .padding(.bottom, 12)
+ }
+
+ private var caption: String {
+ let label = store.payload.current.label.isEmpty ? store.selectedPeriod.rawValue : store.payload.current.label
+ if store.selectedPeriod == .today {
+ return "\(label) · \(todayDate)"
+ }
+ return label
+ }
+
+ private var todayDate: String {
+ let formatter = DateFormatter()
+ formatter.dateFormat = "EEE MMM d"
+ return formatter.string(from: Date())
+ }
+}
diff --git a/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift b/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift
new file mode 100644
index 0000000..b6c550d
--- /dev/null
+++ b/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift
@@ -0,0 +1,401 @@
+import AppKit
+import SwiftUI
+
+/// Popover root. Assembles all sections matching the HTML design spec.
+struct MenuBarContent: View {
+ @Environment(AppStore.self) private var store
+
+ var body: some View {
+ VStack(spacing: 0) {
+ Header()
+
+ Divider()
+
+ AgentTabStrip()
+
+ Divider()
+
+ ZStack {
+ ScrollView(.vertical, showsIndicators: false) {
+ VStack(spacing: 0) {
+ HeroSection()
+ Divider().opacity(0.5)
+ PeriodSegmentedControl()
+ Divider().opacity(0.5)
+ if isFilteredEmpty {
+ EmptyProviderState(provider: store.selectedProvider, period: store.selectedPeriod)
+ } else {
+ HeatmapSection()
+ .padding(.horizontal, 14)
+ .padding(.top, 10)
+ .padding(.bottom, 10)
+ .zIndex(10)
+ Divider().opacity(0.5)
+ ActivitySection()
+ Divider().opacity(0.5)
+ ModelsSection()
+ Divider().opacity(0.5)
+ FindingsSection()
+ }
+ }
+ }
+
+ if store.isLoading {
+ BurnLoadingOverlay(periodLabel: store.selectedPeriod.rawValue)
+ .transition(.opacity)
+ }
+ }
+ .frame(height: 520)
+ .animation(.easeInOut(duration: 0.2), value: store.isLoading)
+
+ Divider()
+
+ FooterBar()
+
+ StarBanner()
+ }
+ }
+
+ /// True when a specific provider tab is selected and that provider has no spend in the
+ /// currently selected period. The .all tab is exempt -- it always shows aggregated data.
+ private var isFilteredEmpty: Bool {
+ guard store.selectedProvider != .all else { return false }
+ return store.payload.current.cost <= 0 && store.payload.current.calls == 0
+ }
+
+}
+
+private struct EmptyProviderState: View {
+ let provider: ProviderFilter
+ let period: Period
+
+ var body: some View {
+ VStack(spacing: 10) {
+ Image(systemName: "tray")
+ .font(.system(size: 26))
+ .foregroundStyle(.tertiary)
+ Text("No \(provider.rawValue) data for \(periodPhrase)")
+ .font(.system(size: 12, weight: .medium))
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ }
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 60)
+ }
+
+ private var periodPhrase: String {
+ switch period {
+ case .today: "today"
+ case .sevenDays: "the last 7 days"
+ case .thirtyDays: "the last 30 days"
+ case .month: "this month"
+ case .all: "all time"
+ }
+ }
+}
+
+/// Translucent overlay that blurs whatever's behind it (the previous tab/period content)
+/// and centers an animated burning flame -- the brand mark filling up bottom-to-top in
+/// yellow→orange→red, looping.
+private struct BurnLoadingOverlay: View {
+ let periodLabel: String
+ @State private var fillProgress: CGFloat = 0
+ @State private var glowing: Bool = false
+
+ private let flameSize: CGFloat = 64
+
+ var body: some View {
+ ZStack {
+ // Blur backdrop -- ultraThinMaterial uses live blur of underlying content.
+ Rectangle()
+ .fill(.ultraThinMaterial)
+
+ VStack(spacing: 14) {
+ BurnFlame(size: flameSize, fillProgress: fillProgress, glowing: glowing)
+ Text("Loading \(periodLabel)…")
+ .font(.system(size: 11.5, weight: .medium))
+ .foregroundStyle(.secondary)
+ }
+ }
+ .onAppear {
+ withAnimation(.easeInOut(duration: 1.4).repeatForever(autoreverses: true)) {
+ fillProgress = 1.0
+ }
+ withAnimation(.easeInOut(duration: 0.9).repeatForever(autoreverses: true)) {
+ glowing = true
+ }
+ }
+ }
+}
+
+private struct BurnFlame: View {
+ let size: CGFloat
+ let fillProgress: CGFloat
+ let glowing: Bool
+
+ var body: some View {
+ ZStack {
+ // Soft outer glow that pulses, matching the brand terracotta palette.
+ Image(systemName: "flame.fill")
+ .font(.system(size: size, weight: .regular))
+ .foregroundStyle(Theme.brandEmberGlow.opacity(glowing ? 0.55 : 0.20))
+ .blur(radius: glowing ? 14 : 6)
+
+ // Empty (cool) flame as base
+ Image(systemName: "flame")
+ .font(.system(size: size, weight: .regular))
+ .foregroundStyle(Theme.brandAccent.opacity(0.25))
+
+ // Burning gradient (brand orange) masked by an animated bottom-up rectangle
+ Image(systemName: "flame.fill")
+ .font(.system(size: size, weight: .regular))
+ .foregroundStyle(
+ LinearGradient(
+ colors: [
+ Theme.brandEmberGlow,
+ Theme.brandAccentDark,
+ Theme.brandAccent,
+ Theme.brandEmberDeep
+ ],
+ startPoint: .bottom,
+ endPoint: .top
+ )
+ )
+ .mask(
+ GeometryReader { geo in
+ Rectangle()
+ .frame(height: geo.size.height * fillProgress)
+ .frame(maxHeight: .infinity, alignment: .bottom)
+ }
+ )
+ }
+ .frame(width: size, height: size)
+ }
+}
+
+private struct Header: View {
+ var body: some View {
+ VStack(alignment: .leading, spacing: 1) {
+ (
+ Text("Code").foregroundStyle(.primary)
+ + Text("Burn").foregroundStyle(Theme.brandAccent)
+ )
+ .font(.system(size: 13, weight: .semibold))
+ .tracking(-0.15)
+ Text("AI Coding Cost Tracker")
+ .font(.system(size: 10.5))
+ .foregroundStyle(.secondary)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.horizontal, 14)
+ .padding(.top, 10)
+ .padding(.bottom, 8)
+ }
+}
+
+struct FlameMark: View {
+ var body: some View {
+ ZStack {
+ RoundedRectangle(cornerRadius: 5)
+ .fill(
+ LinearGradient(
+ colors: [Theme.brandAccentDark, Theme.brandEmberDeep],
+ startPoint: .topLeading,
+ endPoint: .bottomTrailing
+ )
+ )
+ .shadow(color: .black.opacity(0.2), radius: 1, y: 0.5)
+ Image(systemName: "flame.fill")
+ .font(.system(size: 12, weight: .semibold))
+ .foregroundStyle(.white)
+ }
+ }
+}
+
+private let starBannerGitHubURL = URL(string: "https://github.com/AgentSeal/codeburn")!
+
+/// Shown at the very bottom on first launch. A small terracotta strip nudges users to star the
+/// repo; clicking opens GitHub, clicking the close icon hides it forever (persisted to
+/// UserDefaults so it never returns across launches).
+struct StarBanner: View {
+ @AppStorage("codeburn.starBannerDismissed") private var dismissed: Bool = false
+
+ var body: some View {
+ if !dismissed {
+ HStack(spacing: 8) {
+ Image(systemName: "star.fill")
+ .font(.system(size: 10, weight: .semibold))
+ .foregroundStyle(Theme.brandAccent)
+
+ Button {
+ NSWorkspace.shared.open(starBannerGitHubURL)
+ } label: {
+ HStack(spacing: 4) {
+ Text("Enjoying CodeBurn?")
+ .foregroundStyle(.primary)
+ Text("Star us on GitHub")
+ .foregroundStyle(Theme.brandAccent)
+ .underline(true, pattern: .solid)
+ }
+ .font(.system(size: 10.5, weight: .medium))
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+
+ Spacer()
+
+ Button {
+ dismissed = true
+ } label: {
+ Image(systemName: "xmark")
+ .font(.system(size: 9, weight: .semibold))
+ .foregroundStyle(.secondary)
+ .padding(4)
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ .help("Hide this banner")
+ }
+ .padding(.horizontal, 12)
+ .padding(.vertical, 6)
+ .background(Theme.brandAccent.opacity(0.08))
+ .overlay(alignment: .top) {
+ Rectangle()
+ .fill(Color.secondary.opacity(0.18))
+ .frame(height: 0.5)
+ }
+ }
+ }
+}
+
+struct FooterBar: View {
+ @Environment(AppStore.self) private var store
+
+ var body: some View {
+ HStack(spacing: 6) {
+ Menu {
+ ForEach(SupportedCurrency.allCases) { currency in
+ Button {
+ applyCurrency(code: currency.rawValue)
+ } label: {
+ if currency.rawValue == store.currency {
+ Label("\(currency.displayName) (\(currency.rawValue))", systemImage: "checkmark")
+ } else {
+ Text("\(currency.displayName) (\(currency.rawValue))")
+ }
+ }
+ }
+ } label: {
+ Label(store.currency, systemImage: "dollarsign.circle")
+ .font(.system(size: 11, weight: .medium))
+ .labelStyle(.titleAndIcon)
+ }
+ .menuStyle(.button)
+ .menuIndicator(.hidden)
+ .buttonStyle(.bordered)
+ .controlSize(.small)
+ .fixedSize()
+
+ Button {
+ Task { await store.refresh(includeOptimize: true) }
+ } label: {
+ Image(systemName: store.isLoading ? "arrow.triangle.2.circlepath" : "arrow.clockwise")
+ .font(.system(size: 11, weight: .medium))
+ }
+ .buttonStyle(.bordered)
+ .controlSize(.small)
+ .disabled(store.isLoading)
+
+ Menu {
+ Button("CSV (folder)") { runExport(format: .csv) }
+ Button("JSON") { runExport(format: .json) }
+ } label: {
+ Label("Export", systemImage: "square.and.arrow.down")
+ .font(.system(size: 11, weight: .medium))
+ .labelStyle(.titleAndIcon)
+ }
+ .menuStyle(.button)
+ .menuIndicator(.hidden)
+ .buttonStyle(.bordered)
+ .controlSize(.small)
+ .fixedSize()
+
+ Spacer()
+
+ Button { openReport() } label: {
+ Label("Open Full Report", systemImage: "terminal")
+ .font(.system(size: 11, weight: .semibold))
+ .labelStyle(.titleAndIcon)
+ }
+ .buttonStyle(.borderedProminent)
+ .controlSize(.small)
+ .tint(Theme.brandAccent)
+ }
+ .padding(.horizontal, 12)
+ .padding(.vertical, 8)
+ }
+
+ private func openReport() {
+ TerminalLauncher.open(subcommand: ["report"])
+ }
+
+ private enum ExportFormat {
+ case csv, json
+ var cliName: String { self == .csv ? "csv" : "json" }
+ var suffix: String { self == .csv ? "" : ".json" }
+ }
+
+ /// Runs `codeburn export` directly into ~/Downloads and reveals the result in Finder. CSV
+ /// produces a folder of clean one-table-per-file CSVs; JSON produces a single structured
+ /// file. The CLI is spawned with argv (no shell interpretation), so the output path cannot
+ /// be abused to inject shell commands even if a pathological value slips through.
+ private func runExport(format: ExportFormat) {
+ Task {
+ let downloads = (NSHomeDirectory() as NSString).appendingPathComponent("Downloads")
+ let formatter = DateFormatter()
+ formatter.dateFormat = "yyyy-MM-dd"
+ let base = "codeburn-\(formatter.string(from: Date()))"
+ let outputPath = (downloads as NSString).appendingPathComponent(base + format.suffix)
+
+ let process = CodeburnCLI.makeProcess(subcommand: [
+ "export", "-f", format.cliName, "-o", outputPath
+ ])
+
+ do {
+ try process.run()
+ process.waitUntilExit()
+ if process.terminationStatus == 0 {
+ NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: outputPath)])
+ } else {
+ NSLog("CodeBurn: \(format.cliName.uppercased()) export exited with status \(process.terminationStatus)")
+ }
+ } catch {
+ NSLog("CodeBurn: \(format.cliName.uppercased()) export failed: \(error)")
+ }
+ }
+ }
+
+ /// Instant-feeling currency switch. Updates the symbol and any cached FX rate on the main
+ /// thread right away so the UI redraws the next frame, then fetches a fresh rate in the
+ /// background. CLI config is persisted so other codeburn commands stay in sync.
+ private func applyCurrency(code: String) {
+ store.currency = code
+ let symbol = CurrencyState.symbolForCode(code)
+
+ Task {
+ let cached = await FXRateCache.shared.cachedRate(for: code)
+ await MainActor.run {
+ CurrencyState.shared.apply(code: code, rate: cached, symbol: symbol)
+ }
+
+ let fresh = await FXRateCache.shared.rate(for: code)
+ if let fresh, fresh != cached {
+ await MainActor.run {
+ CurrencyState.shared.apply(code: code, rate: fresh, symbol: symbol)
+ }
+ }
+ }
+
+ CLICurrencyConfig.persist(code: code)
+ }
+}
diff --git a/mac/Sources/CodeBurnMenubar/Views/ModelsSection.swift b/mac/Sources/CodeBurnMenubar/Views/ModelsSection.swift
new file mode 100644
index 0000000..cac5457
--- /dev/null
+++ b/mac/Sources/CodeBurnMenubar/Views/ModelsSection.swift
@@ -0,0 +1,97 @@
+import SwiftUI
+
+struct ModelsSection: View {
+ @Environment(AppStore.self) private var store
+ @State private var isExpanded: Bool = true
+
+ var body: some View {
+ CollapsibleSection(
+ caption: "Models",
+ isExpanded: $isExpanded,
+ trailing: {
+ HStack(spacing: 8) {
+ Text("Cost").frame(minWidth: 54, alignment: .trailing)
+ Text("Calls").frame(minWidth: 52, alignment: .trailing)
+ }
+ .font(.system(size: 10, weight: .medium))
+ .foregroundStyle(.tertiary)
+ .tracking(-0.05)
+ }
+ ) {
+ VStack(alignment: .leading, spacing: 7) {
+ let maxCost = store.payload.current.topModels.map(\.cost).max() ?? 1
+ ForEach(store.payload.current.topModels, id: \.name) { model in
+ ModelRow(model: model, maxCost: maxCost)
+ }
+
+ TokensLine()
+ .padding(.top, 5)
+ }
+ }
+ }
+}
+
+private struct ModelRow: View {
+ let model: ModelEntry
+ let maxCost: Double
+
+ var body: some View {
+ HStack(spacing: 8) {
+ FixedBar(fraction: model.cost / maxCost)
+ .frame(width: 56, height: 6)
+
+ Text(model.name)
+ .font(.system(size: 12.5, weight: .medium))
+ .frame(maxWidth: .infinity, alignment: .leading)
+
+ Text(model.cost.asCompactCurrency())
+ .font(.codeMono(size: 12, weight: .medium))
+ .tracking(-0.2)
+ .frame(minWidth: 54, alignment: .trailing)
+
+ Text("\(model.calls)")
+ .font(.system(size: 11))
+ .monospacedDigit()
+ .foregroundStyle(.secondary)
+ .frame(minWidth: 52, alignment: .trailing)
+ }
+ .padding(.horizontal, 2)
+ .padding(.vertical, 1)
+ }
+}
+
+private struct TokensLine: View {
+ @Environment(AppStore.self) private var store
+
+ var body: some View {
+ let t = store.payload.current
+ let cacheHit = String(format: "%.0f", t.cacheHitPercent)
+
+ HStack(spacing: 4) {
+ Text("Tokens")
+ .foregroundStyle(.tertiary)
+ Text(formatTokens(t.inputTokens) + " in")
+ .foregroundStyle(.secondary)
+ Text("·")
+ .foregroundStyle(.tertiary)
+ Text(formatTokens(t.outputTokens) + " out")
+ .foregroundStyle(.secondary)
+ Text("·")
+ .foregroundStyle(.tertiary)
+ Text(cacheHit + "% cache hit")
+ .foregroundStyle(.secondary)
+ Spacer()
+ }
+ .font(.system(size: 10.5))
+ .monospacedDigit()
+ }
+
+ private func formatTokens(_ n: Int) -> String {
+ if n >= 1_000_000 {
+ return String(format: "%.1fM", Double(n) / 1_000_000)
+ } else if n >= 1_000 {
+ return String(format: "%.1fK", Double(n) / 1_000)
+ }
+ return "\(n)"
+ }
+}
diff --git a/mac/Sources/CodeBurnMenubar/Views/PeriodSegmentedControl.swift b/mac/Sources/CodeBurnMenubar/Views/PeriodSegmentedControl.swift
new file mode 100644
index 0000000..a636932
--- /dev/null
+++ b/mac/Sources/CodeBurnMenubar/Views/PeriodSegmentedControl.swift
@@ -0,0 +1,36 @@
+import SwiftUI
+
+struct PeriodSegmentedControl: View {
+ @Environment(AppStore.self) private var store
+
+ var body: some View {
+ HStack(spacing: 1) {
+ ForEach(Period.allCases) { period in
+ Button {
+ Task { await store.switchTo(period: period) }
+ } label: {
+ Text(period.rawValue)
+ .font(.system(size: 11, weight: .medium))
+ .foregroundStyle(store.selectedPeriod == period ? AnyShapeStyle(.primary) : AnyShapeStyle(.secondary))
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 4)
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ .background(
+ RoundedRectangle(cornerRadius: 5)
+ .fill(store.selectedPeriod == period ? Color(NSColor.windowBackgroundColor).opacity(0.85) : .clear)
+ .shadow(color: .black.opacity(store.selectedPeriod == period ? 0.06 : 0), radius: 1, y: 0.5)
+ )
+ }
+ }
+ .padding(2)
+ .background(
+ RoundedRectangle(cornerRadius: 7)
+ .fill(Color.secondary.opacity(0.08))
+ )
+ .padding(.horizontal, 12)
+ .padding(.top, 6)
+ .padding(.bottom, 10)
+ }
+}
diff --git a/mac/Sources/CodeBurnMenubar/Views/SectionCaption.swift b/mac/Sources/CodeBurnMenubar/Views/SectionCaption.swift
new file mode 100644
index 0000000..1c4db4c
--- /dev/null
+++ b/mac/Sources/CodeBurnMenubar/Views/SectionCaption.swift
@@ -0,0 +1,85 @@
+import SwiftUI
+
+struct SectionCaption: View {
+ let text: String
+
+ var body: some View {
+ HStack(spacing: 5) {
+ Circle()
+ .fill(Theme.brandAccent.opacity(0.7))
+ .frame(width: 3, height: 3)
+ Text(text)
+ .font(.system(size: 11.5, weight: .medium))
+ .foregroundStyle(.secondary)
+ .tracking(-0.1)
+ }
+ }
+}
+
+/// Collapsible section shell with a clickable caption, optional inline trailing
+/// view (e.g. column headers), and a chevron.
+struct CollapsibleSection: View {
+ let caption: String
+ @Binding var isExpanded: Bool
+ let trailing: Trailing
+ let content: Content
+
+ init(
+ caption: String,
+ isExpanded: Binding,
+ @ViewBuilder trailing: () -> Trailing,
+ @ViewBuilder content: () -> Content
+ ) {
+ self.caption = caption
+ self._isExpanded = isExpanded
+ self.trailing = trailing()
+ self.content = content()
+ }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 7) {
+ Button {
+ withAnimation(.easeInOut(duration: 0.18)) {
+ isExpanded.toggle()
+ }
+ } label: {
+ HStack(spacing: 8) {
+ HStack(spacing: 5) {
+ Circle()
+ .fill(Theme.brandAccent.opacity(0.7))
+ .frame(width: 3, height: 3)
+ Text(caption)
+ .font(.system(size: 11.5, weight: .medium))
+ .tracking(-0.1)
+ }
+ Spacer()
+ trailing
+ Image(systemName: "chevron.right")
+ .font(.system(size: 9, weight: .semibold))
+ .rotationEffect(.degrees(isExpanded ? 90 : 0))
+ .opacity(0.55)
+ }
+ .foregroundStyle(.secondary)
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+
+ if isExpanded {
+ content
+ .transition(.opacity)
+ }
+ }
+ .padding(.horizontal, 14)
+ .padding(.vertical, 11)
+ }
+}
+
+extension CollapsibleSection where Trailing == EmptyView {
+ init(
+ caption: String,
+ isExpanded: Binding,
+ @ViewBuilder content: () -> Content
+ ) {
+ self.init(caption: caption, isExpanded: isExpanded, trailing: { EmptyView() }, content: content)
+ }
+}
diff --git a/mac/Sources/CodeBurnMenubar/Views/SparklineView.swift b/mac/Sources/CodeBurnMenubar/Views/SparklineView.swift
new file mode 100644
index 0000000..db7d7cc
--- /dev/null
+++ b/mac/Sources/CodeBurnMenubar/Views/SparklineView.swift
@@ -0,0 +1,99 @@
+import SwiftUI
+
+struct SparklineView: View {
+ let points: [Double]
+
+ var body: some View {
+ GeometryReader { geo in
+ let cgPoints = makePoints(in: geo.size)
+ let smooth = smoothPath(cgPoints)
+
+ ZStack {
+ // Gradient fill under the curve
+ let fill = closedPath(smooth, width: geo.size.width, height: geo.size.height)
+ fill.fill(
+ LinearGradient(
+ colors: [Theme.brandAccent.opacity(0.25), .clear],
+ startPoint: .top,
+ endPoint: .bottom
+ )
+ )
+
+ // Smooth accent stroke
+ smooth.stroke(
+ Theme.brandAccent.opacity(0.85),
+ style: StrokeStyle(lineWidth: 1.6, lineCap: .round, lineJoin: .round)
+ )
+
+ // Highlighted current-day point
+ if let last = cgPoints.last {
+ Circle()
+ .fill(Theme.brandAccent)
+ .frame(width: 6, height: 6)
+ .overlay(
+ Circle()
+ .stroke(Color(NSColor.windowBackgroundColor).opacity(0.9), lineWidth: 1.3)
+ )
+ .position(last)
+ }
+ }
+ }
+ }
+
+ // MARK: - Geometry
+
+ private func makePoints(in size: CGSize) -> [CGPoint] {
+ guard !points.isEmpty else { return [] }
+ let w = size.width
+ let h = size.height
+ let maxV = points.max() ?? 1
+ let minV = points.min() ?? 0
+ let range = max(maxV - minV, 1)
+ let count = max(points.count - 1, 1)
+ let topPad: CGFloat = 5
+ let bottomPad: CGFloat = 5
+ let usable = max(h - topPad - bottomPad, 1)
+
+ return points.enumerated().map { idx, v in
+ CGPoint(
+ x: w * CGFloat(idx) / CGFloat(count),
+ y: h - bottomPad - usable * CGFloat(v - minV) / CGFloat(range)
+ )
+ }
+ }
+
+ /// Catmull-Rom → cubic bezier. Standard smooth interpolation, no overshoot.
+ private func smoothPath(_ pts: [CGPoint]) -> Path {
+ var path = Path()
+ guard pts.count >= 2 else { return path }
+ path.move(to: pts[0])
+
+ let tension: CGFloat = 0.5
+ for i in 0..<(pts.count - 1) {
+ let p0 = i > 0 ? pts[i - 1] : pts[i]
+ let p1 = pts[i]
+ let p2 = pts[i + 1]
+ let p3 = i + 2 < pts.count ? pts[i + 2] : p2
+
+ let cp1 = CGPoint(
+ x: p1.x + (p2.x - p0.x) * tension / 3,
+ y: p1.y + (p2.y - p0.y) * tension / 3
+ )
+ let cp2 = CGPoint(
+ x: p2.x - (p3.x - p1.x) * tension / 3,
+ y: p2.y - (p3.y - p1.y) * tension / 3
+ )
+ path.addCurve(to: p2, control1: cp1, control2: cp2)
+ }
+ return path
+ }
+
+ /// Close the path along the bottom to form a fill region.
+ private func closedPath(_ line: Path, width: CGFloat, height: CGFloat) -> Path {
+ var p = line
+ p.addLine(to: CGPoint(x: width, y: height))
+ p.addLine(to: CGPoint(x: 0, y: height))
+ p.closeSubpath()
+ return p
+ }
+}
diff --git a/mac/Tests/CodeBurnMenubarTests/CapacityEstimatorTests.swift b/mac/Tests/CodeBurnMenubarTests/CapacityEstimatorTests.swift
new file mode 100644
index 0000000..b23ba5f
--- /dev/null
+++ b/mac/Tests/CodeBurnMenubarTests/CapacityEstimatorTests.swift
@@ -0,0 +1,158 @@
+import Foundation
+import Testing
+@testable import CodeBurnMenubar
+
+private let now = Date(timeIntervalSince1970: 1_734_000_000)
+
+private func snap(_ percent: Double, _ tokens: Double, ageDays: Double = 0) -> CapacitySnapshot {
+ CapacitySnapshot(
+ percent: percent,
+ effectiveTokens: tokens,
+ capturedAt: now.addingTimeInterval(-ageDays * 86400)
+ )
+}
+
+@Suite("CapacityEstimator -- gating")
+struct CapacityEstimatorGatingTests {
+ @Test("returns nil with no snapshots")
+ func emptyReturnsNil() {
+ #expect(CapacityEstimator.estimate([], asOf: now) == nil)
+ }
+
+ @Test("returns nil with fewer than 5 snapshots")
+ func tooFewReturnsNil() {
+ let snaps = (1...4).map { snap(Double($0 * 10), Double($0) * 100_000) }
+ #expect(CapacityEstimator.estimate(snaps, asOf: now) == nil)
+ }
+
+ @Test("returns nil when percent range is below 15 points")
+ func tooNarrowReturnsNil() {
+ let snaps = [
+ snap(40, 4_000_000),
+ snap(42, 4_200_000),
+ snap(44, 4_400_000),
+ snap(46, 4_600_000),
+ snap(48, 4_800_000),
+ snap(50, 5_000_000),
+ ]
+ #expect(CapacityEstimator.estimate(snaps, asOf: now) == nil)
+ }
+}
+
+@Suite("CapacityEstimator -- recovery")
+struct CapacityEstimatorRecoveryTests {
+ @Test("recovers capacity from 10 noise-free snapshots within 0.5%")
+ func recoverFromCleanData() {
+ let trueCapacity: Double = 10_000_000
+ let percents = [5.0, 12, 20, 28, 35, 47, 55, 68, 80, 92]
+ let snaps = percents.map { p in snap(p, p / 100 * trueCapacity) }
+ let est = CapacityEstimator.estimate(snaps, asOf: now)
+ #expect(est != nil)
+ #expect(est!.capacity > trueCapacity * 0.995)
+ #expect(est!.capacity < trueCapacity * 1.005)
+ // 10 perfect samples is below the solid sample threshold (15) but easily medium.
+ #expect(est!.confidence == .medium || est!.confidence == .solid)
+ }
+
+ @Test("recovers capacity within 5% from 30 noisy snapshots")
+ func recoverFromNoisyData() {
+ let trueCapacity: Double = 8_000_000
+ var rng = LinearCongruentialGenerator(seed: 42)
+ let snaps: [CapacitySnapshot] = (0..<30).map { i in
+ let p = 5.0 + Double(i) * 3.0 // 5..92, spanning enough
+ let noise = (rng.nextDouble() - 0.5) * 0.10 // ±5%
+ let tokens = (p / 100) * trueCapacity * (1 + noise)
+ return snap(p, tokens)
+ }
+ let est = CapacityEstimator.estimate(snaps, asOf: now)
+ #expect(est != nil)
+ let ratio = est!.capacity / trueCapacity
+ #expect(ratio > 0.95 && ratio < 1.05)
+ #expect(est!.confidence == .solid || est!.confidence == .medium)
+ }
+}
+
+@Suite("CapacityEstimator -- confidence tiers")
+struct CapacityEstimatorConfidenceTests {
+ @Test("six clean snapshots span sufficient range -> at least medium")
+ func sixCleanSnapshotsMedium() {
+ let trueCapacity: Double = 5_000_000
+ let percents = [5.0, 18, 32, 51, 70, 88]
+ let snaps = percents.map { p in snap(p, p / 100 * trueCapacity) }
+ let est = CapacityEstimator.estimate(snaps, asOf: now)
+ #expect(est != nil)
+ #expect(est!.confidence == .medium || est!.confidence == .solid)
+ }
+
+ @Test("noisy small-sample data falls to low confidence")
+ func noisySmallSampleLow() {
+ let trueCapacity: Double = 5_000_000
+ var rng = LinearCongruentialGenerator(seed: 7)
+ let percents = [5.0, 22, 40, 60, 80, 95]
+ let snaps: [CapacitySnapshot] = percents.map { p in
+ let noise = (rng.nextDouble() - 0.5) * 1.6 // ±80% noise -> drops R^2 below medium gate
+ return snap(p, p / 100 * trueCapacity * (1 + noise))
+ }
+ let est = CapacityEstimator.estimate(snaps, asOf: now)
+ #expect(est != nil)
+ #expect(est!.confidence == .low)
+ }
+}
+
+@Suite("CapacityEstimator -- recency weighting")
+struct CapacityEstimatorRecencyTests {
+ @Test("recent snapshots dominate over old ones with different capacity")
+ func recencyShiftsEstimate() {
+ // Old data: capacity = 5M (45 days ago)
+ // New data: capacity = 10M (today)
+ // With 30-day half-life, recent data should win.
+ let oldSnaps = (0..<10).map { i -> CapacitySnapshot in
+ let p = 10.0 + Double(i) * 8
+ return snap(p, p / 100 * 5_000_000, ageDays: 45)
+ }
+ let newSnaps = (0..<10).map { i -> CapacitySnapshot in
+ let p = 10.0 + Double(i) * 8
+ return snap(p, p / 100 * 10_000_000, ageDays: 1)
+ }
+ let est = CapacityEstimator.estimate(oldSnaps + newSnaps, asOf: now)
+ #expect(est != nil)
+ // Recent capacity is 10M; estimate should be closer to 10M than 5M.
+ #expect(est!.capacity > 7_500_000)
+ }
+}
+
+@Suite("CapacityEstimator -- non-linearity")
+struct CapacityEstimatorNonLinearityTests {
+ @Test("flags non-linearity when residuals show systematic sign pattern")
+ func detectsKneePattern() {
+ // Data follows a knee: linear up to 60%, then flatter (Anthropic capping).
+ let snaps: [CapacitySnapshot] = (0..<20).map { i in
+ let p = 5.0 + Double(i) * 5
+ let tokens: Double = p < 60 ? p / 100 * 8_000_000 : 0.6 * 8_000_000 + (p - 60) / 100 * 4_000_000
+ return snap(p, tokens)
+ }
+ let est = CapacityEstimator.estimate(snaps, asOf: now)
+ #expect(est != nil)
+ #expect(est!.nonLinearityWarning == true)
+ }
+
+ @Test("does not flag clean linear data")
+ func cleanLinearNoFlag() {
+ let trueCapacity: Double = 6_000_000
+ let percents = stride(from: 5.0, to: 95.0, by: 5.0).map { $0 }
+ let snaps = percents.map { p in snap(p, p / 100 * trueCapacity) }
+ let est = CapacityEstimator.estimate(snaps, asOf: now)
+ #expect(est != nil)
+ #expect(est!.nonLinearityWarning == false)
+ }
+}
+
+// Lightweight deterministic RNG for reproducible noise in tests.
+struct LinearCongruentialGenerator {
+ private var state: UInt64
+ init(seed: UInt64) { self.state = seed }
+ mutating func nextDouble() -> Double {
+ state = state &* 6364136223846793005 &+ 1442695040888963407
+ return Double(state >> 11) / Double(1 << 53)
+ }
+}
diff --git a/package-lock.json b/package-lock.json
index c4ffcb6..a406801 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "codeburn",
- "version": "0.7.1",
+ "version": "0.7.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "codeburn",
- "version": "0.7.1",
+ "version": "0.7.2",
"license": "MIT",
"dependencies": {
"chalk": "^5.4.1",
diff --git a/package.json b/package.json
index 06932e4..d7046b1 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "codeburn",
- "version": "0.7.1",
+ "version": "0.7.2",
"description": "See where your AI coding tokens go - by task, tool, model, and project",
"type": "module",
"main": "./dist/cli.js",
diff --git a/src/cli.ts b/src/cli.ts
index 342a9f2..ac87127 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -1,13 +1,17 @@
import { Command } from 'commander'
+import { installMenubarApp } from './menubar-installer.js'
import { exportCsv, exportJson, type PeriodExport } from './export.js'
import { loadPricing } from './models.js'
import { parseAllSessions, filterProjectsByName } from './parser.js'
import { convertCost } from './currency.js'
import { renderStatusBar } from './format.js'
-import { installMenubar, renderMenubarFormat, type PeriodData, type ProviderCost, uninstallMenubar } from './menubar.js'
+import { type PeriodData, type ProviderCost } from './menubar-json.js'
+import { buildMenubarPayload } from './menubar-json.js'
+import { addNewDays, getDaysInRange, loadDailyCache, saveDailyCache, withDailyCacheLock } from './daily-cache.js'
+import { aggregateProjectsIntoDays, buildPeriodDataFromDays } from './day-aggregator.js'
import { CATEGORY_LABELS, type DateRange, type ProjectSummary, type TaskCategory } from './types.js'
import { renderDashboard } from './dashboard.js'
-import { runOptimize } from './optimize.js'
+import { runOptimize, scanAndDetect } from './optimize.js'
import { getAllProviders } from './providers/index.js'
import { readConfig, saveConfig, getConfigFilePath } from './config.js'
import { createRequire } from 'node:module'
@@ -16,6 +20,13 @@ const require = createRequire(import.meta.url)
const { version } = require('../package.json')
import { loadCurrency, getCurrency, isValidCurrencyCode } from './currency.js'
+const MS_PER_DAY = 24 * 60 * 60 * 1000
+const BACKFILL_DAYS = 365
+
+function toDateString(date: Date): string {
+ return date.toISOString().slice(0, 10)
+}
+
function getDateRange(period: string): { range: DateRange; label: string } {
const now = new Date()
const end = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59, 999)
@@ -43,7 +54,11 @@ function getDateRange(period: string): { range: DateRange; label: string } {
return { range: { start, end }, label: 'Last 30 Days' }
}
case 'all': {
- return { range: { start: new Date(0), end }, label: 'All Time' }
+ // Cap "All Time" to the last 6 months. Older data is rarely actionable for a cost
+ // tracker and keeps the parse path bounded so providers like Codex/Cursor with sparse
+ // data still load in seconds.
+ const start = new Date(now.getFullYear(), now.getMonth() - 6, now.getDate())
+ return { range: { start, end }, label: 'Last 6 months' }
}
default: {
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 7)
@@ -98,8 +113,10 @@ function buildJsonReport(projects: ProjectSummary[], period: string, periodKey:
const totalOutput = sessions.reduce((s, sess) => s + sess.totalOutputTokens, 0)
const totalCacheRead = sessions.reduce((s, sess) => s + sess.totalCacheReadTokens, 0)
const totalCacheWrite = sessions.reduce((s, sess) => s + sess.totalCacheWriteTokens, 0)
- const allInput = totalInput + totalCacheRead + totalCacheWrite
- const cacheHitPercent = allInput > 0 ? Math.round((totalCacheRead / allInput) * 1000) / 10 : 0
+ // Match src/menubar-json.ts:cacheHitPercent: reads over reads+fresh-input. cache_write
+ // counts tokens being stored, not served, so it doesn't belong in the denominator.
+ const cacheHitDenom = totalInput + totalCacheRead
+ const cacheHitPercent = cacheHitDenom > 0 ? Math.round((totalCacheRead / cacheHitDenom) * 1000) / 10 : 0
const dailyMap: Record = {}
for (const sess of sessions) {
@@ -262,6 +279,7 @@ function buildPeriodData(label: string, projects: ProjectSummary[]): PeriodData
label,
cost: projects.reduce((s, p) => s + p.totalCostUSD, 0),
calls: projects.reduce((s, p) => s + p.totalApiCalls, 0),
+ sessions: projects.reduce((s, p) => s + p.sessions.length, 0),
inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens,
categories: Object.entries(catTotals)
.sort(([, a], [, b]) => b.cost - a.cost)
@@ -275,27 +293,148 @@ function buildPeriodData(label: string, projects: ProjectSummary[]): PeriodData
program
.command('status')
.description('Compact status output (today + week + month)')
- .option('--format ', 'Output format: terminal, menubar, json', 'terminal')
+ .option('--format ', 'Output format: terminal, menubar-json, json', 'terminal')
.option('--provider ', 'Filter by provider: all, claude, codex, cursor', 'all')
.option('--project ', 'Show only projects matching name (repeatable)', collect, [])
.option('--exclude ', 'Exclude projects matching name (repeatable)', collect, [])
+ .option('--period ', 'Primary period for menubar-json: today, week, 30days, month, all', 'today')
+ .option('--no-optimize', 'Skip optimize findings (menubar-json only, faster)')
.action(async (opts) => {
await loadPricing()
const pf = opts.provider
const fp = (p: ProjectSummary[]) => filterProjectsByName(p, opts.project, opts.exclude)
- if (opts.format === 'menubar') {
- const todayRange = getDateRange('today').range
- const todayData = buildPeriodData('Today', fp(await parseAllSessions(todayRange, pf)))
- const weekData = buildPeriodData('7 Days', fp(await parseAllSessions(getDateRange('week').range, pf)))
- const thirtyDayData = buildPeriodData('30 Days', fp(await parseAllSessions(getDateRange('30days').range, pf)))
- const monthData = buildPeriodData('Month', fp(await parseAllSessions(getDateRange('month').range, pf)))
- const todayProviders: ProviderCost[] = []
- for (const p of await getAllProviders()) {
- const data = fp(await parseAllSessions(todayRange, p.name))
- const cost = data.reduce((s, proj) => s + proj.totalCostUSD, 0)
- if (cost > 0) todayProviders.push({ name: p.displayName, cost })
+ if (opts.format === 'menubar-json') {
+ const periodInfo = getDateRange(opts.period)
+ const now = new Date()
+ const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())
+ const yesterdayEnd = new Date(todayStart.getTime() - 1)
+ const yesterdayStr = toDateString(new Date(todayStart.getTime() - MS_PER_DAY))
+ const isAllProviders = pf === 'all'
+
+ // The daily cache is provider-agnostic: always backfill it from .all so subsequent
+ // provider-filtered reads can derive per-provider cost+calls from DailyEntry.providers.
+ const cache = await withDailyCacheLock(async () => {
+ let c = await loadDailyCache()
+ const gapStart = c.lastComputedDate
+ ? new Date(new Date(`${c.lastComputedDate}T00:00:00.000Z`).getTime() + MS_PER_DAY)
+ : new Date(todayStart.getTime() - BACKFILL_DAYS * MS_PER_DAY)
+
+ if (gapStart.getTime() <= yesterdayEnd.getTime()) {
+ const gapRange: DateRange = { start: gapStart, end: yesterdayEnd }
+ const gapProjects = filterProjectsByName(await parseAllSessions(gapRange, 'all'), opts.project, opts.exclude)
+ const gapDays = aggregateProjectsIntoDays(gapProjects)
+ c = addNewDays(c, gapDays, yesterdayStr)
+ await saveDailyCache(c)
+ }
+ return c
+ })
+
+ // CURRENT PERIOD DATA
+ // - .all provider: assemble from cache + today (fast)
+ // - specific provider: parse the period range with provider filter (correct, but slower)
+ let currentData: PeriodData
+ let scanProjects: ProjectSummary[]
+ let scanRange: DateRange
+
+ if (isAllProviders) {
+ const todayRange: DateRange = { start: todayStart, end: now }
+ const todayProjects = fp(await parseAllSessions(todayRange, 'all'))
+ const todayDays = aggregateProjectsIntoDays(todayProjects)
+ const rangeStartStr = toDateString(periodInfo.range.start)
+ const rangeEndStr = toDateString(periodInfo.range.end)
+ const historicalDays = getDaysInRange(cache, rangeStartStr, yesterdayStr)
+ const todayInRange = todayDays.filter(d => d.date >= rangeStartStr && d.date <= rangeEndStr)
+ const allDays = [...historicalDays, ...todayInRange].sort((a, b) => a.date.localeCompare(b.date))
+ currentData = buildPeriodDataFromDays(allDays, periodInfo.label)
+ scanProjects = todayProjects
+ scanRange = todayRange
+ } else {
+ const projects = fp(await parseAllSessions(periodInfo.range, pf))
+ currentData = buildPeriodData(periodInfo.label, projects)
+ scanProjects = projects
+ scanRange = periodInfo.range
}
- console.log(renderMenubarFormat(todayData, weekData, thirtyDayData, monthData, todayProviders))
+
+ // PROVIDERS
+ // For .all: enumerate every provider with cost across the period (from cache) + installed-but-zero.
+ // For specific: just this single provider with its scoped cost.
+ const allProviders = await getAllProviders()
+ const displayNameByName = new Map(allProviders.map(p => [p.name, p.displayName]))
+ const providers: ProviderCost[] = []
+ if (isAllProviders) {
+ const todayRangeForProviders: DateRange = { start: todayStart, end: now }
+ const todayDaysForProviders = aggregateProjectsIntoDays(fp(await parseAllSessions(todayRangeForProviders, 'all')))
+ const rangeStartStr = toDateString(periodInfo.range.start)
+ const allDaysForProviders = [
+ ...getDaysInRange(cache, rangeStartStr, yesterdayStr),
+ ...todayDaysForProviders.filter(d => d.date >= rangeStartStr),
+ ]
+ const providerTotals: Record = {}
+ for (const d of allDaysForProviders) {
+ for (const [name, p] of Object.entries(d.providers)) {
+ providerTotals[name] = (providerTotals[name] ?? 0) + p.cost
+ }
+ }
+ for (const [name, cost] of Object.entries(providerTotals)) {
+ providers.push({ name: displayNameByName.get(name) ?? name, cost })
+ }
+ for (const p of allProviders) {
+ if (providers.some(pc => pc.name === p.displayName)) continue
+ const sources = await p.discoverSessions()
+ if (sources.length > 0) providers.push({ name: p.displayName, cost: 0 })
+ }
+ } else {
+ const display = displayNameByName.get(pf) ?? pf
+ providers.push({ name: display, cost: currentData.cost })
+ }
+
+ // DAILY HISTORY (last 365 days)
+ // Cache stores per-provider cost+calls per day in DailyEntry.providers, so we can derive
+ // a provider-filtered history without re-parsing. Tokens aren't broken down per provider
+ // in the cache, so the filtered view shows zero tokens (heatmap/trend still works on cost).
+ const historyStartStr = toDateString(new Date(todayStart.getTime() - BACKFILL_DAYS * MS_PER_DAY))
+ const allCacheDays = getDaysInRange(cache, historyStartStr, yesterdayStr)
+ const allTodayDaysForHistory = aggregateProjectsIntoDays(fp(await parseAllSessions({ start: todayStart, end: now }, 'all')))
+ const fullHistory = [...allCacheDays, ...allTodayDaysForHistory]
+ const dailyHistory = fullHistory.map(d => {
+ if (isAllProviders) {
+ const topModels = Object.entries(d.models)
+ .filter(([name]) => name !== '')
+ .sort(([, a], [, b]) => b.cost - a.cost)
+ .slice(0, 5)
+ .map(([name, m]) => ({
+ name,
+ cost: m.cost,
+ calls: m.calls,
+ inputTokens: m.inputTokens,
+ outputTokens: m.outputTokens,
+ }))
+ return {
+ date: d.date,
+ cost: d.cost,
+ calls: d.calls,
+ inputTokens: d.inputTokens,
+ outputTokens: d.outputTokens,
+ cacheReadTokens: d.cacheReadTokens,
+ cacheWriteTokens: d.cacheWriteTokens,
+ topModels,
+ }
+ }
+ const prov = d.providers[pf] ?? { calls: 0, cost: 0 }
+ return {
+ date: d.date,
+ cost: prov.cost,
+ calls: prov.calls,
+ inputTokens: 0,
+ outputTokens: 0,
+ cacheReadTokens: 0,
+ cacheWriteTokens: 0,
+ topModels: [],
+ }
+ })
+
+ const optimize = opts.optimize === false ? null : await scanAndDetect(scanProjects, scanRange)
+ console.log(JSON.stringify(buildMenubarPayload(currentData, providers, optimize, dailyHistory)))
return
}
@@ -374,29 +513,37 @@ program
const outputPath = opts.output ?? `${defaultName}.${opts.format}`
let savedPath: string
- if (opts.format === 'json') {
- savedPath = await exportJson(periods, outputPath)
- } else {
- savedPath = await exportCsv(periods, outputPath)
+ try {
+ if (opts.format === 'json') {
+ savedPath = await exportJson(periods, outputPath)
+ } else {
+ savedPath = await exportCsv(periods, outputPath)
+ }
+ } catch (err) {
+ // Protection guards in export.ts (symlink refusal, non-codeburn folder refusal, etc.)
+ // throw with a user-readable message. Print just the message, not the stack, so the CLI
+ // doesn't spray its internals at the user.
+ const message = err instanceof Error ? err.message : String(err)
+ console.error(`\n Export failed: ${message}\n`)
+ process.exit(1)
}
console.log(`\n Exported (Today + 7 Days + 30 Days) to: ${savedPath}\n`)
})
program
- .command('install-menubar')
- .description('Install macOS menu bar plugin (SwiftBar/xbar)')
- .action(async () => {
- const result = await installMenubar()
- console.log(result)
- })
-
-program
- .command('uninstall-menubar')
- .description('Remove macOS menu bar plugin')
- .action(async () => {
- const result = await uninstallMenubar()
- console.log(result)
+ .command('menubar')
+ .description('Install and launch the macOS menubar app (one command, no clone)')
+ .option('--force', 'Reinstall even if an older copy is already in ~/Applications')
+ .action(async (opts: { force?: boolean }) => {
+ try {
+ const result = await installMenubarApp({ force: opts.force })
+ console.log(`\n Ready. ${result.installedPath}\n`)
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err)
+ console.error(`\n Menubar install failed: ${message}\n`)
+ process.exit(1)
+ }
})
program
diff --git a/src/currency.ts b/src/currency.ts
index 53bdb1f..8788e07 100644
--- a/src/currency.ts
+++ b/src/currency.ts
@@ -12,6 +12,17 @@ type CurrencyState = {
const CACHE_TTL_MS = 24 * 60 * 60 * 1000
const FRANKFURTER_URL = 'https://api.frankfurter.app/latest?from=USD&to='
+// Defensive bounds on any fetched FX rate. Outside this band the rate is either a parser bug
+// or a tampered Frankfurter response, and we refuse to multiply it into displayed costs.
+const MIN_VALID_FX_RATE = 0.0001
+const MAX_VALID_FX_RATE = 1_000_000
+
+function isValidRate(value: unknown): value is number {
+ return typeof value === 'number'
+ && Number.isFinite(value)
+ && value >= MIN_VALID_FX_RATE
+ && value <= MAX_VALID_FX_RATE
+}
let active: CurrencyState = { code: 'USD', rate: 1, symbol: '$' }
@@ -54,18 +65,22 @@ function getRateCachePath(): string {
async function fetchRate(code: string): Promise {
const response = await fetch(`${FRANKFURTER_URL}${code}`)
if (!response.ok) throw new Error(`HTTP ${response.status}`)
- const data = await response.json() as { rates: Record }
- const rate = data.rates[code]
- if (!rate) throw new Error(`No rate returned for ${code}`)
+ const data = await response.json() as { rates?: Record }
+ const rate = data.rates?.[code]
+ if (!isValidRate(rate)) throw new Error(`Invalid rate returned for ${code}`)
return rate
}
async function loadCachedRate(code: string): Promise {
try {
const raw = await readFile(getRateCachePath(), 'utf-8')
- const cached = JSON.parse(raw) as { timestamp: number; code: string; rate: number }
- if (cached.code !== code) return null
+ const cached = JSON.parse(raw) as Partial<{ timestamp: number; code: string; rate: number }>
+ // Validate every field -- a tampered cache file could set rate to a string, null, or
+ // Infinity and break downstream math silently.
+ if (typeof cached.code !== 'string' || cached.code !== code) return null
+ if (typeof cached.timestamp !== 'number' || !Number.isFinite(cached.timestamp)) return null
if (Date.now() - cached.timestamp > CACHE_TTL_MS) return null
+ if (!isValidRate(cached.rate)) return null
return cached.rate
} catch {
return null
diff --git a/src/daily-cache.ts b/src/daily-cache.ts
new file mode 100644
index 0000000..1320aa6
--- /dev/null
+++ b/src/daily-cache.ts
@@ -0,0 +1,118 @@
+import { randomBytes } from 'crypto'
+import { existsSync } from 'fs'
+import { mkdir, open, readFile, rename, unlink } from 'fs/promises'
+import { homedir } from 'os'
+import { join } from 'path'
+
+export const DAILY_CACHE_VERSION = 2
+const DAILY_CACHE_FILENAME = 'daily-cache.json'
+
+export type DailyEntry = {
+ date: string
+ cost: number
+ calls: number
+ sessions: number
+ inputTokens: number
+ outputTokens: number
+ cacheReadTokens: number
+ cacheWriteTokens: number
+ editTurns: number
+ oneShotTurns: number
+ models: Record
+ categories: Record
+ providers: Record
+}
+
+export type DailyCache = {
+ version: number
+ lastComputedDate: string | null
+ days: DailyEntry[]
+}
+
+function getCacheDir(): string {
+ return process.env['CODEBURN_CACHE_DIR'] ?? join(homedir(), '.cache', 'codeburn')
+}
+
+function getCachePath(): string {
+ return join(getCacheDir(), DAILY_CACHE_FILENAME)
+}
+
+function emptyCache(): DailyCache {
+ return { version: DAILY_CACHE_VERSION, lastComputedDate: null, days: [] }
+}
+
+function isValidCache(parsed: unknown): parsed is DailyCache {
+ if (!parsed || typeof parsed !== 'object') return false
+ const c = parsed as Partial
+ if (c.version !== DAILY_CACHE_VERSION) return false
+ if (!Array.isArray(c.days)) return false
+ return true
+}
+
+export async function loadDailyCache(): Promise {
+ const path = getCachePath()
+ if (!existsSync(path)) return emptyCache()
+ try {
+ const raw = await readFile(path, 'utf-8')
+ const parsed: unknown = JSON.parse(raw)
+ if (!isValidCache(parsed)) return emptyCache()
+ return parsed
+ } catch {
+ return emptyCache()
+ }
+}
+
+export async function saveDailyCache(cache: DailyCache): Promise {
+ const dir = getCacheDir()
+ if (!existsSync(dir)) await mkdir(dir, { recursive: true })
+ const finalPath = getCachePath()
+ const tempPath = `${finalPath}.${randomBytes(8).toString('hex')}.tmp`
+ const payload = JSON.stringify(cache)
+ const handle = await open(tempPath, 'w', 0o600)
+ try {
+ await handle.writeFile(payload, { encoding: 'utf-8' })
+ await handle.sync()
+ } finally {
+ await handle.close()
+ }
+ try {
+ await rename(tempPath, finalPath)
+ } catch (err) {
+ try { await unlink(tempPath) } catch { /* ignore */ }
+ throw err
+ }
+}
+
+export function addNewDays(cache: DailyCache, incoming: DailyEntry[], newestDate: string): DailyCache {
+ const seen = new Set(cache.days.map(d => d.date))
+ const merged = [...cache.days]
+ for (const day of incoming) {
+ if (seen.has(day.date)) continue
+ seen.add(day.date)
+ merged.push(day)
+ }
+ merged.sort((a, b) => a.date.localeCompare(b.date))
+ const nextLast = cache.lastComputedDate && cache.lastComputedDate > newestDate
+ ? cache.lastComputedDate
+ : newestDate
+ return { version: DAILY_CACHE_VERSION, lastComputedDate: nextLast, days: merged }
+}
+
+export function getDaysInRange(cache: DailyCache, start: string, end: string): DailyEntry[] {
+ return cache.days.filter(d => d.date >= start && d.date <= end)
+}
+
+let lockChain: Promise = Promise.resolve()
+
+export function withDailyCacheLock(fn: () => Promise): Promise {
+ const next = lockChain.then(() => fn())
+ lockChain = next.catch(() => undefined)
+ return next
+}
diff --git a/src/day-aggregator.ts b/src/day-aggregator.ts
new file mode 100644
index 0000000..5030f8d
--- /dev/null
+++ b/src/day-aggregator.ts
@@ -0,0 +1,142 @@
+import type { DailyEntry } from './daily-cache.js'
+import type { PeriodData } from './menubar-json.js'
+import { CATEGORY_LABELS, type ProjectSummary, type TaskCategory } from './types.js'
+
+function emptyEntry(date: string): DailyEntry {
+ return {
+ date,
+ cost: 0,
+ calls: 0,
+ sessions: 0,
+ inputTokens: 0,
+ outputTokens: 0,
+ cacheReadTokens: 0,
+ cacheWriteTokens: 0,
+ editTurns: 0,
+ oneShotTurns: 0,
+ models: {},
+ categories: {},
+ providers: {},
+ }
+}
+
+function dateKey(iso: string): string {
+ return iso.slice(0, 10)
+}
+
+export function aggregateProjectsIntoDays(projects: ProjectSummary[]): DailyEntry[] {
+ const byDate = new Map()
+ const ensure = (date: string): DailyEntry => {
+ let d = byDate.get(date)
+ if (!d) { d = emptyEntry(date); byDate.set(date, d) }
+ return d
+ }
+
+ for (const project of projects) {
+ for (const session of project.sessions) {
+ const sessionDate = dateKey(session.firstTimestamp)
+ ensure(sessionDate).sessions += 1
+
+ for (const turn of session.turns) {
+ if (turn.assistantCalls.length === 0) continue
+ const turnDate = dateKey(turn.assistantCalls[0]!.timestamp)
+ const turnDay = ensure(turnDate)
+
+ const editTurns = turn.hasEdits ? 1 : 0
+ const oneShotTurns = turn.hasEdits && turn.retries === 0 ? 1 : 0
+ const turnCost = turn.assistantCalls.reduce((s, c) => s + c.costUSD, 0)
+
+ turnDay.editTurns += editTurns
+ turnDay.oneShotTurns += oneShotTurns
+
+ const cat = turnDay.categories[turn.category] ?? { turns: 0, cost: 0, editTurns: 0, oneShotTurns: 0 }
+ cat.turns += 1
+ cat.cost += turnCost
+ cat.editTurns += editTurns
+ cat.oneShotTurns += oneShotTurns
+ turnDay.categories[turn.category] = cat
+
+ for (const call of turn.assistantCalls) {
+ const callDate = dateKey(call.timestamp)
+ const callDay = ensure(callDate)
+
+ callDay.cost += call.costUSD
+ callDay.calls += 1
+ callDay.inputTokens += call.usage.inputTokens
+ callDay.outputTokens += call.usage.outputTokens
+ callDay.cacheReadTokens += call.usage.cacheReadInputTokens
+ callDay.cacheWriteTokens += call.usage.cacheCreationInputTokens
+
+ const model = callDay.models[call.model] ?? {
+ calls: 0, cost: 0,
+ inputTokens: 0, outputTokens: 0,
+ cacheReadTokens: 0, cacheWriteTokens: 0,
+ }
+ model.calls += 1
+ model.cost += call.costUSD
+ model.inputTokens += call.usage.inputTokens
+ model.outputTokens += call.usage.outputTokens
+ model.cacheReadTokens += call.usage.cacheReadInputTokens
+ model.cacheWriteTokens += call.usage.cacheCreationInputTokens
+ callDay.models[call.model] = model
+
+ const provider = callDay.providers[call.provider] ?? { calls: 0, cost: 0 }
+ provider.calls += 1
+ provider.cost += call.costUSD
+ callDay.providers[call.provider] = provider
+ }
+ }
+ }
+ }
+
+ return [...byDate.values()].sort((a, b) => a.date.localeCompare(b.date))
+}
+
+export function buildPeriodDataFromDays(days: DailyEntry[], label: string): PeriodData {
+ let cost = 0, calls = 0, sessions = 0
+ let inputTokens = 0, outputTokens = 0, cacheReadTokens = 0, cacheWriteTokens = 0
+ const catTotals: Record = {}
+ const modelTotals: Record = {}
+
+ for (const d of days) {
+ cost += d.cost
+ calls += d.calls
+ sessions += d.sessions
+ inputTokens += d.inputTokens
+ outputTokens += d.outputTokens
+ cacheReadTokens += d.cacheReadTokens
+ cacheWriteTokens += d.cacheWriteTokens
+
+ for (const [name, m] of Object.entries(d.models)) {
+ const acc = modelTotals[name] ?? { calls: 0, cost: 0 }
+ acc.calls += m.calls
+ acc.cost += m.cost
+ modelTotals[name] = acc
+ }
+ for (const [cat, c] of Object.entries(d.categories)) {
+ const acc = catTotals[cat] ?? { turns: 0, cost: 0, editTurns: 0, oneShotTurns: 0 }
+ acc.turns += c.turns
+ acc.cost += c.cost
+ acc.editTurns += c.editTurns
+ acc.oneShotTurns += c.oneShotTurns
+ catTotals[cat] = acc
+ }
+ }
+
+ return {
+ label,
+ cost,
+ calls,
+ sessions,
+ inputTokens,
+ outputTokens,
+ cacheReadTokens,
+ cacheWriteTokens,
+ categories: Object.entries(catTotals)
+ .sort(([, a], [, b]) => b.cost - a.cost)
+ .map(([cat, d]) => ({ name: CATEGORY_LABELS[cat as TaskCategory] ?? cat, ...d })),
+ models: Object.entries(modelTotals)
+ .sort(([, a], [, b]) => b.cost - a.cost)
+ .map(([name, d]) => ({ name, ...d })),
+ }
+}
diff --git a/src/export.ts b/src/export.ts
index c1940aa..d07f08d 100644
--- a/src/export.ts
+++ b/src/export.ts
@@ -1,8 +1,8 @@
-import { writeFile } from 'fs/promises'
-import { resolve } from 'path'
+import { writeFile, mkdir, readdir, stat, rm } from 'fs/promises'
+import { dirname, join, resolve } from 'path'
import { CATEGORY_LABELS, type ProjectSummary, type TaskCategory } from './types.js'
-import { getCostColumnHeader, convertCost } from './currency.js'
+import { getCurrency, convertCost } from './currency.js'
function escCsv(s: string): string {
const sanitized = /^[=+\-@]/.test(s) ? `'${s}` : s
@@ -12,15 +12,47 @@ function escCsv(s: string): string {
return sanitized
}
-function buildDailyRows(projects: ProjectSummary[]): Array> {
- const daily: Record = {}
+type Row = Record
+function rowsToCsv(rows: Row[]): string {
+ if (rows.length === 0) return ''
+ const headers = Object.keys(rows[0])
+ const lines = [headers.map(escCsv).join(',')]
+ for (const row of rows) {
+ lines.push(headers.map(h => escCsv(String(row[h] ?? ''))).join(','))
+ }
+ return lines.join('\n') + '\n'
+}
+
+function round2(n: number): number {
+ return Math.round(n * 100) / 100
+}
+
+function pct(n: number, total: number): number {
+ return total > 0 ? round2((n / total) * 100) : 0
+}
+
+type DailyAgg = {
+ cost: number
+ calls: number
+ input: number
+ output: number
+ cacheRead: number
+ cacheWrite: number
+ sessions: Set
+}
+
+function buildDailyRows(projects: ProjectSummary[], period: string): Row[] {
+ const daily: Record = {}
for (const project of projects) {
for (const session of project.sessions) {
for (const turn of session.turns) {
if (!turn.timestamp) continue
const day = turn.timestamp.slice(0, 10)
- if (!daily[day]) daily[day] = { cost: 0, calls: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
+ if (!daily[day]) {
+ daily[day] = { cost: 0, calls: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, sessions: new Set() }
+ }
+ daily[day].sessions.add(session.sessionId)
for (const call of turn.assistantCalls) {
daily[day].cost += call.costUSD
daily[day].calls++
@@ -32,11 +64,13 @@ function buildDailyRows(projects: ProjectSummary[]): Array ({
+ Period: period,
Date: date,
- [getCostColumnHeader()]: convertCost(d.cost),
+ [`Cost (${code})`]: round2(convertCost(d.cost)),
'API Calls': d.calls,
+ Sessions: d.sessions.size,
'Input Tokens': d.input,
'Output Tokens': d.output,
'Cache Read Tokens': d.cacheRead,
@@ -44,7 +78,7 @@ function buildDailyRows(projects: ProjectSummary[]): Array> {
+function buildActivityRows(projects: ProjectSummary[], period: string): Row[] {
const catTotals: Record = {}
for (const project of projects) {
for (const session of project.sessions) {
@@ -55,40 +89,53 @@ function buildActivityRows(projects: ProjectSummary[]): Array s + d.cost, 0)
+ const { code } = getCurrency()
return Object.entries(catTotals)
.sort(([, a], [, b]) => b.cost - a.cost)
.map(([cat, d]) => ({
+ Period: period,
Activity: CATEGORY_LABELS[cat as TaskCategory] ?? cat,
- [getCostColumnHeader()]: convertCost(d.cost),
+ [`Cost (${code})`]: round2(convertCost(d.cost)),
+ 'Share (%)': pct(d.cost, totalCost),
Turns: d.turns,
}))
}
-function buildModelRows(projects: ProjectSummary[]): Array> {
- const modelTotals: Record = {}
+function buildModelRows(projects: ProjectSummary[], period: string): Row[] {
+ const modelTotals: Record = {}
for (const project of projects) {
for (const session of project.sessions) {
for (const [model, d] of Object.entries(session.modelBreakdown)) {
- if (!modelTotals[model]) modelTotals[model] = { calls: 0, cost: 0, input: 0, output: 0 }
+ if (!modelTotals[model]) modelTotals[model] = { calls: 0, cost: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
modelTotals[model].calls += d.calls
modelTotals[model].cost += d.costUSD
modelTotals[model].input += d.tokens.inputTokens
modelTotals[model].output += d.tokens.outputTokens
+ modelTotals[model].cacheRead += d.tokens.cacheReadInputTokens ?? 0
+ modelTotals[model].cacheWrite += d.tokens.cacheCreationInputTokens ?? 0
}
}
}
+ const totalCost = Object.values(modelTotals).reduce((s, d) => s + d.cost, 0)
+ const { code } = getCurrency()
return Object.entries(modelTotals)
+ .filter(([name]) => name !== '')
.sort(([, a], [, b]) => b.cost - a.cost)
.map(([model, d]) => ({
+ Period: period,
Model: model,
- [getCostColumnHeader()]: convertCost(d.cost),
+ [`Cost (${code})`]: round2(convertCost(d.cost)),
+ 'Share (%)': pct(d.cost, totalCost),
'API Calls': d.calls,
'Input Tokens': d.input,
'Output Tokens': d.output,
+ 'Cache Read Tokens': d.cacheRead,
+ 'Cache Write Tokens': d.cacheWrite,
}))
}
-function buildToolRows(projects: ProjectSummary[]): Array> {
+function buildToolRows(projects: ProjectSummary[]): Row[] {
const toolTotals: Record = {}
for (const project of projects) {
for (const session of project.sessions) {
@@ -97,12 +144,17 @@ function buildToolRows(projects: ProjectSummary[]): Array s + n, 0)
return Object.entries(toolTotals)
.sort(([, a], [, b]) => b - a)
- .map(([tool, calls]) => ({ Tool: tool, Calls: calls }))
+ .map(([tool, calls]) => ({
+ Tool: tool,
+ Calls: calls,
+ 'Share (%)': pct(calls, total),
+ }))
}
-function buildBashRows(projects: ProjectSummary[]): Array> {
+function buildBashRows(projects: ProjectSummary[]): Row[] {
const bashTotals: Record = {}
for (const project of projects) {
for (const session of project.sessions) {
@@ -111,28 +163,47 @@ function buildBashRows(projects: ProjectSummary[]): Array s + n, 0)
return Object.entries(bashTotals)
.sort(([, a], [, b]) => b - a)
- .map(([cmd, calls]) => ({ Command: cmd, Calls: calls }))
+ .map(([cmd, calls]) => ({
+ Command: cmd,
+ Calls: calls,
+ 'Share (%)': pct(calls, total),
+ }))
}
-function buildProjectRows(projects: ProjectSummary[]): Array> {
- return projects.map(p => ({
- Project: p.projectPath,
- [getCostColumnHeader()]: convertCost(p.totalCostUSD),
- 'API Calls': p.totalApiCalls,
- Sessions: p.sessions.length,
- }))
+function buildProjectRows(projects: ProjectSummary[]): Row[] {
+ const { code } = getCurrency()
+ const total = projects.reduce((s, p) => s + p.totalCostUSD, 0)
+ return projects
+ .slice()
+ .sort((a, b) => b.totalCostUSD - a.totalCostUSD)
+ .map(p => ({
+ Project: p.projectPath,
+ [`Cost (${code})`]: round2(convertCost(p.totalCostUSD)),
+ 'Share (%)': pct(p.totalCostUSD, total),
+ 'API Calls': p.totalApiCalls,
+ Sessions: p.sessions.length,
+ }))
}
-function rowsToCsv(rows: Array>): string {
- if (rows.length === 0) return ''
- const headers = Object.keys(rows[0])
- const lines = [headers.map(escCsv).join(',')]
- for (const row of rows) {
- lines.push(headers.map(h => escCsv(String(row[h] ?? ''))).join(','))
+function buildSessionRows(projects: ProjectSummary[]): Row[] {
+ const { code } = getCurrency()
+ const rows: Row[] = []
+ for (const p of projects) {
+ for (const s of p.sessions) {
+ rows.push({
+ Project: p.projectPath,
+ 'Session ID': s.sessionId,
+ 'Started At': s.firstTimestamp ?? '',
+ [`Cost (${code})`]: round2(convertCost(s.totalCostUSD)),
+ 'API Calls': s.apiCalls,
+ Turns: s.turns.length,
+ })
+ }
}
- return lines.join('\n')
+ return rows.sort((a, b) => (b[`Cost (${code})`] as number) - (a[`Cost (${code})`] as number))
}
export type PeriodExport = {
@@ -140,77 +211,140 @@ export type PeriodExport = {
projects: ProjectSummary[]
}
-function buildSummaryRow(period: PeriodExport): Record {
- const cost = period.projects.reduce((s, p) => s + p.totalCostUSD, 0)
- const calls = period.projects.reduce((s, p) => s + p.totalApiCalls, 0)
- const sessions = period.projects.reduce((s, p) => s + p.sessions.length, 0)
- return { Period: period.label, [getCostColumnHeader()]: convertCost(cost), 'API Calls': calls, Sessions: sessions }
+function buildSummaryRows(periods: PeriodExport[]): Row[] {
+ const { code } = getCurrency()
+ return periods.map(p => {
+ const cost = p.projects.reduce((s, proj) => s + proj.totalCostUSD, 0)
+ const calls = p.projects.reduce((s, proj) => s + proj.totalApiCalls, 0)
+ const sessions = p.projects.reduce((s, proj) => s + proj.sessions.length, 0)
+ const projectCount = p.projects.filter(proj => proj.totalCostUSD > 0).length
+ return {
+ Period: p.label,
+ [`Cost (${code})`]: round2(convertCost(cost)),
+ 'API Calls': calls,
+ Sessions: sessions,
+ Projects: projectCount,
+ }
+ })
}
+function buildReadme(periods: PeriodExport[]): string {
+ const { code } = getCurrency()
+ const generated = new Date().toISOString()
+ const lines = [
+ 'CodeBurn Usage Export',
+ '====================',
+ '',
+ `Generated: ${generated}`,
+ `Currency: ${code}`,
+ `Periods: ${periods.map(p => p.label).join(', ')}`,
+ '',
+ 'Files',
+ '-----',
+ ' summary.csv One row per period. Headline totals.',
+ ' daily.csv Day-by-day breakdown, Period column distinguishes the window.',
+ ' activity.csv Time spent per task category (Coding, Debugging, Exploration, etc.).',
+ ' models.csv Spend per model with token totals and cache usage.',
+ ' projects.csv Spend per project folder (30-day window).',
+ ' sessions.csv One row per session (30-day window) with session IDs and costs.',
+ ' tools.csv Tool invocations and share (30-day window).',
+ ' shell-commands.csv Shell commands executed via Bash tool (30-day window).',
+ '',
+ 'Notes',
+ '-----',
+ ' Every cost column is already converted to the active currency. Tokens are raw integer',
+ ' counts from provider telemetry. Share (%) is relative to the period/table total.',
+ '',
+ ]
+ return lines.join('\n')
+}
+
+/// Sentinel file dropped into every folder we create so we can safely overwrite an older
+/// codeburn export without ever deleting a user's unrelated files by accident.
+const EXPORT_MARKER_FILE = '.codeburn-export'
+
+async function isCodeburnExportFolder(path: string): Promise {
+ const markerStat = await stat(join(path, EXPORT_MARKER_FILE)).catch(() => null)
+ return markerStat?.isFile() ?? false
+}
+
+async function clearCodeburnExportFolder(path: string): Promise {
+ const entries = await readdir(path)
+ for (const entry of entries) {
+ await rm(join(path, entry), { recursive: true, force: true })
+ }
+}
+
+/// Writes a folder of one-table-per-file CSVs. The outputPath is treated as a directory. If it
+/// ends in `.csv` the extension is stripped to form the folder name. Refuses to delete a
+/// pre-existing file or a non-codeburn folder, so a typo like `-o ~/.ssh/id_ed25519` can't
+/// wipe a sensitive file (prior versions did `rm(path, { force: true })` unconditionally).
export async function exportCsv(periods: PeriodExport[], outputPath: string): Promise {
- const allProjects = periods.find(p => p.label === '30 Days')?.projects
- ?? periods[periods.length - 1].projects
+ const thirtyDays = periods.find(p => p.label === '30 Days')
+ const thirtyDayProjects = thirtyDays?.projects ?? periods[periods.length - 1].projects
- const parts: string[] = []
-
- parts.push('# Summary')
- parts.push(rowsToCsv(periods.map(buildSummaryRow)))
- parts.push('')
-
- for (const period of periods) {
- parts.push(`# Daily - ${period.label}`)
- parts.push(rowsToCsv(buildDailyRows(period.projects)))
- parts.push('')
-
- parts.push(`# Activity - ${period.label}`)
- parts.push(rowsToCsv(buildActivityRows(period.projects)))
- parts.push('')
-
- parts.push(`# Models - ${period.label}`)
- parts.push(rowsToCsv(buildModelRows(period.projects)))
- parts.push('')
+ let folder = resolve(outputPath)
+ if (folder.toLowerCase().endsWith('.csv')) {
+ folder = folder.slice(0, -4)
}
- parts.push('# Tools - All')
- parts.push(rowsToCsv(buildToolRows(allProjects)))
- parts.push('')
+ const existingStat = await stat(folder).catch(() => null)
+ if (existingStat?.isFile()) {
+ throw new Error(`Refusing to overwrite existing file at ${folder}. Pass a directory path instead.`)
+ }
+ if (existingStat?.isDirectory()) {
+ if (!(await isCodeburnExportFolder(folder))) {
+ throw new Error(
+ `Refusing to reuse non-empty directory ${folder}: no ${EXPORT_MARKER_FILE} marker. ` +
+ `Delete it manually or pick a different -o path.`
+ )
+ }
+ await clearCodeburnExportFolder(folder)
+ }
+ await mkdir(folder, { recursive: true })
+ await writeFile(join(folder, EXPORT_MARKER_FILE), '', 'utf-8')
- parts.push('# Shell Commands - All')
- parts.push(rowsToCsv(buildBashRows(allProjects)))
- parts.push('')
+ const dailyRows = periods.flatMap(p => buildDailyRows(p.projects, p.label))
+ const activityRows = periods.flatMap(p => buildActivityRows(p.projects, p.label))
+ const modelRows = periods.flatMap(p => buildModelRows(p.projects, p.label))
- parts.push('# Projects - All')
- parts.push(rowsToCsv(buildProjectRows(allProjects)))
- parts.push('')
+ await writeFile(join(folder, 'README.txt'), buildReadme(periods), 'utf-8')
+ await writeFile(join(folder, 'summary.csv'), rowsToCsv(buildSummaryRows(periods)), 'utf-8')
+ await writeFile(join(folder, 'daily.csv'), rowsToCsv(dailyRows), 'utf-8')
+ await writeFile(join(folder, 'activity.csv'), rowsToCsv(activityRows), 'utf-8')
+ await writeFile(join(folder, 'models.csv'), rowsToCsv(modelRows), 'utf-8')
+ await writeFile(join(folder, 'projects.csv'), rowsToCsv(buildProjectRows(thirtyDayProjects)), 'utf-8')
+ await writeFile(join(folder, 'sessions.csv'), rowsToCsv(buildSessionRows(thirtyDayProjects)), 'utf-8')
+ await writeFile(join(folder, 'tools.csv'), rowsToCsv(buildToolRows(thirtyDayProjects)), 'utf-8')
+ await writeFile(join(folder, 'shell-commands.csv'), rowsToCsv(buildBashRows(thirtyDayProjects)), 'utf-8')
- const fullPath = resolve(outputPath)
- await writeFile(fullPath, parts.join('\n'), 'utf-8')
- return fullPath
+ return folder
}
export async function exportJson(periods: PeriodExport[], outputPath: string): Promise {
- const allProjects = periods.find(p => p.label === '30 Days')?.projects
- ?? periods[periods.length - 1].projects
-
- const periodData: Record = {}
- for (const period of periods) {
- periodData[period.label] = {
- summary: buildSummaryRow(period),
- daily: buildDailyRows(period.projects),
- activity: buildActivityRows(period.projects),
- models: buildModelRows(period.projects),
- }
- }
+ const thirtyDays = periods.find(p => p.label === '30 Days')
+ const thirtyDayProjects = thirtyDays?.projects ?? periods[periods.length - 1].projects
+ const { code, rate, symbol } = getCurrency()
const data = {
+ schema: 'codeburn.export.v2',
generated: new Date().toISOString(),
- periods: periodData,
- tools: buildToolRows(allProjects),
- shellCommands: buildBashRows(allProjects),
- projects: buildProjectRows(allProjects),
+ currency: { code, rate, symbol },
+ summary: buildSummaryRows(periods),
+ periods: periods.map(p => ({
+ label: p.label,
+ daily: buildDailyRows(p.projects, p.label),
+ activity: buildActivityRows(p.projects, p.label),
+ models: buildModelRows(p.projects, p.label),
+ })),
+ projects: buildProjectRows(thirtyDayProjects),
+ sessions: buildSessionRows(thirtyDayProjects),
+ tools: buildToolRows(thirtyDayProjects),
+ shellCommands: buildBashRows(thirtyDayProjects),
}
- const fullPath = resolve(outputPath)
- await writeFile(fullPath, JSON.stringify(data, null, 2), 'utf-8')
- return fullPath
+ const target = resolve(outputPath.toLowerCase().endsWith('.json') ? outputPath : `${outputPath}.json`)
+ await mkdir(dirname(target), { recursive: true })
+ await writeFile(target, JSON.stringify(data, null, 2), 'utf-8')
+ return target
}
diff --git a/src/format.ts b/src/format.ts
index 493d4e6..3905048 100644
--- a/src/format.ts
+++ b/src/format.ts
@@ -13,9 +13,20 @@ export function formatTokens(n: number): string {
return n.toString()
}
+/// Returns YYYY-MM-DD for the given date in the process-local timezone. Cheaper than shelling
+/// out to Intl.DateTimeFormat for every turn in a loop and avoids the UTC drift that bites
+/// `Date.toISOString().slice(0,10)` whenever the user runs this between local midnight and
+/// UTC midnight.
+function localDateString(d: Date): string {
+ const y = d.getFullYear()
+ const m = String(d.getMonth() + 1).padStart(2, '0')
+ const day = String(d.getDate()).padStart(2, '0')
+ return `${y}-${m}-${day}`
+}
+
export function renderStatusBar(projects: ProjectSummary[]): string {
const now = new Date()
- const today = now.toISOString().slice(0, 10)
+ const today = localDateString(now)
const monthStart = `${today.slice(0, 7)}-01`
let todayCost = 0, todayCalls = 0, monthCost = 0, monthCalls = 0
@@ -24,7 +35,11 @@ export function renderStatusBar(projects: ProjectSummary[]): string {
for (const session of project.sessions) {
for (const turn of session.turns) {
if (!turn.timestamp) continue
- const day = turn.timestamp.slice(0, 10)
+ // Bucket by the session timestamp's local date so the user's "today" and "this month"
+ // match the wall clock on their machine. Session timestamps are stored as UTC ISO
+ // strings; naively slicing `timestamp.slice(0,10)` bucketed them by UTC date, which
+ // showed `Today $0` during the UTC-midnight-to-local-midnight window.
+ const day = localDateString(new Date(turn.timestamp))
const turnCost = turn.assistantCalls.reduce((s, c) => s + c.costUSD, 0)
const turnCalls = turn.assistantCalls.length
if (day === today) { todayCost += turnCost; todayCalls += turnCalls }
diff --git a/src/menubar-installer.ts b/src/menubar-installer.ts
new file mode 100644
index 0000000..53265c5
--- /dev/null
+++ b/src/menubar-installer.ts
@@ -0,0 +1,173 @@
+import { spawn } from 'node:child_process'
+import { createWriteStream } from 'node:fs'
+import { mkdir, mkdtemp, rename, rm, stat } from 'node:fs/promises'
+import { homedir, platform, tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { pipeline } from 'node:stream/promises'
+import { Readable } from 'node:stream'
+
+/// Public GitHub repo that hosts signed macOS release builds. `/releases/latest` returns the
+/// newest tagged release; we filter its assets list for our zipped .app bundle.
+const RELEASE_API = 'https://api.github.com/repos/AgentSeal/codeburn/releases/latest'
+const APP_BUNDLE_NAME = 'CodeBurnMenubar.app'
+const ASSET_PATTERN = /^CodeBurnMenubar-.*\.zip$/
+const APP_PROCESS_NAME = 'CodeBurnMenubar'
+const SUPPORTED_OS = 'darwin'
+const MIN_MACOS_MAJOR = 14
+
+export type InstallResult = { installedPath: string; launched: boolean }
+
+type ReleaseAsset = { name: string; browser_download_url: string }
+type ReleaseResponse = { tag_name: string; assets: ReleaseAsset[] }
+
+function userApplicationsDir(): string {
+ return join(homedir(), 'Applications')
+}
+
+async function exists(path: string): Promise {
+ try {
+ await stat(path)
+ return true
+ } catch {
+ return false
+ }
+}
+
+async function ensureSupportedPlatform(): Promise {
+ if (platform() !== SUPPORTED_OS) {
+ throw new Error(`The menubar app is macOS only (detected: ${platform()}).`)
+ }
+ const major = Number((process.env.CODEBURN_FORCE_MACOS_MAJOR ?? '')
+ || (await sysProductVersion()).split('.')[0])
+ if (!Number.isFinite(major) || major < MIN_MACOS_MAJOR) {
+ throw new Error(`macOS ${MIN_MACOS_MAJOR}+ required (detected ${major}).`)
+ }
+}
+
+async function sysProductVersion(): Promise {
+ return new Promise((resolve, reject) => {
+ const proc = spawn('/usr/bin/sw_vers', ['-productVersion'])
+ let out = ''
+ proc.stdout.on('data', (chunk: Buffer) => { out += chunk.toString() })
+ proc.on('error', reject)
+ proc.on('close', (code) => {
+ if (code !== 0) reject(new Error(`sw_vers exited with ${code}`))
+ else resolve(out.trim())
+ })
+ })
+}
+
+async function fetchLatestReleaseAsset(): Promise {
+ const response = await fetch(RELEASE_API, {
+ headers: {
+ // Identify the installer so GitHub's abuse heuristics treat us as a known client.
+ 'User-Agent': 'codeburn-menubar-installer',
+ Accept: 'application/vnd.github+json',
+ },
+ })
+ if (!response.ok) {
+ throw new Error(`GitHub release lookup failed: HTTP ${response.status}`)
+ }
+ const body = await response.json() as ReleaseResponse
+ const asset = body.assets.find(a => ASSET_PATTERN.test(a.name))
+ if (!asset) {
+ throw new Error(
+ `No ${APP_BUNDLE_NAME} zip found in release ${body.tag_name}. ` +
+ `Check https://github.com/AgentSeal/codeburn/releases.`
+ )
+ }
+ return asset
+}
+
+async function downloadToFile(url: string, destPath: string): Promise {
+ const response = await fetch(url, {
+ headers: { 'User-Agent': 'codeburn-menubar-installer' },
+ redirect: 'follow',
+ })
+ if (!response.ok || response.body === null) {
+ throw new Error(`Download failed: HTTP ${response.status}`)
+ }
+ // fetch's ReadableStream needs to be wrapped for Node streams.
+ const nodeStream = Readable.fromWeb(response.body as never)
+ await pipeline(nodeStream, createWriteStream(destPath))
+}
+
+async function runCommand(command: string, args: string[]): Promise {
+ return new Promise((resolve, reject) => {
+ const proc = spawn(command, args, { stdio: 'inherit' })
+ proc.on('error', reject)
+ proc.on('close', (code) => {
+ if (code === 0) resolve()
+ else reject(new Error(`${command} exited with status ${code}`))
+ })
+ })
+}
+
+async function isAppRunning(): Promise {
+ return new Promise((resolve) => {
+ const proc = spawn('/usr/bin/pgrep', ['-f', APP_PROCESS_NAME])
+ proc.on('close', (code) => resolve(code === 0))
+ proc.on('error', () => resolve(false))
+ })
+}
+
+async function killRunningApp(): Promise {
+ await new Promise((resolve) => {
+ const proc = spawn('/usr/bin/pkill', ['-f', APP_PROCESS_NAME])
+ proc.on('close', () => resolve())
+ proc.on('error', () => resolve())
+ })
+}
+
+export async function installMenubarApp(options: { force?: boolean } = {}): Promise {
+ await ensureSupportedPlatform()
+
+ const appsDir = userApplicationsDir()
+ const targetPath = join(appsDir, APP_BUNDLE_NAME)
+ const alreadyInstalled = await exists(targetPath)
+
+ if (alreadyInstalled && !options.force) {
+ if (!(await isAppRunning())) {
+ await runCommand('/usr/bin/open', [targetPath])
+ }
+ return { installedPath: targetPath, launched: true }
+ }
+
+ console.log('Looking up the latest CodeBurn Menubar release...')
+ const asset = await fetchLatestReleaseAsset()
+
+ const stagingDir = await mkdtemp(join(tmpdir(), 'codeburn-menubar-'))
+ try {
+ const archivePath = join(stagingDir, asset.name)
+ console.log(`Downloading ${asset.name}...`)
+ await downloadToFile(asset.browser_download_url, archivePath)
+
+ console.log('Unpacking...')
+ await runCommand('/usr/bin/unzip', ['-q', archivePath, '-d', stagingDir])
+
+ const unpackedApp = join(stagingDir, APP_BUNDLE_NAME)
+ if (!(await exists(unpackedApp))) {
+ throw new Error(`Archive did not contain ${APP_BUNDLE_NAME}.`)
+ }
+
+ // Clear Gatekeeper's quarantine xattr. Without this, the first launch shows the
+ // "cannot verify developer" prompt even for a signed + notarized app when the bundle
+ // was delivered via curl/fetch instead of the Mac App Store.
+ await runCommand('/usr/bin/xattr', ['-dr', 'com.apple.quarantine', unpackedApp]).catch(() => {})
+
+ await mkdir(appsDir, { recursive: true })
+ if (alreadyInstalled) {
+ // Kill the running copy before replacing its bundle so `mv` can proceed cleanly and the
+ // user ends up on the new version.
+ await killRunningApp()
+ await rm(targetPath, { recursive: true, force: true })
+ }
+ await rename(unpackedApp, targetPath)
+
+ console.log('Launching CodeBurn Menubar...')
+ await runCommand('/usr/bin/open', [targetPath])
+ return { installedPath: targetPath, launched: true }
+ } finally {
+ await rm(stagingDir, { recursive: true, force: true })
+ }
+}
diff --git a/src/menubar-json.ts b/src/menubar-json.ts
new file mode 100644
index 0000000..bab4e40
--- /dev/null
+++ b/src/menubar-json.ts
@@ -0,0 +1,182 @@
+/// Rollup of one time window (today / 7 days / 30 days / month / all) used as the canonical
+/// input to the menubar payload. Built inside the CLI and also consumed by the day-aggregator
+/// when hydrating per-day cache entries.
+export type PeriodData = {
+ label: string
+ cost: number
+ calls: number
+ sessions: number
+ inputTokens: number
+ outputTokens: number
+ cacheReadTokens: number
+ cacheWriteTokens: number
+ categories: Array<{ name: string; cost: number; turns: number; editTurns: number; oneShotTurns: number }>
+ models: Array<{ name: string; cost: number; calls: number }>
+}
+
+export type ProviderCost = {
+ name: string
+ cost: number
+}
+import type { OptimizeResult } from './optimize.js'
+
+const TOP_ACTIVITIES_LIMIT = 20
+const TOP_MODELS_LIMIT = 20
+const TOP_FINDINGS_LIMIT = 10
+const HISTORY_DAYS_LIMIT = 365
+const SYNTHETIC_MODEL_NAME = ''
+
+export type DailyModelBreakdown = {
+ name: string
+ cost: number
+ calls: number
+ inputTokens: number
+ outputTokens: number
+}
+
+export type DailyHistoryEntry = {
+ date: string
+ cost: number
+ calls: number
+ inputTokens: number
+ outputTokens: number
+ cacheReadTokens: number
+ cacheWriteTokens: number
+ topModels: DailyModelBreakdown[]
+}
+
+export type MenubarPayload = {
+ generated: string
+ current: {
+ label: string
+ cost: number
+ calls: number
+ sessions: number
+ oneShotRate: number | null
+ inputTokens: number
+ outputTokens: number
+ cacheHitPercent: number
+ topActivities: Array<{
+ name: string
+ cost: number
+ turns: number
+ oneShotRate: number | null
+ }>
+ topModels: Array<{
+ name: string
+ cost: number
+ calls: number
+ }>
+ providers: Record
+ }
+ optimize: {
+ findingCount: number
+ savingsUSD: number
+ topFindings: Array<{
+ title: string
+ impact: 'high' | 'medium' | 'low'
+ savingsUSD: number
+ }>
+ }
+ history: {
+ daily: DailyHistoryEntry[]
+ }
+}
+
+function oneShotRateFor(editTurns: number, oneShotTurns: number): number | null {
+ if (editTurns === 0) return null
+ return oneShotTurns / editTurns
+}
+
+function aggregateOneShotRate(categories: PeriodData['categories']): number | null {
+ let edits = 0
+ let oneShots = 0
+ for (const cat of categories) {
+ edits += cat.editTurns
+ oneShots += cat.oneShotTurns
+ }
+ if (edits === 0) return null
+ return oneShots / edits
+}
+
+function cacheHitPercent(inputTokens: number, cacheReadTokens: number): number {
+ const denom = inputTokens + cacheReadTokens
+ if (denom === 0) return 0
+ return (cacheReadTokens / denom) * 100
+}
+
+function buildTopActivities(categories: PeriodData['categories']): MenubarPayload['current']['topActivities'] {
+ return categories.slice(0, TOP_ACTIVITIES_LIMIT).map(cat => ({
+ name: cat.name,
+ cost: cat.cost,
+ turns: cat.turns,
+ oneShotRate: oneShotRateFor(cat.editTurns, cat.oneShotTurns),
+ }))
+}
+
+function buildTopModels(models: PeriodData['models']): MenubarPayload['current']['topModels'] {
+ return models
+ .filter(m => m.name !== SYNTHETIC_MODEL_NAME)
+ .slice(0, TOP_MODELS_LIMIT)
+ .map(m => ({ name: m.name, cost: m.cost, calls: m.calls }))
+}
+
+function buildOptimize(optimize: OptimizeResult | null): MenubarPayload['optimize'] {
+ if (!optimize || optimize.findings.length === 0) {
+ return { findingCount: 0, savingsUSD: 0, topFindings: [] }
+ }
+ const { findings, costRate } = optimize
+ const totalSavingsUSD = findings.reduce((s, f) => s + f.tokensSaved * costRate, 0)
+ const topFindings = findings.slice(0, TOP_FINDINGS_LIMIT).map(f => ({
+ title: f.title,
+ impact: f.impact,
+ savingsUSD: f.tokensSaved * costRate,
+ }))
+ return {
+ findingCount: findings.length,
+ savingsUSD: totalSavingsUSD,
+ topFindings,
+ }
+}
+
+function buildProviders(providers: ProviderCost[]): Record {
+ const map: Record = {}
+ for (const p of providers) {
+ if (p.cost < 0) continue
+ map[p.name.toLowerCase()] = p.cost
+ }
+ return map
+}
+
+function buildHistory(daily: DailyHistoryEntry[] | undefined): MenubarPayload['history'] {
+ if (!daily || daily.length === 0) return { daily: [] }
+ const sorted = [...daily].sort((a, b) => a.date.localeCompare(b.date))
+ const trimmed = sorted.slice(-HISTORY_DAYS_LIMIT)
+ return { daily: trimmed }
+}
+
+export function buildMenubarPayload(
+ current: PeriodData,
+ providers: ProviderCost[],
+ optimize: OptimizeResult | null,
+ dailyHistory?: DailyHistoryEntry[],
+): MenubarPayload {
+ return {
+ generated: new Date().toISOString(),
+ current: {
+ label: current.label,
+ cost: current.cost,
+ calls: current.calls,
+ sessions: current.sessions,
+ oneShotRate: aggregateOneShotRate(current.categories),
+ inputTokens: current.inputTokens,
+ outputTokens: current.outputTokens,
+ cacheHitPercent: cacheHitPercent(current.inputTokens, current.cacheReadTokens),
+ topActivities: buildTopActivities(current.categories),
+ topModels: buildTopModels(current.models),
+ providers: buildProviders(providers),
+ },
+ optimize: buildOptimize(optimize),
+ history: buildHistory(dailyHistory),
+ }
+}
diff --git a/src/menubar.ts b/src/menubar.ts
deleted file mode 100644
index be2bdb8..0000000
--- a/src/menubar.ts
+++ /dev/null
@@ -1,334 +0,0 @@
-import { execFileSync, execSync } from 'child_process'
-import { existsSync } from 'fs'
-import { chmod, mkdir, unlink, writeFile } from 'fs/promises'
-import { homedir, platform } from 'os'
-import { join } from 'path'
-import { formatCost, formatTokens } from './format.js'
-import { getCurrency } from './currency.js'
-
-const PLUGIN_REFRESH = '5m'
-const SWIFTBAR_PREFERENCES_DOMAIN = 'com.ameba.SwiftBar'
-const SWIFTBAR_PLUGIN_DIRECTORY_KEY = 'PluginDirectory'
-
-const MENUBAR_LABEL_MAX_LENGTH = 14
-const MENUBAR_LABEL_ALLOWLIST = /[^A-Za-z0-9 ._/-]/g
-
-// SwiftBar/xbar parse `|` as the metadata separator and interpret ANSI escapes
-// on some paths. Replace anything outside a conservative allowlist with `?`
-// and truncate before padEnd.
-function sanitizeMenubarLabel(name: string): string {
- return name.replace(MENUBAR_LABEL_ALLOWLIST, '?').slice(0, MENUBAR_LABEL_MAX_LENGTH)
-}
-
-function getSwiftBarPluginDir(): string {
- return join(homedir(), 'Library', 'Application Support', 'SwiftBar', 'plugins')
-}
-
-function getXbarPluginDir(): string {
- return join(homedir(), 'Library', 'Application Support', 'xbar', 'plugins')
-}
-
-export function parsePluginDirectoryPreference(value: string): string | undefined {
- const pluginDir = value.trim()
- if (!pluginDir) return undefined
- if (pluginDir === '~') return homedir()
- if (pluginDir.startsWith('~/')) return join(homedir(), pluginDir.slice(2))
- return pluginDir
-}
-
-function getConfiguredSwiftBarPluginDir(): string | undefined {
- if (platform() !== 'darwin') return undefined
-
- try {
- return parsePluginDirectoryPreference(execFileSync('defaults', [
- 'read',
- SWIFTBAR_PREFERENCES_DOMAIN,
- SWIFTBAR_PLUGIN_DIRECTORY_KEY,
- ], { encoding: 'utf-8' }))
- } catch {
- return undefined
- }
-}
-
-function getSwiftBarPluginDirs(): string[] {
- const dirs = [getConfiguredSwiftBarPluginDir(), getSwiftBarPluginDir()]
- return dirs.filter((dir, index): dir is string => dir !== undefined && dirs.indexOf(dir) === index)
-}
-
-export function chooseMenubarPluginDir(
- swiftBarPluginDirs: string[],
- xbarPluginDir: string,
- pathExists: (path: string) => boolean,
-): { pluginDir: string; appName: string } {
- const preferredSwiftBarDir = swiftBarPluginDirs[0] ?? getSwiftBarPluginDir()
-
- for (const pluginDir of swiftBarPluginDirs) {
- if (pathExists(pluginDir)) return { pluginDir, appName: 'SwiftBar' }
- }
-
- if (pathExists(xbarPluginDir)) return { pluginDir: xbarPluginDir, appName: 'xbar' }
-
- return { pluginDir: preferredSwiftBarDir, appName: 'SwiftBar' }
-}
-
-function getCodeburnBin(): string {
- try {
- return execSync('which codeburn', { encoding: 'utf-8' }).trim()
- } catch {
- return 'npx --yes codeburn'
- }
-}
-
-function generatePlugin(bin: string): string {
- const home = homedir()
- // Resolve the directory of the node binary used at install time so the
- // plugin uses the same Node version codeburn was installed with — even
- // when SwiftBar/xbar launch with a minimal PATH that finds an older
- // system Node first. Fixes #63.
- const nodeBinDir = join(process.execPath, '..')
- return `#!/bin/bash
-# CodeBurn
-# v0.1.0
-# AgentSeal
-# agentseal
-# See where your AI coding tokens burn. Tracks cost, activity, and model usage across Claude Code, Cursor, and Codex by task type, tool, MCP server, and project.
-# file://${home}/codeburn/assets/logo.png
-# https://github.com/agentseal/codeburn
-# node
-
-export HOME="${home}"
-export PATH="${nodeBinDir}:$HOME/.local/bin:$HOME/.npm-global/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"
-
-${bin} status --format menubar 2>/dev/null || echo "-- | sfimage=flame.fill"
-`
-}
-
-function miniBar(value: number, max: number, width: number = 10): string {
- if (max === 0) return '·'.repeat(width)
- const filled = Math.round((value / max) * width)
- return '█'.repeat(Math.min(filled, width)) + '·'.repeat(Math.max(width - filled, 0))
-}
-
-export type PeriodData = {
- label: string
- cost: number
- calls: number
- inputTokens: number
- outputTokens: number
- cacheReadTokens: number
- cacheWriteTokens: number
- categories: Array<{ name: string; cost: number; turns: number; editTurns: number; oneShotTurns: number }>
- models: Array<{ name: string; cost: number; calls: number }>
-}
-
-export type ProviderCost = {
- name: string
- cost: number
-}
-
-export function renderMenubarFormat(
- today: PeriodData,
- week: PeriodData,
- thirtyDays: PeriodData,
- month: PeriodData,
- todayProviders?: ProviderCost[],
-): string {
- const lines: string[] = []
-
- lines.push(`${formatCost(today.cost)} | sfimage=flame.fill color=#FF8C42`)
- lines.push('---')
-
- lines.push(`CodeBurn | size=15 color=#FF8C42`)
- lines.push(`AI Coding Cost Tracker | size=11`)
- if (todayProviders && todayProviders.length > 1) {
- for (const p of todayProviders) {
- lines.push(` ${p.name.padEnd(10)} ${formatCost(p.cost).padStart(10)} | font=Menlo size=11`)
- }
- }
- lines.push('---')
-
- lines.push(`Today ${formatCost(today.cost)} ${today.calls.toLocaleString()} calls | size=14`)
- lines.push('---')
-
- const maxCat = Math.max(...today.categories.map(c => c.cost), 0.01)
- lines.push(`Activity - Today | size=12 color=#FF8C42`)
- for (const cat of today.categories.slice(0, 8)) {
- const bar = miniBar(cat.cost, maxCat)
- const name = sanitizeMenubarLabel(cat.name).padEnd(14)
- lines.push(`${bar} ${name} ${formatCost(cat.cost).padStart(8)} ${String(cat.turns).padStart(4)} turns | font=Menlo size=11`)
- }
- lines.push('---')
-
- const maxModel = Math.max(...today.models.filter(m => m.name !== '').map(m => m.cost), 0.01)
- lines.push(`Models - Today | size=12 color=#FF8C42`)
- for (const model of today.models.slice(0, 5)) {
- if (model.name === '') continue
- const bar = miniBar(model.cost, maxModel)
- const name = sanitizeMenubarLabel(model.name).padEnd(14)
- lines.push(`${bar} ${name} ${formatCost(model.cost).padStart(8)} ${String(model.calls).padStart(5)} calls | font=Menlo size=11`)
- }
-
- const cacheHit = today.inputTokens + today.cacheReadTokens > 0
- ? ((today.cacheReadTokens / (today.inputTokens + today.cacheReadTokens)) * 100).toFixed(0)
- : '0'
- lines.push(`Tokens: ${formatTokens(today.inputTokens)} in · ${formatTokens(today.outputTokens)} out · ${cacheHit}% cache hit | font=Menlo size=10`)
- lines.push('---')
-
- lines.push(`7 Days ${formatCost(week.cost)} ${week.calls.toLocaleString()} calls | size=14`)
- const weekMaxCat = Math.max(...week.categories.map(c => c.cost), 0.01)
- const weekMaxModel = Math.max(...week.models.filter(m => m.name !== '').map(m => m.cost), 0.01)
- lines.push(`--Activity | size=12 color=#FF8C42`)
- for (const cat of week.categories.slice(0, 8)) {
- const bar = miniBar(cat.cost, weekMaxCat)
- const name = sanitizeMenubarLabel(cat.name).padEnd(14)
- lines.push(`--${bar} ${name} ${formatCost(cat.cost).padStart(8)} ${String(cat.turns).padStart(4)} turns | font=Menlo size=11`)
- }
- lines.push(`-----`)
- lines.push(`--Models | size=12 color=#FF8C42`)
- for (const model of week.models.slice(0, 5)) {
- if (model.name === '') continue
- const bar = miniBar(model.cost, weekMaxModel)
- const name = sanitizeMenubarLabel(model.name).padEnd(14)
- lines.push(`--${bar} ${name} ${formatCost(model.cost).padStart(8)} ${String(model.calls).padStart(5)} calls | font=Menlo size=11`)
- }
-
- lines.push(`30 Days ${formatCost(thirtyDays.cost)} ${thirtyDays.calls.toLocaleString()} calls | size=14`)
- const tdMaxCat = Math.max(...thirtyDays.categories.map(c => c.cost), 0.01)
- const tdMaxModel = Math.max(...thirtyDays.models.filter(m => m.name !== '').map(m => m.cost), 0.01)
- lines.push(`--Activity | size=12 color=#FF8C42`)
- for (const cat of thirtyDays.categories.slice(0, 8)) {
- const bar = miniBar(cat.cost, tdMaxCat)
- const name = sanitizeMenubarLabel(cat.name).padEnd(14)
- lines.push(`--${bar} ${name} ${formatCost(cat.cost).padStart(8)} ${String(cat.turns).padStart(4)} turns | font=Menlo size=11`)
- }
- lines.push(`-----`)
- lines.push(`--Models | size=12 color=#FF8C42`)
- for (const model of thirtyDays.models.slice(0, 5)) {
- if (model.name === '') continue
- const bar = miniBar(model.cost, tdMaxModel)
- const name = sanitizeMenubarLabel(model.name).padEnd(14)
- lines.push(`--${bar} ${name} ${formatCost(model.cost).padStart(8)} ${String(model.calls).padStart(5)} calls | font=Menlo size=11`)
- }
-
- lines.push(`Month ${formatCost(month.cost)} ${month.calls.toLocaleString()} calls | size=14`)
- const monthMaxCat = Math.max(...month.categories.map(c => c.cost), 0.01)
- const monthMaxModel = Math.max(...month.models.filter(m => m.name !== '').map(m => m.cost), 0.01)
- lines.push(`--Activity | size=12 color=#FF8C42`)
- for (const cat of month.categories.slice(0, 8)) {
- const bar = miniBar(cat.cost, monthMaxCat)
- const name = sanitizeMenubarLabel(cat.name).padEnd(14)
- lines.push(`--${bar} ${name} ${formatCost(cat.cost).padStart(8)} ${String(cat.turns).padStart(4)} turns | font=Menlo size=11`)
- }
- lines.push(`-----`)
- lines.push(`--Models | size=12 color=#FF8C42`)
- for (const model of month.models.slice(0, 5)) {
- if (model.name === '') continue
- const bar = miniBar(model.cost, monthMaxModel)
- const name = sanitizeMenubarLabel(model.name).padEnd(14)
- lines.push(`--${bar} ${name} ${formatCost(model.cost).padStart(8)} ${String(model.calls).padStart(5)} calls | font=Menlo size=11`)
- }
-
- lines.push('---')
- const home = process.env.HOME ?? '~'
- const bin = getCodeburnBin()
- // Invoke the resolved `codeburn` binary directly. SwiftBar/xbar deliver
- // each `paramN=` value as its own argv entry, so there's no shell
- // quoting involved — and we don't ship the user to a `~/codeburn`
- // checkout that only exists when running from a dev clone (#32).
- lines.push(`Open Full Report | terminal=true shell=${bin} param1=report`)
- lines.push(`Export CSV to Desktop | terminal=false shell=${bin} param1=export param2=-o param3=${home}/Desktop/codeburn-report.csv`)
-
- // Currency submenu -- common currencies as clickable items.
- // Clicking one runs 'codeburn currency XXX' and refreshes the plugin.
- const activeCurrency = getCurrency().code
- const currencies = [
- { code: 'USD', name: 'US Dollar' },
- { code: 'GBP', name: 'British Pound' },
- { code: 'EUR', name: 'Euro' },
- { code: 'AUD', name: 'Australian Dollar' },
- { code: 'CAD', name: 'Canadian Dollar' },
- { code: 'NZD', name: 'New Zealand Dollar' },
- { code: 'JPY', name: 'Japanese Yen' },
- { code: 'CHF', name: 'Swiss Franc' },
- { code: 'INR', name: 'Indian Rupee' },
- { code: 'BRL', name: 'Brazilian Real' },
- { code: 'SEK', name: 'Swedish Krona' },
- { code: 'SGD', name: 'Singapore Dollar' },
- { code: 'HKD', name: 'Hong Kong Dollar' },
- { code: 'KRW', name: 'South Korean Won' },
- { code: 'MXN', name: 'Mexican Peso' },
- { code: 'ZAR', name: 'South African Rand' },
- { code: 'DKK', name: 'Danish Krone' },
- ]
- lines.push(`Currency: ${activeCurrency} | size=14`)
- for (const { code, name } of currencies) {
- const check = code === activeCurrency ? ' *' : ''
- // The real CLI subcommand is `codeburn currency [code]` (with `--reset`
- // for USD), not `codeburn config currency` — the latter doesn't exist
- // and silently fails when SwiftBar runs it. Fixes #27.
- if (code === 'USD') {
- lines.push(`--${name} (${code})${check} | terminal=false refresh=true shell=${bin} param1=currency param2=--reset`)
- } else {
- lines.push(`--${name} (${code})${check} | terminal=false refresh=true shell=${bin} param1=currency param2=${code}`)
- }
- }
-
- lines.push(`Refresh | refresh=true`)
-
- return lines.join('\n')
-}
-
-export async function installMenubar(): Promise {
- if (platform() !== 'darwin') {
- return 'Menu bar integration is only available on macOS. Use `codeburn watch` or `codeburn status` instead.'
- }
-
- const bin = getCodeburnBin()
- const pluginContent = generatePlugin(bin)
-
- const { pluginDir, appName } = chooseMenubarPluginDir(getSwiftBarPluginDirs(), getXbarPluginDir(), existsSync)
-
- if (!existsSync(pluginDir)) {
- await mkdir(pluginDir, { recursive: true })
- }
-
- const pluginPath = join(pluginDir, `codeburn.${PLUGIN_REFRESH}.sh`)
- await writeFile(pluginPath, pluginContent, 'utf-8')
- await chmod(pluginPath, 0o755)
-
- const swiftbarInstalled = existsSync('/Applications/SwiftBar.app') || existsSync(join(homedir(), 'Applications', 'SwiftBar.app'))
- const xbarInstalled = existsSync('/Applications/xbar.app') || existsSync(join(homedir(), 'Applications', 'xbar.app'))
-
- const lines: string[] = []
- lines.push(`\n Plugin installed to: ${pluginPath}`)
-
- if (swiftbarInstalled || xbarInstalled) {
- lines.push(` ${appName} detected - plugin should appear in your menu bar shortly.`)
- lines.push(` If not, open ${appName} and refresh plugins.\n`)
- } else {
- lines.push(`\n To see CodeBurn in your menu bar, install SwiftBar:`)
- lines.push(` brew install --cask swiftbar`)
- lines.push(`\n Then launch SwiftBar - the plugin will load automatically.\n`)
- }
-
- return lines.join('\n')
-}
-
-export async function uninstallMenubar(): Promise {
- const paths = [
- ...getSwiftBarPluginDirs().map(dir => join(dir, `codeburn.${PLUGIN_REFRESH}.sh`)),
- join(getXbarPluginDir(), `codeburn.${PLUGIN_REFRESH}.sh`),
- ]
-
- let removed = false
- for (const p of paths) {
- if (existsSync(p)) {
- await unlink(p)
- removed = true
- }
- }
-
- return removed
- ? '\n Menu bar plugin removed.\n'
- : '\n No menu bar plugin found.\n'
-}
diff --git a/src/parser.ts b/src/parser.ts
index 5ba0da9..4adcf3b 100644
--- a/src/parser.ts
+++ b/src/parser.ts
@@ -1,4 +1,4 @@
-import { readdir } from 'fs/promises'
+import { readdir, stat } from 'fs/promises'
import { basename, join } from 'path'
import { readSessionFile } from './fs-utils.js'
import { calculateCost, getShortModelName } from './models.js'
@@ -266,6 +266,15 @@ async function parseSessionFile(
seenMsgIds: Set,
dateRange?: DateRange,
): Promise {
+ // Skip files whose mtime is older than the range start. A session file
+ // can only contain entries up to its last-modified time; if that predates
+ // the requested range, nothing in this file can match.
+ if (dateRange) {
+ try {
+ const s = await stat(filePath)
+ if (s.mtimeMs < dateRange.start.getTime()) return null
+ } catch { /* fall through to normal read; missing stat shouldn't break parsing */ }
+ }
const content = await readSessionFile(filePath)
if (content === null) return null
const lines = content.split('\n').filter(l => l.trim())
@@ -388,6 +397,12 @@ async function parseProviderSources(
const sessionMap = new Map()
for (const source of sources) {
+ if (dateRange) {
+ try {
+ const s = await stat(source.path)
+ if (s.mtimeMs < dateRange.start.getTime()) continue
+ } catch { /* fall through; treat unknown stat as "may contain data" */ }
+ }
const parser = provider.createSessionParser(
{ path: source.path, project: source.project, provider: providerName },
seenKeys,
diff --git a/tests/daily-cache.test.ts b/tests/daily-cache.test.ts
new file mode 100644
index 0000000..b1741e3
--- /dev/null
+++ b/tests/daily-cache.test.ts
@@ -0,0 +1,189 @@
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import { readFile, rm } from 'fs/promises'
+import { existsSync } from 'fs'
+import { tmpdir } from 'os'
+import { join } from 'path'
+
+import {
+ addNewDays,
+ DAILY_CACHE_VERSION,
+ type DailyCache,
+ type DailyEntry,
+ getDaysInRange,
+ loadDailyCache,
+ saveDailyCache,
+ withDailyCacheLock,
+} from '../src/daily-cache.js'
+
+function emptyDay(date: string, cost = 0, calls = 0): DailyEntry {
+ return {
+ date,
+ cost,
+ calls,
+ sessions: 0,
+ inputTokens: 0,
+ outputTokens: 0,
+ cacheReadTokens: 0,
+ cacheWriteTokens: 0,
+ editTurns: 0,
+ oneShotTurns: 0,
+ models: {},
+ categories: {},
+ providers: {},
+ }
+}
+
+const TMP_CACHE_ROOT = join(tmpdir(), `codeburn-cache-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`)
+
+beforeEach(() => {
+ process.env['CODEBURN_CACHE_DIR'] = TMP_CACHE_ROOT
+})
+
+afterEach(async () => {
+ delete process.env['CODEBURN_CACHE_DIR']
+ if (existsSync(TMP_CACHE_ROOT)) {
+ await rm(TMP_CACHE_ROOT, { recursive: true, force: true })
+ }
+})
+
+describe('loadDailyCache', () => {
+ it('returns an empty cache when the file does not exist', async () => {
+ const cache = await loadDailyCache()
+ expect(cache.version).toBe(DAILY_CACHE_VERSION)
+ expect(cache.lastComputedDate).toBeNull()
+ expect(cache.days).toEqual([])
+ })
+
+ it('returns an empty cache when the file contains invalid JSON', async () => {
+ const { writeFile, mkdir } = await import('fs/promises')
+ await mkdir(TMP_CACHE_ROOT, { recursive: true })
+ await writeFile(join(TMP_CACHE_ROOT, 'daily-cache.json'), 'not valid json{{', 'utf-8')
+ const cache = await loadDailyCache()
+ expect(cache.days).toEqual([])
+ })
+
+ it('returns an empty cache when the version does not match', async () => {
+ const saved: DailyCache = {
+ version: DAILY_CACHE_VERSION - 999,
+ lastComputedDate: '2026-04-10',
+ days: [emptyDay('2026-04-10', 10)],
+ }
+ const { writeFile, mkdir } = await import('fs/promises')
+ await mkdir(TMP_CACHE_ROOT, { recursive: true })
+ await writeFile(join(TMP_CACHE_ROOT, 'daily-cache.json'), JSON.stringify(saved), 'utf-8')
+ const cache = await loadDailyCache()
+ expect(cache.days).toEqual([])
+ expect(cache.lastComputedDate).toBeNull()
+ })
+
+ it('round-trips a valid cache through save and load', async () => {
+ const saved: DailyCache = {
+ version: DAILY_CACHE_VERSION,
+ lastComputedDate: '2026-04-10',
+ days: [emptyDay('2026-04-09', 12.5, 40), emptyDay('2026-04-10', 7.25, 28)],
+ }
+ await saveDailyCache(saved)
+ const loaded = await loadDailyCache()
+ expect(loaded).toEqual(saved)
+ })
+})
+
+describe('saveDailyCache', () => {
+ it('writes atomically so no temp file is left after a successful save', async () => {
+ const saved: DailyCache = {
+ version: DAILY_CACHE_VERSION,
+ lastComputedDate: '2026-04-10',
+ days: [emptyDay('2026-04-10', 5)],
+ }
+ await saveDailyCache(saved)
+ const { readdir } = await import('fs/promises')
+ const files = await readdir(TMP_CACHE_ROOT)
+ const tempLeftovers = files.filter(f => f.endsWith('.tmp'))
+ expect(tempLeftovers).toEqual([])
+ const finalFile = await readFile(join(TMP_CACHE_ROOT, 'daily-cache.json'), 'utf-8')
+ expect(JSON.parse(finalFile)).toEqual(saved)
+ })
+})
+
+describe('addNewDays', () => {
+ it('returns a new cache with the added days sorted ascending by date', () => {
+ const base: DailyCache = {
+ version: DAILY_CACHE_VERSION,
+ lastComputedDate: '2026-04-08',
+ days: [emptyDay('2026-04-07', 3), emptyDay('2026-04-08', 5)],
+ }
+ const updated = addNewDays(base, [emptyDay('2026-04-10', 9), emptyDay('2026-04-09', 7)], '2026-04-10')
+ expect(updated.days.map(d => d.date)).toEqual(['2026-04-07', '2026-04-08', '2026-04-09', '2026-04-10'])
+ expect(updated.lastComputedDate).toBe('2026-04-10')
+ })
+
+ it('skips days already present in the cache (first write wins)', () => {
+ const base: DailyCache = {
+ version: DAILY_CACHE_VERSION,
+ lastComputedDate: '2026-04-08',
+ days: [emptyDay('2026-04-08', 5)],
+ }
+ const updated = addNewDays(base, [emptyDay('2026-04-08', 99)], '2026-04-08')
+ const aprilEight = updated.days.find(d => d.date === '2026-04-08')!
+ expect(aprilEight.cost).toBe(5)
+ })
+
+ it('does not regress lastComputedDate if incoming newestDate is older', () => {
+ const base: DailyCache = {
+ version: DAILY_CACHE_VERSION,
+ lastComputedDate: '2026-04-10',
+ days: [emptyDay('2026-04-10', 5)],
+ }
+ const updated = addNewDays(base, [emptyDay('2026-04-05', 3)], '2026-04-05')
+ expect(updated.lastComputedDate).toBe('2026-04-10')
+ })
+})
+
+describe('getDaysInRange', () => {
+ const cache: DailyCache = {
+ version: DAILY_CACHE_VERSION,
+ lastComputedDate: '2026-04-10',
+ days: [
+ emptyDay('2026-04-05', 1),
+ emptyDay('2026-04-06', 2),
+ emptyDay('2026-04-07', 3),
+ emptyDay('2026-04-08', 4),
+ emptyDay('2026-04-09', 5),
+ emptyDay('2026-04-10', 6),
+ ],
+ }
+
+ it('returns inclusive start and end range', () => {
+ const days = getDaysInRange(cache, '2026-04-07', '2026-04-09')
+ expect(days.map(d => d.date)).toEqual(['2026-04-07', '2026-04-08', '2026-04-09'])
+ })
+
+ it('returns empty when range is entirely outside cache', () => {
+ expect(getDaysInRange(cache, '2026-03-01', '2026-03-10')).toEqual([])
+ expect(getDaysInRange(cache, '2026-05-01', '2026-05-10')).toEqual([])
+ })
+
+ it('clips to available cache days when range extends beyond', () => {
+ const days = getDaysInRange(cache, '2026-04-09', '2026-04-20')
+ expect(days.map(d => d.date)).toEqual(['2026-04-09', '2026-04-10'])
+ })
+})
+
+describe('withDailyCacheLock', () => {
+ it('serializes concurrent operations', async () => {
+ const sequence: string[] = []
+ const op = async (tag: string): Promise => {
+ await withDailyCacheLock(async () => {
+ sequence.push(`start-${tag}`)
+ await new Promise(r => setTimeout(r, 20))
+ sequence.push(`end-${tag}`)
+ })
+ }
+ await Promise.all([op('a'), op('b'), op('c')])
+ for (let i = 0; i < sequence.length; i += 2) {
+ expect(sequence[i]?.startsWith('start-')).toBe(true)
+ expect(sequence[i + 1]?.startsWith('end-')).toBe(true)
+ expect(sequence[i]!.slice(6)).toBe(sequence[i + 1]!.slice(4))
+ }
+ })
+})
diff --git a/tests/day-aggregator.test.ts b/tests/day-aggregator.test.ts
new file mode 100644
index 0000000..fb90840
--- /dev/null
+++ b/tests/day-aggregator.test.ts
@@ -0,0 +1,258 @@
+import { describe, expect, it } from 'vitest'
+
+import { aggregateProjectsIntoDays, buildPeriodDataFromDays } from '../src/day-aggregator.js'
+import type { ProjectSummary } from '../src/types.js'
+
+function makeProject(overrides: Partial & { sessions: ProjectSummary['sessions'] }): ProjectSummary {
+ return {
+ project: 'p',
+ projectPath: '/p',
+ totalCostUSD: overrides.sessions.reduce((s, sess) => s + sess.totalCostUSD, 0),
+ totalApiCalls: overrides.sessions.reduce((s, sess) => s + sess.apiCalls, 0),
+ ...overrides,
+ }
+}
+
+function makeCall(timestamp: string, costUSD: number, model = 'Opus 4.7', provider = 'claude') {
+ return {
+ provider,
+ model,
+ usage: {
+ inputTokens: 100,
+ outputTokens: 200,
+ cacheCreationInputTokens: 0,
+ cacheReadInputTokens: 50,
+ cachedInputTokens: 0,
+ reasoningTokens: 0,
+ webSearchRequests: 0,
+ },
+ costUSD,
+ tools: [],
+ mcpTools: [],
+ hasAgentSpawn: false,
+ hasPlanMode: false,
+ speed: 'standard' as const,
+ timestamp,
+ bashCommands: [],
+ deduplicationKey: `dk-${timestamp}-${costUSD}`,
+ }
+}
+
+describe('aggregateProjectsIntoDays', () => {
+ it('buckets api calls by calendar date derived from timestamp', () => {
+ const projects: ProjectSummary[] = [
+ makeProject({
+ sessions: [{
+ sessionId: 's1',
+ project: 'p',
+ firstTimestamp: '2026-04-09T10:00:00Z',
+ lastTimestamp: '2026-04-10T08:00:00Z',
+ totalCostUSD: 10,
+ totalInputTokens: 0,
+ totalOutputTokens: 0,
+ totalCacheReadTokens: 0,
+ totalCacheWriteTokens: 0,
+ apiCalls: 2,
+ turns: [
+ {
+ userMessage: 'hi',
+ timestamp: '2026-04-09T10:00:00Z',
+ sessionId: 's1',
+ category: 'coding',
+ retries: 0,
+ hasEdits: true,
+ assistantCalls: [
+ makeCall('2026-04-09T10:00:00Z', 4),
+ makeCall('2026-04-10T08:00:00Z', 6),
+ ],
+ },
+ ],
+ modelBreakdown: {},
+ toolBreakdown: {},
+ mcpBreakdown: {},
+ bashBreakdown: {},
+ categoryBreakdown: {} as never,
+ }],
+ }),
+ ]
+
+ const days = aggregateProjectsIntoDays(projects)
+ expect(days.map(d => d.date)).toEqual(['2026-04-09', '2026-04-10'])
+ expect(days[0]!.cost).toBe(4)
+ expect(days[0]!.calls).toBe(1)
+ expect(days[1]!.cost).toBe(6)
+ expect(days[1]!.calls).toBe(1)
+ })
+
+ it('attributes category turns + editTurns + oneShotTurns to the first call date of the turn', () => {
+ const projects: ProjectSummary[] = [
+ makeProject({
+ sessions: [{
+ sessionId: 's1',
+ project: 'p',
+ firstTimestamp: '2026-04-09T10:00:00Z',
+ lastTimestamp: '2026-04-09T10:05:00Z',
+ totalCostUSD: 3,
+ totalInputTokens: 0,
+ totalOutputTokens: 0,
+ totalCacheReadTokens: 0,
+ totalCacheWriteTokens: 0,
+ apiCalls: 1,
+ turns: [
+ {
+ userMessage: 'hi',
+ timestamp: '2026-04-09T10:00:00Z',
+ sessionId: 's1',
+ category: 'coding',
+ retries: 0,
+ hasEdits: true,
+ assistantCalls: [makeCall('2026-04-09T10:00:00Z', 3)],
+ },
+ ],
+ modelBreakdown: {},
+ toolBreakdown: {},
+ mcpBreakdown: {},
+ bashBreakdown: {},
+ categoryBreakdown: {} as never,
+ }],
+ }),
+ ]
+ const days = aggregateProjectsIntoDays(projects)
+ const day = days[0]!
+ expect(day.editTurns).toBe(1)
+ expect(day.oneShotTurns).toBe(1)
+ expect(day.categories['coding']).toEqual({
+ turns: 1,
+ cost: 3,
+ editTurns: 1,
+ oneShotTurns: 1,
+ })
+ })
+
+ it('counts a session under its firstTimestamp date', () => {
+ const projects: ProjectSummary[] = [
+ makeProject({
+ sessions: [{
+ sessionId: 's1',
+ project: 'p',
+ firstTimestamp: '2026-04-09T23:59:00Z',
+ lastTimestamp: '2026-04-10T00:10:00Z',
+ totalCostUSD: 1,
+ totalInputTokens: 0, totalOutputTokens: 0, totalCacheReadTokens: 0, totalCacheWriteTokens: 0,
+ apiCalls: 0,
+ turns: [],
+ modelBreakdown: {}, toolBreakdown: {}, mcpBreakdown: {}, bashBreakdown: {},
+ categoryBreakdown: {} as never,
+ }],
+ }),
+ ]
+ const days = aggregateProjectsIntoDays(projects)
+ expect(days[0]!.date).toBe('2026-04-09')
+ expect(days[0]!.sessions).toBe(1)
+ })
+
+ it('aggregates per-model and per-provider totals inside each day', () => {
+ const projects: ProjectSummary[] = [
+ makeProject({
+ sessions: [{
+ sessionId: 's1',
+ project: 'p',
+ firstTimestamp: '2026-04-10T10:00:00Z',
+ lastTimestamp: '2026-04-10T10:00:00Z',
+ totalCostUSD: 10,
+ totalInputTokens: 0, totalOutputTokens: 0, totalCacheReadTokens: 0, totalCacheWriteTokens: 0,
+ apiCalls: 2,
+ turns: [
+ {
+ userMessage: 'x', timestamp: '2026-04-10T10:00:00Z', sessionId: 's1',
+ category: 'coding', retries: 0, hasEdits: false,
+ assistantCalls: [
+ makeCall('2026-04-10T10:00:00Z', 7, 'Opus 4.7', 'claude'),
+ makeCall('2026-04-10T10:00:00Z', 3, 'gpt-5', 'codex'),
+ ],
+ },
+ ],
+ modelBreakdown: {}, toolBreakdown: {}, mcpBreakdown: {}, bashBreakdown: {},
+ categoryBreakdown: {} as never,
+ }],
+ }),
+ ]
+ const days = aggregateProjectsIntoDays(projects)
+ const day = days[0]!
+ expect(day.models['Opus 4.7']).toEqual({
+ calls: 1, cost: 7,
+ inputTokens: 100, outputTokens: 200,
+ cacheReadTokens: 50, cacheWriteTokens: 0,
+ })
+ expect(day.models['gpt-5']).toEqual({
+ calls: 1, cost: 3,
+ inputTokens: 100, outputTokens: 200,
+ cacheReadTokens: 50, cacheWriteTokens: 0,
+ })
+ expect(day.providers['claude']).toEqual({ calls: 1, cost: 7 })
+ expect(day.providers['codex']).toEqual({ calls: 1, cost: 3 })
+ })
+})
+
+describe('buildPeriodDataFromDays', () => {
+ function makeDay(date: string, cost: number) {
+ return {
+ date,
+ cost,
+ calls: 10,
+ sessions: 2,
+ inputTokens: 100,
+ outputTokens: 200,
+ cacheReadTokens: 300,
+ cacheWriteTokens: 0,
+ editTurns: 3,
+ oneShotTurns: 2,
+ models: {
+ 'Opus 4.7': { calls: 8, cost: cost * 0.8, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 },
+ 'Haiku 4.5': { calls: 2, cost: cost * 0.2, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 },
+ },
+ categories: { 'coding': { turns: 2, cost: cost * 0.5, editTurns: 2, oneShotTurns: 1 } },
+ providers: { 'claude': { calls: 10, cost } },
+ }
+ }
+
+ it('sums cost, calls, sessions, tokens across days', () => {
+ const days = [makeDay('2026-04-09', 10), makeDay('2026-04-10', 20)]
+ const pd = buildPeriodDataFromDays(days, '7 Days')
+ expect(pd.label).toBe('7 Days')
+ expect(pd.cost).toBe(30)
+ expect(pd.calls).toBe(20)
+ expect(pd.sessions).toBe(4)
+ expect(pd.inputTokens).toBe(200)
+ expect(pd.outputTokens).toBe(400)
+ expect(pd.cacheReadTokens).toBe(600)
+ })
+
+ it('merges per-model totals across days and sorts by cost desc', () => {
+ const days = [makeDay('2026-04-09', 10), makeDay('2026-04-10', 20)]
+ const pd = buildPeriodDataFromDays(days, 'Today')
+ expect(pd.models[0]!.name).toBe('Opus 4.7')
+ expect(pd.models[0]!.cost).toBeCloseTo(24)
+ expect(pd.models[1]!.name).toBe('Haiku 4.5')
+ expect(pd.models[1]!.cost).toBeCloseTo(6)
+ })
+
+ it('merges per-category totals and keeps editTurns + oneShotTurns per category', () => {
+ const days = [makeDay('2026-04-09', 10), makeDay('2026-04-10', 20)]
+ const pd = buildPeriodDataFromDays(days, 'Today')
+ const coding = pd.categories.find(c => c.name === 'Coding')!
+ expect(coding.turns).toBe(4)
+ expect(coding.editTurns).toBe(4)
+ expect(coding.oneShotTurns).toBe(2)
+ expect(coding.cost).toBeCloseTo(15)
+ })
+
+ it('returns empty period totals when no days supplied', () => {
+ const pd = buildPeriodDataFromDays([], 'Today')
+ expect(pd.cost).toBe(0)
+ expect(pd.calls).toBe(0)
+ expect(pd.sessions).toBe(0)
+ expect(pd.categories).toEqual([])
+ expect(pd.models).toEqual([])
+ })
+})
diff --git a/tests/export.test.ts b/tests/export.test.ts
index 1f196bf..b9b6a99 100644
--- a/tests/export.test.ts
+++ b/tests/export.test.ts
@@ -120,8 +120,15 @@ describe('exportCsv', () => {
]
const outputPath = join(tmpDir, 'report.csv')
- await exportCsv(periods, outputPath)
- const content = await readFile(outputPath, 'utf-8')
+ const folder = await exportCsv(periods, outputPath)
+ // exportCsv now writes a folder of clean one-table-per-file CSVs, so the formula-prefix
+ // guard is scattered across files. Concatenate them for the assertion surface.
+ const [projects, models, shell] = await Promise.all([
+ readFile(join(folder, 'projects.csv'), 'utf-8'),
+ readFile(join(folder, 'models.csv'), 'utf-8'),
+ readFile(join(folder, 'shell-commands.csv'), 'utf-8'),
+ ])
+ const content = projects + models + shell
expect(content).toContain("\"'=cmd,calc\"")
expect(content).toContain("'+danger-model")
diff --git a/tests/menubar-json.test.ts b/tests/menubar-json.test.ts
new file mode 100644
index 0000000..f7493d0
--- /dev/null
+++ b/tests/menubar-json.test.ts
@@ -0,0 +1,234 @@
+import { describe, expect, it } from 'vitest'
+
+import { buildMenubarPayload, type PeriodData, type ProviderCost } from '../src/menubar-json.js'
+import type { OptimizeResult } from '../src/optimize.js'
+
+function emptyPeriod(label: string): PeriodData {
+ return {
+ label,
+ cost: 0,
+ calls: 0,
+ sessions: 0,
+ inputTokens: 0,
+ outputTokens: 0,
+ cacheReadTokens: 0,
+ cacheWriteTokens: 0,
+ categories: [],
+ models: [],
+ }
+}
+
+describe('buildMenubarPayload', () => {
+ it('emits the full schema with current-period metrics and iso timestamp', () => {
+ const period: PeriodData = {
+ label: '7 Days',
+ cost: 1248.01,
+ calls: 11231,
+ sessions: 97,
+ inputTokens: 19100,
+ outputTokens: 675600,
+ cacheReadTokens: 0,
+ cacheWriteTokens: 0,
+ categories: [],
+ models: [],
+ }
+ const payload = buildMenubarPayload(period, [], null)
+
+ expect(payload.generated).toMatch(/^\d{4}-\d{2}-\d{2}T/)
+ expect(payload.current.label).toBe('7 Days')
+ expect(payload.current.cost).toBe(1248.01)
+ expect(payload.current.calls).toBe(11231)
+ expect(payload.current.sessions).toBe(97)
+ expect(payload.current.inputTokens).toBe(19100)
+ expect(payload.current.outputTokens).toBe(675600)
+ })
+
+ it('computes per-category oneShotRate from editTurns and skips categories without edits', () => {
+ const period: PeriodData = {
+ label: 'Today',
+ cost: 0, calls: 0, sessions: 0,
+ inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0,
+ categories: [
+ { name: 'Coding', cost: 15.83, turns: 7, editTurns: 7, oneShotTurns: 6 },
+ { name: 'Conversation', cost: 16.69, turns: 47, editTurns: 0, oneShotTurns: 0 },
+ ],
+ models: [],
+ }
+ const payload = buildMenubarPayload(period, [], null)
+
+ const coding = payload.current.topActivities.find(a => a.name === 'Coding')!
+ expect(coding.oneShotRate).toBeCloseTo(6 / 7)
+
+ const conv = payload.current.topActivities.find(a => a.name === 'Conversation')!
+ expect(conv.oneShotRate).toBeNull()
+ })
+
+ it('computes aggregate oneShotRate across categories with edits', () => {
+ const period: PeriodData = {
+ label: 'Today',
+ cost: 0, calls: 0, sessions: 0,
+ inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0,
+ categories: [
+ { name: 'Coding', cost: 1, turns: 7, editTurns: 10, oneShotTurns: 8 },
+ { name: 'Debugging', cost: 1, turns: 5, editTurns: 10, oneShotTurns: 6 },
+ { name: 'Conversation', cost: 1, turns: 40, editTurns: 0, oneShotTurns: 0 },
+ ],
+ models: [],
+ }
+ const payload = buildMenubarPayload(period, [], null)
+ expect(payload.current.oneShotRate).toBeCloseTo((8 + 6) / (10 + 10))
+ })
+
+ it('returns null aggregate oneShotRate when no categories have editTurns', () => {
+ const period: PeriodData = {
+ label: 'Today',
+ cost: 0, calls: 0, sessions: 0,
+ inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0,
+ categories: [{ name: 'Conversation', cost: 1, turns: 5, editTurns: 0, oneShotTurns: 0 }],
+ models: [],
+ }
+ const payload = buildMenubarPayload(period, [], null)
+ expect(payload.current.oneShotRate).toBeNull()
+ })
+
+ it('filters out the synthetic model and caps topModels at 20 so multi-model users see all their models', () => {
+ const models = Array.from({ length: 30 }, (_, i) => ({
+ name: `Model${i}`, cost: 30 - i, calls: 100,
+ }))
+ const period: PeriodData = {
+ label: 'Today',
+ cost: 0, calls: 0, sessions: 0,
+ inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0,
+ categories: [],
+ models: [{ name: '', cost: 99, calls: 0 }, ...models],
+ }
+ const payload = buildMenubarPayload(period, [], null)
+ expect(payload.current.topModels.find(m => m.name === '')).toBeUndefined()
+ expect(payload.current.topModels).toHaveLength(20)
+ expect(payload.current.topModels[0].name).toBe('Model0')
+ })
+
+ it('caps topActivities at 20 so all task categories can surface', () => {
+ const period: PeriodData = {
+ label: 'Today',
+ cost: 0, calls: 0, sessions: 0,
+ inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0,
+ categories: Array.from({ length: 25 }, (_, i) => ({
+ name: `Cat${i}`, cost: 1, turns: 1, editTurns: 1, oneShotTurns: 1,
+ })),
+ models: [],
+ }
+ const payload = buildMenubarPayload(period, [], null)
+ expect(payload.current.topActivities).toHaveLength(20)
+ })
+
+ it('computes cacheHitPercent from cache reads over input plus cache reads', () => {
+ const period: PeriodData = {
+ label: 'Today',
+ cost: 0, calls: 0, sessions: 0,
+ inputTokens: 100,
+ outputTokens: 200,
+ cacheReadTokens: 900,
+ cacheWriteTokens: 0,
+ categories: [],
+ models: [],
+ }
+ const payload = buildMenubarPayload(period, [], null)
+ expect(payload.current.cacheHitPercent).toBeCloseTo(90)
+ })
+
+ it('returns zero cacheHitPercent when there is no input or cache traffic', () => {
+ const payload = buildMenubarPayload(emptyPeriod('Today'), [], null)
+ expect(payload.current.cacheHitPercent).toBe(0)
+ })
+
+ it('handles null optimize as empty findings block', () => {
+ const payload = buildMenubarPayload(emptyPeriod('Today'), [], null)
+ expect(payload.optimize).toEqual({ findingCount: 0, savingsUSD: 0, topFindings: [] })
+ })
+
+ it('converts tokensSaved to savingsUSD via costRate and caps topFindings at 10', () => {
+ const findings = Array.from({ length: 15 }, (_, i) => ({
+ title: `F${i}`, explanation: '', impact: 'low' as const, tokensSaved: 1000,
+ fix: { type: 'paste' as const, label: '', text: '' },
+ }))
+ const optimize: OptimizeResult = {
+ findings,
+ costRate: 0.00002,
+ healthScore: 60,
+ healthGrade: 'C',
+ }
+ const payload = buildMenubarPayload(emptyPeriod('Today'), [], optimize)
+
+ expect(payload.optimize.findingCount).toBe(15)
+ expect(payload.optimize.topFindings).toHaveLength(10)
+ expect(payload.optimize.topFindings[0].title).toBe('F0')
+ expect(payload.optimize.topFindings[0].savingsUSD).toBeCloseTo(1000 * 0.00002)
+ expect(payload.optimize.savingsUSD).toBeCloseTo(15 * 1000 * 0.00002)
+ })
+
+ it('maps providers into a lowercased dict inside the current-period block', () => {
+ const providers: ProviderCost[] = [
+ { name: 'Claude Code', cost: 76.45 },
+ { name: 'Cursor', cost: 2.18 },
+ { name: 'Codex', cost: 1.5 },
+ ]
+ const payload = buildMenubarPayload(emptyPeriod('Today'), providers, null)
+ expect(payload.current.providers).toEqual({ 'claude code': 76.45, cursor: 2.18, codex: 1.5 })
+ })
+
+ it('keeps zero-cost providers in the dict so installed-but-unused providers still render as tabs', () => {
+ const providers: ProviderCost[] = [
+ { name: 'Claude', cost: 76.45 },
+ { name: 'Codex', cost: 0 },
+ { name: 'Cursor', cost: 2.18 },
+ ]
+ const payload = buildMenubarPayload(emptyPeriod('Today'), providers, null)
+ expect(payload.current.providers).toEqual({ claude: 76.45, codex: 0, cursor: 2.18 })
+ })
+
+ it('includes up to 365 daily history entries sorted ascending by date', () => {
+ const history = Array.from({ length: 400 }, (_, i) => {
+ const d = new Date(2025, 0, 1)
+ d.setDate(d.getDate() + i)
+ return {
+ date: d.toISOString().slice(0, 10),
+ cost: i,
+ calls: i * 10,
+ inputTokens: 0,
+ outputTokens: 0,
+ cacheReadTokens: 0,
+ cacheWriteTokens: 0,
+ topModels: [],
+ }
+ })
+ const payload = buildMenubarPayload(emptyPeriod('Today'), [], null, history)
+ expect(payload.history.daily).toHaveLength(365)
+ expect(payload.history.daily[0]!.date < payload.history.daily[364]!.date).toBe(true)
+ expect(payload.history.daily[364]!.date).toBe(history[399]!.date)
+ })
+
+ it('preserves token fields in dailyHistory entries', () => {
+ const history = [
+ { date: '2026-04-15', cost: 10, calls: 50, inputTokens: 100, outputTokens: 200, cacheReadTokens: 5000, cacheWriteTokens: 800, topModels: [{ name: 'Opus 4.7', cost: 8, calls: 40, inputTokens: 80, outputTokens: 160 }] },
+ { date: '2026-04-16', cost: 20, calls: 75, inputTokens: 150, outputTokens: 350, cacheReadTokens: 8000, cacheWriteTokens: 1200, topModels: [] },
+ ]
+ const payload = buildMenubarPayload(emptyPeriod('Today'), [], null, history)
+ expect(payload.history.daily[0]).toEqual(history[0])
+ expect(payload.history.daily[1]).toEqual(history[1])
+ })
+
+ it('returns empty history when none supplied', () => {
+ const payload = buildMenubarPayload(emptyPeriod('Today'), [], null)
+ expect(payload.history.daily).toEqual([])
+ })
+
+ it('drops providers with negative cost defensively', () => {
+ const providers: ProviderCost[] = [
+ { name: 'Claude', cost: 76.45 },
+ { name: 'Broken', cost: -1 },
+ ]
+ const payload = buildMenubarPayload(emptyPeriod('Today'), providers, null)
+ expect(payload.current.providers).toEqual({ claude: 76.45 })
+ })
+})
diff --git a/tests/menubar.test.ts b/tests/menubar.test.ts
deleted file mode 100644
index 93936b8..0000000
--- a/tests/menubar.test.ts
+++ /dev/null
@@ -1,57 +0,0 @@
-import { describe, expect, it } from 'vitest'
-import { join } from 'path'
-import { homedir } from 'os'
-
-import { chooseMenubarPluginDir, parsePluginDirectoryPreference } from '../src/menubar.js'
-
-describe('parsePluginDirectoryPreference', () => {
- it('trims defaults output and preserves spaces in paths', () => {
- expect(parsePluginDirectoryPreference('/Users/test/Documents/Tech stuff/swiftbar_plugins\n')).toBe('/Users/test/Documents/Tech stuff/swiftbar_plugins')
- })
-
- it('expands tilde paths', () => {
- expect(parsePluginDirectoryPreference('~/swiftbar_plugins')).toBe(join(homedir(), 'swiftbar_plugins'))
- })
-
- it('ignores blank preference values', () => {
- expect(parsePluginDirectoryPreference(' \n')).toBeUndefined()
- })
-})
-
-describe('chooseMenubarPluginDir', () => {
- const configuredSwiftBarDir = '/Users/test/Documents/Tech stuff/swiftbar_plugins'
- const defaultSwiftBarDir = '/Users/test/Library/Application Support/SwiftBar/plugins'
- const xbarDir = '/Users/test/Library/Application Support/xbar/plugins'
-
- it('uses SwiftBar configured plugin directory before the default directory', () => {
- const existing = new Set([configuredSwiftBarDir, defaultSwiftBarDir])
- const result = chooseMenubarPluginDir(
- [configuredSwiftBarDir, defaultSwiftBarDir],
- xbarDir,
- path => existing.has(path),
- )
-
- expect(result).toEqual({ pluginDir: configuredSwiftBarDir, appName: 'SwiftBar' })
- })
-
- it('falls back to xbar when no SwiftBar plugin directory exists', () => {
- const existing = new Set([xbarDir])
- const result = chooseMenubarPluginDir(
- [defaultSwiftBarDir],
- xbarDir,
- path => existing.has(path),
- )
-
- expect(result).toEqual({ pluginDir: xbarDir, appName: 'xbar' })
- })
-
- it('creates the preferred SwiftBar directory when no plugin directory exists', () => {
- const result = chooseMenubarPluginDir(
- [configuredSwiftBarDir, defaultSwiftBarDir],
- xbarDir,
- () => false,
- )
-
- expect(result).toEqual({ pluginDir: configuredSwiftBarDir, appName: 'SwiftBar' })
- })
-})
diff --git a/tests/security/menubar-injection.test.ts b/tests/security/menubar-injection.test.ts
deleted file mode 100644
index d8de96d..0000000
--- a/tests/security/menubar-injection.test.ts
+++ /dev/null
@@ -1,47 +0,0 @@
-import { describe, it, expect } from 'vitest'
-
-import { renderMenubarFormat, type PeriodData } from '../../src/menubar.js'
-
-const ESC = '\u001b'
-
-function period(name: string): PeriodData {
- return {
- label: 'x',
- cost: 0.01,
- calls: 1,
- inputTokens: 1,
- outputTokens: 1,
- cacheReadTokens: 0,
- cacheWriteTokens: 0,
- categories: [{ name, cost: 0.01, turns: 1, editTurns: 0, oneShotTurns: 1 }],
- models: [{ name, cost: 0.01, calls: 1 }],
- }
-}
-
-function linesWithToken(output: string, token: string): string[] {
- return output.split('\n').filter(l => l.includes(token))
-}
-
-describe('MEDIUM-2 menubar directive separator injection', () => {
- it('strips pipe separators from model names', () => {
- const p = period('foo | href=https://attacker.example/pwn')
- const out = renderMenubarFormat(p, p, p, p)
- for (const line of linesWithToken(out, 'foo')) {
- expect(line.split('|').length).toBeLessThanOrEqual(2)
- }
- })
-
- it('strips ANSI escapes from model names', () => {
- const p = period(`foo${ESC}[31mMODEL${ESC}[0m`)
- const out = renderMenubarFormat(p, p, p, p)
- expect(out).not.toContain(ESC)
- })
-
- it('strips pipe separators from category names', () => {
- const p = period('cat | color=red')
- const out = renderMenubarFormat(p, p, p, p)
- for (const line of linesWithToken(out, 'cat')) {
- expect(line.split('|').length).toBeLessThanOrEqual(2)
- }
- })
-})