diff --git a/.github/workflows/block-claude-coauthor.yml b/.github/workflows/block-claude-coauthor.yml
new file mode 100644
index 0000000..c587da2
--- /dev/null
+++ b/.github/workflows/block-claude-coauthor.yml
@@ -0,0 +1,43 @@
+name: Block Claude / Anthropic co-author trailers
+
+# Rejects PRs that contain a `Co-authored-by: ... claude ...` or `... anthropic ...`
+# trailer in any of their commits. Contributors can still use AI tools to help
+# write code, but they must remove the co-author attribution before the PR is
+# eligible to merge, per the project's contributor guidelines.
+
+on:
+ pull_request:
+ types: [opened, synchronize, reopened]
+
+jobs:
+ check:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Scan PR commits for disallowed co-author trailers
+ run: |
+ BASE_SHA="${{ github.event.pull_request.base.sha }}"
+ HEAD_SHA="${{ github.event.pull_request.head.sha }}"
+ FOUND=0
+ while IFS= read -r c; do
+ [ -z "$c" ] && continue
+ SUBJECT=$(git log -1 "$c" --format=%s)
+ if git log -1 "$c" --format="%B" | grep -qiE 'co-authored-by:.*(claude|anthropic)'; then
+ echo "::error::Commit $c ($SUBJECT) has a Claude/Anthropic co-author trailer. Please remove it before this PR can merge."
+ FOUND=1
+ fi
+ done < <(git log --format=%H "$BASE_SHA".."$HEAD_SHA")
+ if [ "$FOUND" != "0" ]; then
+ echo ""
+ echo "How to fix:"
+ echo " 1. Run: git rebase -i $BASE_SHA"
+ echo " 2. Mark each flagged commit as 'reword'"
+ echo " 3. Delete the Co-authored-by line from the commit message"
+ echo " 4. Save, then: git push --force-with-lease"
+ exit 1
+ fi
+ echo "No disallowed co-author trailers found."
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..5233243
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,27 @@
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+
+jobs:
+ semgrep:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Install Semgrep
+ run: pip install semgrep
+
+ - name: Run Semgrep bracket-assign guard
+ run: |
+ set -e
+ semgrep --config .semgrep/rules/no-bracket-assign-hot-paths.yml \
+ --strict --json \
+ src/providers/ src/parser.ts > semgrep-out.json
+ FINDINGS=$(jq '.results | length' semgrep-out.json)
+ if [ "$FINDINGS" -gt 0 ]; then
+ jq -r '.results[] | "::error file=\(.path),line=\(.start.line)::\(.extra.message)"' semgrep-out.json
+ exit 1
+ fi
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/.gitignore b/.gitignore
index bc8b930..23a5028 100644
--- a/.gitignore
+++ b/.gitignore
@@ -15,6 +15,7 @@ Thumbs.db
# Planning artifacts (internal, not shipped)
docs/superpowers/
+.claude/
# Config / secrets
.env
@@ -32,3 +33,6 @@ npm-debug.log*
# Build artifacts
*.tsbuildinfo
+
+# Local Discord brand / promo assets not yet ready to publish
+assets/discord-*.png
diff --git a/.semgrep/rules/no-bracket-assign-hot-paths.yml b/.semgrep/rules/no-bracket-assign-hot-paths.yml
new file mode 100644
index 0000000..e2e633d
--- /dev/null
+++ b/.semgrep/rules/no-bracket-assign-hot-paths.yml
@@ -0,0 +1,22 @@
+rules:
+ - id: no-bracket-assign-on-literal-object-map
+ languages: [typescript]
+ severity: ERROR
+ message: >
+ Bracket-assign on a map created with `{}` allows prototype pollution when
+ the key comes from external data. Initialize the map with
+ `Object.create(null)` instead.
+ patterns:
+ - pattern-either:
+ - pattern: $MAP[$KEY] = $MAP[$KEY] ?? $INIT
+ - pattern: $MAP[$KEY] = $MAP[$KEY] || $INIT
+ - pattern: $MAP[$KEY] ??= $INIT
+ - pattern: |
+ if (!$MAP[$KEY]) $MAP[$KEY] = $INIT
+ - pattern-not-inside: |
+ const $MAP = Object.create(null)
+ ...
+ paths:
+ include:
+ - '/src/providers/*.ts'
+ - '/src/parser.ts'
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 34e4b46..d7f2beb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,117 @@
# Changelog
+## Unreleased
+
+### Added
+- **`codeburn report --from/--to`.** Filter sessions to an exact `YYYY-MM-DD` date range (local time). Either flag alone is valid: `--from` alone runs from the given date through end-of-today, `--to` alone runs from the earliest data through the given date. Inverted ranges or malformed dates exit with a clear error. In the TUI, pressing `1`-`5` still switches to the predefined periods. Credit: @lfl1337 (PR #80).
+- **`avgCostPerSession` in reports.** JSON `projects[]` entries gain an `avgCostPerSession` field and `export -f csv` adds an `Avg/Session (USD)` column to `projects.csv`. Column order in `projects.csv` is now `Project, Cost, Avg/Session, Share, API Calls, Sessions` -- scripts parsing by column position should read by header instead. Credit: @lfl1337 (PR #80).
+
+### Security
+- **Semgrep CI guard against prototype pollution regressions.** New `.github/workflows/ci.yml` runs a bracket-assign guard on `src/providers/` and `src/parser.ts` on every push to main and every PR. Blocks re-introducing `$MAP[$KEY] = $MAP[$KEY] ?? $INIT` patterns on `{}`-initialized maps. `categoryBreakdown` in `parser.ts` switched to `Object.create(null)` for consistency with its sibling breakdown maps. Credit: @lfl1337 (PR #78).
+
+## 0.7.3 - 2026-04-18
+
+### Changed
+- **Dropped `better-sqlite3` in favor of Node's built-in `node:sqlite`.** Removes the deprecated `prebuild-install` transitive dependency that npm warned about on every install (issue #75, credit @primeminister). End-user install is now 40 packages down from 167 and shows zero deprecation notices. The experimental-SQLite warning Node 22/23 normally prints on module load is silenced for this specific warning; other warnings pass through unchanged.
+- **Minimum Node version raised to 22.** Node 20 reached EOL on 2026-04-30; `node:sqlite` lives in 22+. Users on older Node get a clear upgrade message when a SQLite-backed provider (Cursor, OpenCode) is loaded.
+
+
+## 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
+- **External security audit closed.** 1 HIGH, 2 MEDIUM, and 1 LOW finding fixed. Threat model: a compromised third-party AI CLI with write access to `~/.claude/projects/` dropping malicious session JSONL.
+- **Prototype pollution blocked.** Breakdown maps in `parser.ts` (model, tool, MCP, bash) now use `Object.create(null)` so attacker-controlled keys like `__proto__` create own properties instead of mutating `Object.prototype`. Credit: @lfl1337 (PR #67).
+- **Bounded session-file reads.** New `src/fs-utils.ts` helper caps reads at 128 MB and switches to stream-based parsing above 8 MB. Applied to 13 reachable read sites across parser, Codex, Copilot, Pi, context-budget, and optimize. Credit: @lfl1337 (PR #67).
+- **Menubar label sanitizer.** SwiftBar directive-separator (`|`) and ANSI escape injection via crafted model or category names is now prevented by an allowlist (`[A-Za-z0-9 ._/-]`) plus 14-character truncation. Credit: @lfl1337 (PR #67).
+
+### Added
+- **`--verbose` flag.** Global CLI option that prints warnings to stderr on skipped (oversize) or failed session-file reads. Silent by default. Credit: @lfl1337 (PR #67).
+- **11 new security tests.** `tests/security/prototype-pollution.test.ts`, `tests/security/menubar-injection.test.ts`, `tests/fs-utils.test.ts`. Total suite: 209 tests.
+
+## 0.7.0 - 2026-04-16
+
+### Added
+- **`codeburn optimize` command.** Scans your sessions and your `~/.claude/`
+ setup for 11 common waste patterns and hands back exact copy-paste fixes.
+ Detection-only, never writes to user files. Supports `--period` (today,
+ week, 30days, month, all) and `--provider` (all, claude, codex, cursor).
+- **Setup health grade (A-F).** Urgency-weighted rollup of all findings, with
+ impact scored against observed waste so the most expensive issues rank
+ first. High findings penalise more, medium less, low least.
+- **Trend tracking.** Repeat runs classify each finding as new, improving,
+ or resolved against a 48-hour recent window, so fixed issues disappear
+ instead of lingering as noise.
+- **11 detectors:** files Claude re-reads across sessions, low Read:Edit
+ ratio, projects missing `.claudeignore`, uncapped `BASH_MAX_OUTPUT_LENGTH`,
+ unused MCP servers, ghost agents, ghost skills, ghost slash commands,
+ bloated `CLAUDE.md` files (with `@-import` expansion counted), cache
+ creation overhead, and junk directory reads.
+- **Copy-paste fixes.** Each finding comes with a ready-to-paste remedy: a
+ `CLAUDE.md` line, a `.claudeignore` template, an environment variable, or
+ a `mv` command to archive unused items.
+- **In-TUI optimize view.** Press `o` in the dashboard when the status bar
+ shows a finding count, `b` to return. Same engine as the standalone
+ command, scoped to the current period and provider.
+- **Per-project context budget column.** By Project panel now shows the
+ estimated per-session context overhead for each project (system prompt +
+ tools + `CLAUDE.md` + skills).
+- **34 filesystem-mocking tests.** Tmpdir fixtures with `os.homedir` mocked
+ via `vi.mock` cover the detector surface end to end. Total suite: 198
+ tests across 13 files.
+
+### Performance
+- **mtime pre-filter + parallel reads + 60s result cache** cut a cold scan
+ from 12-17s to 6-7s on a 10k-session history.
+
+## 0.6.1 - 2026-04-16
+
+### Added
+- **JSON output on `report`, `today`, `month`.** `--format json` writes the
+ full dashboard (overview, daily, projects, models, activities, tools, MCP
+ servers, shell commands, top sessions) to stdout. Contributed by @mallek.
+- **Project filters.** `--project ` and `--exclude ` on all
+ commands (`report`, `today`, `month`, `status`, `export`). Case-insensitive
+ substring match against project name and path. Both flags are repeatable.
+ Contributed by @mallek.
+- **claude-opus-4-7 model mapping and pricing.** Displays as `Opus 4.7` with
+ the same Opus pricing as 4.6 and a 6x fast multiplier. Contributed by @mallek.
+- **Unit tests for `filterProjectsByName`** covering include/exclude
+ semantics, case-insensitivity, path matching, and input immutability.
+
+### Fixed
+- **Top Sessions panel truncating the calls column.** Row width filled the
+ full panel width without leaving room for the border and padding, so Ink
+ truncated the last 4 characters -- landing exactly on the calls column and
+ producing rows like `$182.58 ...` with no value.
+- **SwiftBar custom plugin directory** now honoured when installing the
+ menubar widget. Reads the configured path from SwiftBar's defaults before
+ falling back to the standard location. Contributed by @Galeas.
+- **`status --format menubar` per-provider today totals** now respect
+ `--project`/`--exclude`. The main period blocks already did, the provider
+ breakdown loop was the one spot that bypassed the filter.
+
## 0.6.0 - 2026-04-16
### Added
diff --git a/CLAUDE.md b/CLAUDE.md
index f1cd611..1661a28 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -75,3 +75,10 @@ gh pr comment --body "Merged, thanks!"
- NEVER include personal names or usernames in commits
- Small, focused commits. One feature per commit
- Test locally before every commit
+
+### Public-facing language (commits, PRs, release notes, README)
+- Commits and release notes are public. Write like you'd publish them.
+- NEVER use words like "steal", "stealing", "copy", "rip off", "inspired by" in commit messages
+- Describe what the code does, not where ideas came from
+- If you must credit prior art, do it in code comments or docs, not commit messages
+- No snark, no filler, no self-deprecation. Treat each commit as a product statement
diff --git a/README.md b/README.md
index 800c933..03d1683 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,5 @@
-
+
CodeBurn
@@ -11,15 +11,15 @@
-
-
+
+
-
+
-By task type, tool, model, MCP server, and project. Supports **Claude Code**, **Codex** (OpenAI), **Cursor**, **OpenCode**, **Pi**, **[OMP](https://github.com/can1357/oh-my-pi)** (Oh My 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**, **[OMP](https://github.com/can1357/oh-my-pi)** (Oh My 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).
@@ -44,20 +44,44 @@ npx codeburn
## Usage
```bash
-codeburn # interactive dashboard (default: 7 days)
-codeburn today # today's usage
-codeburn month # this month's usage
-codeburn report -p 30days # rolling 30-day window
-codeburn report -p all # every recorded session
-codeburn report --refresh 60 # auto-refresh every 60 seconds
-codeburn status # compact one-liner (today + month)
+codeburn # interactive dashboard (default: 7 days)
+codeburn today # today's usage
+codeburn month # this month's usage
+codeburn report -p 30days # rolling 30-day window
+codeburn report -p all # every recorded session
+codeburn report --from 2026-04-01 --to 2026-04-10 # exact date range
+codeburn report --format json # full dashboard data as JSON
+codeburn report --refresh 60 # auto-refresh every 60 seconds
+codeburn status # compact one-liner (today + month)
codeburn status --format json
-codeburn export # CSV with today, 7 days, 30 days
-codeburn export -f json # JSON export
+codeburn export # CSV with today, 7 days, 30 days
+codeburn export -f json # JSON export
+codeburn optimize # find waste, get copy-paste fixes
+codeburn optimize -p week # scope the scan to last 7 days
```
Arrow keys switch between Today / 7 Days / 30 Days / Month / All Time. Press `q` to quit, `1` `2` `3` `4` `5` as shortcuts. The dashboard also shows average cost per session and the five most expensive sessions across all projects.
+### JSON output
+
+`report`, `today`, and `month` support `--format json` to output the full dashboard data as structured JSON to stdout:
+
+```bash
+codeburn report --format json # 7-day JSON report
+codeburn today --format json # today's data as JSON
+codeburn month --format json # this month as JSON
+codeburn report -p 30days --format json # 30-day window
+```
+
+The JSON includes all dashboard panels: overview (cost, calls, sessions, cache hit %), daily breakdown, projects (with `avgCostPerSession`), models with token counts, activities with one-shot rates, core tools, MCP servers, and shell commands. Pipe to `jq` for filtering:
+
+```bash
+codeburn report --format json | jq '.projects'
+codeburn today --format json | jq '.overview.cost'
+```
+
+For the lighter `status --format json` (today + month totals only) or file-based exports (`export -f json`), see above.
+
## Providers
CodeBurn auto-detects which AI coding tools you use. If multiple providers have session data on disk, press `p` in the dashboard to toggle between them.
@@ -77,6 +101,33 @@ codeburn export --provider claude # export Claude data only
The `--provider` flag works on all commands: `report`, `today`, `month`, `status`, `export`.
+### Project filtering
+
+Filter results by project name (case-insensitive substring match). Both flags are repeatable:
+
+```bash
+codeburn report --project myapp # show only projects matching "myapp"
+codeburn report --exclude myapp # show everything except "myapp"
+codeburn report --exclude myapp --exclude tests # exclude multiple projects
+codeburn month --project api --project web # include multiple projects
+codeburn export --project inventory # export only "inventory" project data
+```
+
+The `--project` and `--exclude` flags work on all commands and can be combined with `--provider`.
+
+### Date range filtering
+
+Beyond the preset periods, specify an exact window with `--from` and `--to` (`YYYY-MM-DD`, local time):
+
+```bash
+codeburn report --from 2026-04-01 --to 2026-04-10 # explicit window
+codeburn report --from 2026-04-01 # this date through today
+codeburn report --to 2026-04-10 # earliest data through this date
+codeburn report --from 2026-04-01 --to 2026-04-10 --format json
+```
+
+Either flag alone is valid. Inverted or malformed dates exit with a clear error. In the TUI, the custom range sets the initial load only -- pressing `1`-`5` switches back to predefined periods.
+
### Supported providers
| Provider | Data location | Status |
@@ -137,14 +188,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
@@ -189,6 +239,35 @@ CodeBurn surfaces the data, you read the story. A few patterns worth knowing:
These are starting points, not verdicts. A 60% cache hit on a single experimental session is fine. A persistent 60% cache hit across weeks of work is a config issue.
+## Optimize
+
+Once you know what to look for, `codeburn optimize` scans your sessions and your `~/.claude/` setup for the most common waste patterns and hands back exact, copy-paste fixes. It never writes to your files.
+
+
+
+
+
+```bash
+codeburn optimize # scan the last 30 days
+codeburn optimize -p today # today only
+codeburn optimize -p week # last 7 days
+codeburn optimize --provider claude # restrict to one provider
+```
+
+**What it detects**
+
+- Files Claude re-reads across sessions (same content, same context, over and over)
+- Low Read:Edit ratio (editing without reading leads to retries and wasted tokens)
+- Wasted bash output (uncapped `BASH_MAX_OUTPUT_LENGTH`, trailing noise)
+- Unused MCP servers still paying their tool-schema overhead every session
+- Ghost agents, skills, and slash commands defined in `~/.claude/` but never invoked
+- Bloated `CLAUDE.md` files (with `@-import` expansion counted)
+- Cache creation overhead and junk directory reads
+
+Each finding shows the estimated token and dollar savings plus a ready-to-paste fix: a `CLAUDE.md` line, an environment variable, or a `mv` command to archive unused items. Findings are ranked by urgency (impact weighted against observed waste) and rolled up into an A-F setup health grade. Repeat runs classify each finding as new, improving, or resolved against a 48-hour recent window.
+
+You can also open it inline from the dashboard: press `o` when a finding count appears in the status bar, `b` to return.
+
## How it reads data
**Claude Code** stores session transcripts as JSONL at `~/.claude/projects//.jsonl`. Each assistant entry contains model name, token usage (input, output, cache read, cache write), tool_use blocks, and timestamps.
@@ -221,7 +300,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-0.7.2.png b/assets/menubar-0.7.2.png
new file mode 100644
index 0000000..1ad264a
Binary files /dev/null and b/assets/menubar-0.7.2.png differ
diff --git a/assets/menubar.png b/assets/menubar.png
deleted file mode 100644
index 1f93d4d..0000000
Binary files a/assets/menubar.png and /dev/null differ
diff --git a/assets/optimize.jpg b/assets/optimize.jpg
new file mode 100644
index 0000000..9d8939d
Binary files /dev/null and b/assets/optimize.jpg 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..3a7f1d7
--- /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/getagentseal/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..db3e2da
--- /dev/null
+++ b/mac/Sources/CodeBurnMenubar/AppStore.swift
@@ -0,0 +1,311 @@
+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"
+ case opencode = "OpenCode"
+ case pi = "Pi"
+
+ 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"
+ case .opencode: "opencode"
+ case .pi: "pi"
+ }
+ }
+}
+
+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..87d2341
--- /dev/null
+++ b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift
@@ -0,0 +1,185 @@
+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 }
+ // Always keep the (today, all) payload warm. The menubar title and the
+ // agent tab strip both read from it, so it has to refresh every cycle
+ // regardless of whether the user is currently viewing Today or a
+ // different period / provider.
+ await self.store.refreshQuietly(period: .today)
+ // Refresh the currently-viewed payload. 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..9d0b3e7
--- /dev/null
+++ b/mac/Sources/CodeBurnMenubar/Security/TerminalLauncher.swift
@@ -0,0 +1,65 @@
+import AppKit
+import Foundation
+
+/// Runs commands in the user's Terminal. Every string that reaches AppleScript `do script`
+/// must be whitespace-joined argv where each token passes `CodeburnCLI.isSafe` (regex allowlist
+/// that excludes shell metacharacters), OR a hardcoded literal defined here. The private
+/// `runInTerminal` re-validates any non-literal input defensively so a future caller can't
+/// bypass the invariant.
+/// 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:)) {
+ runInTerminal(command: command, preValidated: true)
+ return
+ }
+
+ let headless = CodeburnCLI.makeProcess(subcommand: subcommand)
+ try? headless.run()
+ }
+
+ /// Launches `claude login` in Terminal.app so the user can complete the OAuth flow
+ /// without leaving CodeBurn. The command is a hardcoded literal -- no user input is
+ /// interpolated, so there's no injection surface.
+ static func openClaudeLogin() -> Bool {
+ guard terminalPaths.contains(where: FileManager.default.fileExists(atPath:)) else {
+ NSLog("CodeBurn: Terminal.app not present; user must run `claude login` manually")
+ return false
+ }
+ runInTerminal(command: "claude login", preValidated: true)
+ return true
+ }
+
+ private static func runInTerminal(command: String, preValidated: Bool) {
+ if !preValidated {
+ let tokens = command.split(separator: " ", omittingEmptySubsequences: true).map(String.init)
+ guard tokens.allSatisfy(CodeburnCLI.isSafe) else {
+ NSLog("CodeBurn: refusing to run unvalidated command in Terminal")
+ return
+ }
+ }
+ 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()
+ }
+}
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..e4522dd
--- /dev/null
+++ b/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift
@@ -0,0 +1,100 @@
+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] {
+ // Show a tab for every provider detected on this machine. The CLI decides what
+ // to include in the providers map based on session dirs / credential files it
+ // finds, so zero-cost-today is still "installed" and the user expects to see
+ // it. Only providers that aren't installed at all are absent from the map.
+ let detectedKeys = Set(
+ allProvidersToday.current.providers.keys.map { $0.lowercased() }
+ )
+ return ProviderFilter.allCases.filter { filter in
+ if filter == .all { return true }
+ return detectedKeys.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)
+ case .opencode: return Color(red: 0x5B/255.0, green: 0x83/255.0, blue: 0x5B/255.0)
+ case .pi: return Color(red: 0xB2/255.0, green: 0x6B/255.0, blue: 0x3D/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..8b64e2b
--- /dev/null
+++ b/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift
@@ -0,0 +1,1253 @@
+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, value: metric(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
+ /// Value to display in the tooltip header. Matches the metric the trend chart
+ /// is currently using (tokens when the .all-providers view has token data,
+ /// cost when provider-filtered views force a $ fallback). Passing this in keeps
+ /// the tooltip in sync with the chart instead of always reading bar.tokens,
+ /// which is zero for provider-filtered days.
+ let value: Double
+ 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(value))")
+ .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)
+ }
+ }
+ }
+ }
+ }
+ .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
+ @State private var showManualFallback = false
+
+ 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)
+ if showManualFallback {
+ Text("Terminal.app isn't available. Open your terminal and run `claude login`, then click Retry.")
+ .font(.system(size: 10.5))
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ .frame(maxWidth: 280)
+ } else {
+ Text("Click Connect to sign in with Claude, then return here.")
+ .font(.system(size: 10.5))
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ .frame(maxWidth: 260)
+ }
+ HStack(spacing: 8) {
+ Button("Connect Claude") {
+ if !TerminalLauncher.openClaudeLogin() { showManualFallback = true }
+ }
+ .controlSize(.small)
+ .buttonStyle(.borderedProminent)
+ .tint(Theme.brandAccent)
+ 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?
+ @State private var showManualFallback = false
+
+ 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 showManualFallback {
+ Text("Terminal.app isn't available. Open your terminal and run `claude login`, then click Retry.")
+ .font(.system(size: 10.5))
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ .frame(maxWidth: 280)
+ } else if let error {
+ Text(error)
+ .font(.system(size: 10))
+ .foregroundStyle(.tertiary)
+ .multilineTextAlignment(.center)
+ .frame(maxWidth: 280)
+ .lineLimit(3)
+ }
+ HStack(spacing: 8) {
+ Button("Reconnect Claude") {
+ if !TerminalLauncher.openClaudeLogin() { showManualFallback = true }
+ }
+ .controlSize(.small)
+ .buttonStyle(.borderedProminent)
+ .tint(Theme.brandAccent)
+ 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..a295067
--- /dev/null
+++ b/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift
@@ -0,0 +1,410 @@
+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()
+
+ if showAgentTabs {
+ 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
+ }
+
+ /// Show the tab row whenever the CLI detected at least one AI coding tool installed
+ /// on this machine. Hidden only when nothing is detected, which means there's
+ /// nothing to filter by anyway.
+ private var showAgentTabs: Bool {
+ let payload = store.todayPayload ?? store.payload
+ return !payload.current.providers.isEmpty
+ }
+
+}
+
+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/getagentseal/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 282a31d..8911286 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "codeburn",
- "version": "0.6.0",
+ "version": "0.7.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "codeburn",
- "version": "0.6.0",
+ "version": "0.7.3",
"license": "MIT",
"dependencies": {
"chalk": "^5.4.1",
@@ -18,7 +18,7 @@
"codeburn": "dist/cli.js"
},
"devDependencies": {
- "@types/better-sqlite3": "^7.6.0",
+ "@types/node": "^22.19.17",
"@types/react": "^19.2.14",
"tsup": "^8.4.0",
"tsx": "^4.19.0",
@@ -26,10 +26,7 @@
"vitest": "^3.1.0"
},
"engines": {
- "node": ">=20"
- },
- "optionalDependencies": {
- "better-sqlite3": "^12.0.0"
+ "node": ">=22"
}
},
"node_modules/@alcalzone/ansi-tokenize": {
@@ -876,16 +873,6 @@
"win32"
]
},
- "node_modules/@types/better-sqlite3": {
- "version": "7.6.13",
- "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
- "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@types/node": "*"
- }
- },
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
@@ -912,13 +899,13 @@
"license": "MIT"
},
"node_modules/@types/node": {
- "version": "25.6.0",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz",
- "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
+ "version": "22.19.17",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz",
+ "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==",
"dev": true,
"license": "MIT",
"dependencies": {
- "undici-types": "~7.19.0"
+ "undici-types": "~6.21.0"
}
},
"node_modules/@types/react": {
@@ -1127,89 +1114,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/base64-js": {
- "version": "1.5.1",
- "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
- "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/feross"
- },
- {
- "type": "patreon",
- "url": "https://www.patreon.com/feross"
- },
- {
- "type": "consulting",
- "url": "https://feross.org/support"
- }
- ],
- "license": "MIT",
- "optional": true
- },
- "node_modules/better-sqlite3": {
- "version": "12.9.0",
- "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.9.0.tgz",
- "integrity": "sha512-wqUv4Gm3toFpHDQmaKD4QhZm3g1DjUBI0yzS4UBl6lElUmXFYdTQmmEDpAFa5o8FiFiymURypEnfVHzILKaxqQ==",
- "hasInstallScript": true,
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "bindings": "^1.5.0",
- "prebuild-install": "^7.1.1"
- },
- "engines": {
- "node": "20.x || 22.x || 23.x || 24.x || 25.x"
- }
- },
- "node_modules/bindings": {
- "version": "1.5.0",
- "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
- "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "file-uri-to-path": "1.0.0"
- }
- },
- "node_modules/bl": {
- "version": "4.1.0",
- "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
- "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "buffer": "^5.5.0",
- "inherits": "^2.0.4",
- "readable-stream": "^3.4.0"
- }
- },
- "node_modules/buffer": {
- "version": "5.7.1",
- "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
- "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/feross"
- },
- {
- "type": "patreon",
- "url": "https://www.patreon.com/feross"
- },
- {
- "type": "consulting",
- "url": "https://feross.org/support"
- }
- ],
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "base64-js": "^1.3.1",
- "ieee754": "^1.1.13"
- }
- },
"node_modules/bundle-require": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz",
@@ -1291,13 +1195,6 @@
"url": "https://paulmillr.com/funding/"
}
},
- "node_modules/chownr": {
- "version": "1.1.4",
- "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
- "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
- "license": "ISC",
- "optional": true
- },
"node_modules/cli-boxes": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-4.0.1.tgz",
@@ -1413,22 +1310,6 @@
}
}
},
- "node_modules/decompress-response": {
- "version": "6.0.0",
- "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
- "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "mimic-response": "^3.1.0"
- },
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
"node_modules/deep-eql": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
@@ -1439,36 +1320,6 @@
"node": ">=6"
}
},
- "node_modules/deep-extend": {
- "version": "0.6.0",
- "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
- "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
- "license": "MIT",
- "optional": true,
- "engines": {
- "node": ">=4.0.0"
- }
- },
- "node_modules/detect-libc": {
- "version": "2.1.2",
- "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
- "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
- "license": "Apache-2.0",
- "optional": true,
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/end-of-stream": {
- "version": "1.4.5",
- "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
- "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "once": "^1.4.0"
- }
- },
"node_modules/environment": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz",
@@ -1559,16 +1410,6 @@
"@types/estree": "^1.0.0"
}
},
- "node_modules/expand-template": {
- "version": "2.0.3",
- "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
- "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
- "license": "(MIT OR WTFPL)",
- "optional": true,
- "engines": {
- "node": ">=6"
- }
- },
"node_modules/expect-type": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
@@ -1597,13 +1438,6 @@
}
}
},
- "node_modules/file-uri-to-path": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
- "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
- "license": "MIT",
- "optional": true
- },
"node_modules/fix-dts-default-cjs-exports": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/fix-dts-default-cjs-exports/-/fix-dts-default-cjs-exports-1.0.1.tgz",
@@ -1616,13 +1450,6 @@
"rollup": "^4.34.8"
}
},
- "node_modules/fs-constants": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
- "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
- "license": "MIT",
- "optional": true
- },
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -1663,34 +1490,6 @@
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
- "node_modules/github-from-package": {
- "version": "0.0.0",
- "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
- "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
- "license": "MIT",
- "optional": true
- },
- "node_modules/ieee754": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
- "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/feross"
- },
- {
- "type": "patreon",
- "url": "https://www.patreon.com/feross"
- },
- {
- "type": "consulting",
- "url": "https://feross.org/support"
- }
- ],
- "license": "BSD-3-Clause",
- "optional": true
- },
"node_modules/indent-string": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz",
@@ -1703,20 +1502,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/inherits": {
- "version": "2.0.4",
- "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
- "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
- "license": "ISC",
- "optional": true
- },
- "node_modules/ini": {
- "version": "1.3.8",
- "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
- "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
- "license": "ISC",
- "optional": true
- },
"node_modules/ink": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/ink/-/ink-7.0.0.tgz",
@@ -1869,36 +1654,6 @@
"node": ">=6"
}
},
- "node_modules/mimic-response": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
- "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
- "license": "MIT",
- "optional": true,
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/minimist": {
- "version": "1.2.8",
- "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
- "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
- "license": "MIT",
- "optional": true,
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/mkdirp-classic": {
- "version": "0.5.3",
- "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
- "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
- "license": "MIT",
- "optional": true
- },
"node_modules/mlly": {
"version": "1.8.2",
"resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz",
@@ -1950,26 +1705,6 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
- "node_modules/napi-build-utils": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
- "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
- "license": "MIT",
- "optional": true
- },
- "node_modules/node-abi": {
- "version": "3.89.0",
- "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz",
- "integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==",
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "semver": "^7.3.5"
- },
- "engines": {
- "node": ">=10"
- }
- },
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -1980,16 +1715,6 @@
"node": ">=0.10.0"
}
},
- "node_modules/once": {
- "version": "1.4.0",
- "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
- "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
- "license": "ISC",
- "optional": true,
- "dependencies": {
- "wrappy": "1"
- }
- },
"node_modules/onetime": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz",
@@ -2145,61 +1870,6 @@
}
}
},
- "node_modules/prebuild-install": {
- "version": "7.1.3",
- "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
- "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
- "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "detect-libc": "^2.0.0",
- "expand-template": "^2.0.3",
- "github-from-package": "0.0.0",
- "minimist": "^1.2.3",
- "mkdirp-classic": "^0.5.3",
- "napi-build-utils": "^2.0.0",
- "node-abi": "^3.3.0",
- "pump": "^3.0.0",
- "rc": "^1.2.7",
- "simple-get": "^4.0.0",
- "tar-fs": "^2.0.0",
- "tunnel-agent": "^0.6.0"
- },
- "bin": {
- "prebuild-install": "bin.js"
- },
- "engines": {
- "node": ">=10"
- }
- },
- "node_modules/pump": {
- "version": "3.0.4",
- "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
- "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "end-of-stream": "^1.1.0",
- "once": "^1.3.1"
- }
- },
- "node_modules/rc": {
- "version": "1.2.8",
- "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
- "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
- "license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
- "optional": true,
- "dependencies": {
- "deep-extend": "^0.6.0",
- "ini": "~1.3.0",
- "minimist": "^1.2.0",
- "strip-json-comments": "~2.0.1"
- },
- "bin": {
- "rc": "cli.js"
- }
- },
"node_modules/react": {
"version": "19.2.5",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz",
@@ -2224,21 +1894,6 @@
"react": "^19.2.0"
}
},
- "node_modules/readable-stream": {
- "version": "3.6.2",
- "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
- "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "inherits": "^2.0.3",
- "string_decoder": "^1.1.1",
- "util-deprecate": "^1.0.1"
- },
- "engines": {
- "node": ">= 6"
- }
- },
"node_modules/readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
@@ -2334,46 +1989,12 @@
"fsevents": "~2.3.2"
}
},
- "node_modules/safe-buffer": {
- "version": "5.2.1",
- "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
- "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/feross"
- },
- {
- "type": "patreon",
- "url": "https://www.patreon.com/feross"
- },
- {
- "type": "consulting",
- "url": "https://feross.org/support"
- }
- ],
- "license": "MIT",
- "optional": true
- },
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT"
},
- "node_modules/semver": {
- "version": "7.7.4",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
- "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
- "license": "ISC",
- "optional": true,
- "bin": {
- "semver": "bin/semver.js"
- },
- "engines": {
- "node": ">=10"
- }
- },
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
@@ -2387,53 +2008,6 @@
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
"license": "ISC"
},
- "node_modules/simple-concat": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
- "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/feross"
- },
- {
- "type": "patreon",
- "url": "https://www.patreon.com/feross"
- },
- {
- "type": "consulting",
- "url": "https://feross.org/support"
- }
- ],
- "license": "MIT",
- "optional": true
- },
- "node_modules/simple-get": {
- "version": "4.0.1",
- "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
- "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/feross"
- },
- {
- "type": "patreon",
- "url": "https://www.patreon.com/feross"
- },
- {
- "type": "consulting",
- "url": "https://feross.org/support"
- }
- ],
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "decompress-response": "^6.0.0",
- "once": "^1.3.1",
- "simple-concat": "^1.0.0"
- }
- },
"node_modules/slice-ansi": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-9.0.0.tgz",
@@ -2496,16 +2070,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/string_decoder": {
- "version": "1.3.0",
- "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
- "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "safe-buffer": "~5.2.0"
- }
- },
"node_modules/string-width": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.0.tgz",
@@ -2537,16 +2101,6 @@
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
- "node_modules/strip-json-comments": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
- "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
- "license": "MIT",
- "optional": true,
- "engines": {
- "node": ">=0.10.0"
- }
- },
"node_modules/strip-literal": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz",
@@ -2605,36 +2159,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/tar-fs": {
- "version": "2.1.4",
- "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
- "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "chownr": "^1.1.1",
- "mkdirp-classic": "^0.5.2",
- "pump": "^3.0.0",
- "tar-stream": "^2.1.4"
- }
- },
- "node_modules/tar-stream": {
- "version": "2.2.0",
- "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
- "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "bl": "^4.0.3",
- "end-of-stream": "^1.4.1",
- "fs-constants": "^1.0.0",
- "inherits": "^2.0.3",
- "readable-stream": "^3.1.1"
- },
- "engines": {
- "node": ">=6"
- }
- },
"node_modules/terminal-size": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/terminal-size/-/terminal-size-4.0.1.tgz",
@@ -2821,19 +2345,6 @@
"fsevents": "~2.3.3"
}
},
- "node_modules/tunnel-agent": {
- "version": "0.6.0",
- "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
- "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
- "license": "Apache-2.0",
- "optional": true,
- "dependencies": {
- "safe-buffer": "^5.0.1"
- },
- "engines": {
- "node": "*"
- }
- },
"node_modules/type-fest": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.5.0.tgz",
@@ -2871,19 +2382,12 @@
"license": "MIT"
},
"node_modules/undici-types": {
- "version": "7.19.2",
- "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
- "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
+ "version": "6.21.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
- "node_modules/util-deprecate": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
- "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
- "license": "MIT",
- "optional": true
- },
"node_modules/vite": {
"version": "7.3.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
@@ -3104,13 +2608,6 @@
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
- "node_modules/wrappy": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
- "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
- "license": "ISC",
- "optional": true
- },
"node_modules/ws": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
diff --git a/package.json b/package.json
index 2f91219..f7aa295 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "codeburn",
- "version": "0.6.0",
+ "version": "0.7.3",
"description": "See where your AI coding tokens go - by task, tool, model, and project",
"type": "module",
"main": "./dist/cli.js",
@@ -29,21 +29,26 @@
"developer-tools"
],
"engines": {
- "node": ">=20"
+ "node": ">=22"
},
"author": "AgentSeal ",
"license": "MIT",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/getagentseal/codeburn.git"
+ },
+ "bugs": {
+ "url": "https://github.com/getagentseal/codeburn/issues"
+ },
+ "homepage": "https://github.com/getagentseal/codeburn#readme",
"dependencies": {
"chalk": "^5.4.1",
"commander": "^13.1.0",
"ink": "^7.0.0",
"react": "^19.2.5"
},
- "optionalDependencies": {
- "better-sqlite3": "^12.0.0"
- },
"devDependencies": {
- "@types/better-sqlite3": "^7.6.0",
+ "@types/node": "^22.19.17",
"@types/react": "^19.2.14",
"tsup": "^8.4.0",
"tsx": "^4.19.0",
diff --git a/src/cli-date.ts b/src/cli-date.ts
new file mode 100644
index 0000000..66831b9
--- /dev/null
+++ b/src/cli-date.ts
@@ -0,0 +1,39 @@
+import type { DateRange } from './types.js'
+
+const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/
+
+const END_OF_DAY_HOURS = 23
+const END_OF_DAY_MINUTES = 59
+const END_OF_DAY_SECONDS = 59
+const END_OF_DAY_MS = 999
+
+function parseLocalDate(s: string): Date {
+ if (!ISO_DATE_RE.test(s)) {
+ throw new Error(`Invalid date format "${s}": expected YYYY-MM-DD`)
+ }
+ const [y, m, d] = s.split('-').map(Number) as [number, number, number]
+ return new Date(y, m - 1, d)
+}
+
+export function parseDateRangeFlags(from: string | undefined, to: string | undefined): DateRange | null {
+ if (from === undefined && to === undefined) return null
+
+ const now = new Date()
+ const start = from !== undefined ? parseLocalDate(from) : new Date(0)
+
+ const endDate = to !== undefined ? parseLocalDate(to) : new Date(now.getFullYear(), now.getMonth(), now.getDate())
+ const end = new Date(
+ endDate.getFullYear(),
+ endDate.getMonth(),
+ endDate.getDate(),
+ END_OF_DAY_HOURS,
+ END_OF_DAY_MINUTES,
+ END_OF_DAY_SECONDS,
+ END_OF_DAY_MS,
+ )
+
+ if (start > end) {
+ throw new Error(`--from must not be after --to (got ${from} > ${to})`)
+ }
+ return { start, end }
+}
diff --git a/src/cli.ts b/src/cli.ts
index ed62039..0fb18e7 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -1,11 +1,18 @@
import { Command } from 'commander'
+import { installMenubarApp } from './menubar-installer.js'
import { exportCsv, exportJson, type PeriodExport } from './export.js'
import { loadPricing, setModelAliases } from './models.js'
-import { parseAllSessions } from './parser.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 { parseDateRangeFlags } from './cli-date.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'
@@ -14,6 +21,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)
@@ -41,7 +55,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)
@@ -50,7 +68,9 @@ function getDateRange(period: string): { range: DateRange; label: string } {
}
}
-function toPeriod(s: string): 'today' | 'week' | '30days' | 'month' | 'all' {
+type Period = 'today' | 'week' | '30days' | 'month' | 'all'
+
+function toPeriod(s: string): Period {
if (s === 'today') return 'today'
if (s === 'month') return 'month'
if (s === '30days') return '30days'
@@ -58,25 +78,204 @@ function toPeriod(s: string): 'today' | 'week' | '30days' | 'month' | 'all' {
return 'week'
}
+function collect(val: string, acc: string[]): string[] {
+ acc.push(val)
+ return acc
+}
+
+async function runJsonReport(period: Period, provider: string, project: string[], exclude: string[]): Promise {
+ await loadPricing()
+ const { range, label } = getDateRange(period)
+ const projects = filterProjectsByName(await parseAllSessions(range, provider), project, exclude)
+ console.log(JSON.stringify(buildJsonReport(projects, label, period), null, 2))
+}
+
const program = new Command()
.name('codeburn')
.description('See where your AI coding tokens go - by task, tool, model, and project')
.version(version)
+ .option('--verbose', 'print warnings to stderr on read failures and skipped files')
-program.hook('preAction', async () => {
+program.hook('preAction', async (thisCommand) => {
const config = await readConfig()
setModelAliases(config.modelAliases ?? {})
+ if (thisCommand.opts<{ verbose?: boolean }>().verbose) {
+ process.env['CODEBURN_VERBOSE'] = '1'
+ }
await loadCurrency()
})
+function buildJsonReport(projects: ProjectSummary[], period: string, periodKey: string) {
+ const sessions = projects.flatMap(p => p.sessions)
+ const { code } = getCurrency()
+
+ const totalCostUSD = projects.reduce((s, p) => s + p.totalCostUSD, 0)
+ const totalCalls = projects.reduce((s, p) => s + p.totalApiCalls, 0)
+ const totalSessions = projects.reduce((s, p) => s + p.sessions.length, 0)
+ const totalInput = sessions.reduce((s, sess) => s + sess.totalInputTokens, 0)
+ 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)
+ // 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) {
+ for (const turn of sess.turns) {
+ if (!turn.timestamp) { continue }
+ const day = turn.timestamp.slice(0, 10)
+ if (!dailyMap[day]) { dailyMap[day] = { cost: 0, calls: 0 } }
+ for (const call of turn.assistantCalls) {
+ dailyMap[day].cost += call.costUSD
+ dailyMap[day].calls += 1
+ }
+ }
+ }
+ const daily = Object.entries(dailyMap).sort().map(([date, d]) => ({
+ date,
+ cost: convertCost(d.cost),
+ calls: d.calls,
+ }))
+
+ const projectList = projects.map(p => ({
+ name: p.project,
+ path: p.projectPath,
+ cost: convertCost(p.totalCostUSD),
+ avgCostPerSession: p.sessions.length > 0
+ ? convertCost(p.totalCostUSD / p.sessions.length)
+ : null,
+ calls: p.totalApiCalls,
+ sessions: p.sessions.length,
+ }))
+
+ const modelMap: Record = {}
+ for (const sess of sessions) {
+ for (const [model, d] of Object.entries(sess.modelBreakdown)) {
+ if (!modelMap[model]) { modelMap[model] = { calls: 0, cost: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 } }
+ modelMap[model].calls += d.calls
+ modelMap[model].cost += d.costUSD
+ modelMap[model].inputTokens += d.tokens.inputTokens
+ modelMap[model].outputTokens += d.tokens.outputTokens
+ modelMap[model].cacheReadTokens += d.tokens.cacheReadInputTokens
+ modelMap[model].cacheWriteTokens += d.tokens.cacheCreationInputTokens
+ }
+ }
+ const models = Object.entries(modelMap)
+ .sort(([, a], [, b]) => b.cost - a.cost)
+ .map(([name, { cost, ...rest }]) => ({ name, ...rest, cost: convertCost(cost) }))
+
+ const catMap: Record = {}
+ for (const sess of sessions) {
+ for (const [cat, d] of Object.entries(sess.categoryBreakdown)) {
+ if (!catMap[cat]) { catMap[cat] = { turns: 0, cost: 0, editTurns: 0, oneShotTurns: 0 } }
+ catMap[cat].turns += d.turns
+ catMap[cat].cost += d.costUSD
+ catMap[cat].editTurns += d.editTurns
+ catMap[cat].oneShotTurns += d.oneShotTurns
+ }
+ }
+ const activities = Object.entries(catMap)
+ .sort(([, a], [, b]) => b.cost - a.cost)
+ .map(([cat, d]) => ({
+ category: CATEGORY_LABELS[cat as TaskCategory] ?? cat,
+ cost: convertCost(d.cost),
+ turns: d.turns,
+ editTurns: d.editTurns,
+ oneShotTurns: d.oneShotTurns,
+ oneShotRate: d.editTurns > 0 ? Math.round((d.oneShotTurns / d.editTurns) * 1000) / 10 : null,
+ }))
+
+ const toolMap: Record = {}
+ const mcpMap: Record = {}
+ const bashMap: Record = {}
+ for (const sess of sessions) {
+ for (const [tool, d] of Object.entries(sess.toolBreakdown)) {
+ toolMap[tool] = (toolMap[tool] ?? 0) + d.calls
+ }
+ for (const [server, d] of Object.entries(sess.mcpBreakdown)) {
+ mcpMap[server] = (mcpMap[server] ?? 0) + d.calls
+ }
+ for (const [cmd, d] of Object.entries(sess.bashBreakdown)) {
+ bashMap[cmd] = (bashMap[cmd] ?? 0) + d.calls
+ }
+ }
+
+ const sortedMap = (m: Record) =>
+ Object.entries(m).sort(([, a], [, b]) => b - a).map(([name, calls]) => ({ name, calls }))
+
+ const topSessions = projects
+ .flatMap(p => p.sessions.map(s => ({ project: p.project, sessionId: s.sessionId, date: s.firstTimestamp?.slice(0, 10) ?? null, cost: convertCost(s.totalCostUSD), calls: s.apiCalls })))
+ .sort((a, b) => b.cost - a.cost)
+ .slice(0, 5)
+
+ return {
+ generated: new Date().toISOString(),
+ currency: code,
+ period,
+ periodKey,
+ overview: {
+ cost: convertCost(totalCostUSD),
+ calls: totalCalls,
+ sessions: totalSessions,
+ cacheHitPercent,
+ tokens: {
+ input: totalInput,
+ output: totalOutput,
+ cacheRead: totalCacheRead,
+ cacheWrite: totalCacheWrite,
+ },
+ },
+ daily,
+ projects: projectList,
+ models,
+ activities,
+ tools: sortedMap(toolMap),
+ mcpServers: sortedMap(mcpMap),
+ shellCommands: sortedMap(bashMap),
+ topSessions,
+ }
+}
+
program
.command('report', { isDefault: true })
.description('Interactive usage dashboard')
.option('-p, --period ', 'Starting period: today, week, 30days, month, all', 'week')
+ .option('--from ', 'Start date (YYYY-MM-DD). Overrides --period when set')
+ .option('--to ', 'End date (YYYY-MM-DD). Overrides --period when set')
.option('--provider ', 'Filter by provider: all, claude, codex, cursor', 'all')
+ .option('--format ', 'Output format: tui, json', 'tui')
+ .option('--project ', 'Show only projects matching name (repeatable)', collect, [])
+ .option('--exclude ', 'Exclude projects matching name (repeatable)', collect, [])
.option('--refresh ', 'Auto-refresh interval in seconds', parseInt)
.action(async (opts) => {
- await renderDashboard(toPeriod(opts.period), opts.provider, opts.refresh)
+ let customRange: DateRange | null = null
+ try {
+ customRange = parseDateRangeFlags(opts.from, opts.to)
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err)
+ console.error(`\n Error: ${message}\n`)
+ process.exit(1)
+ }
+
+ const period = toPeriod(opts.period)
+ if (opts.format === 'json') {
+ await loadPricing()
+ if (customRange) {
+ const label = `${opts.from ?? 'all'} to ${opts.to ?? 'today'}`
+ const projects = filterProjectsByName(
+ await parseAllSessions(customRange, opts.provider),
+ opts.project,
+ opts.exclude,
+ )
+ console.log(JSON.stringify(buildJsonReport(projects, label, 'custom'), null, 2))
+ } else {
+ await runJsonReport(period, opts.provider, opts.project, opts.exclude)
+ }
+ return
+ }
+ await renderDashboard(period, opts.provider, opts.refresh, opts.project, opts.exclude, customRange)
})
function buildPeriodData(label: string, projects: ProjectSummary[]): PeriodData {
@@ -108,6 +307,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)
@@ -121,30 +321,154 @@ 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
- if (opts.format === 'menubar') {
- const todayRange = getDateRange('today').range
- const todayData = buildPeriodData('Today', await parseAllSessions(todayRange, pf))
- const weekData = buildPeriodData('7 Days', await parseAllSessions(getDateRange('week').range, pf))
- const thirtyDayData = buildPeriodData('30 Days', await parseAllSessions(getDateRange('30days').range, pf))
- const monthData = buildPeriodData('Month', await parseAllSessions(getDateRange('month').range, pf))
- const todayProviders: ProviderCost[] = []
- for (const p of await getAllProviders()) {
- const data = await parseAllSessions(todayRange, p.name)
- const cost = data.reduce((s, proj) => s + proj.totalCostUSD, 0)
- if (cost > 0) todayProviders.push({ name: p.displayName, cost })
+ const fp = (p: ProjectSummary[]) => filterProjectsByName(p, opts.project, opts.exclude)
+ 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
}
if (opts.format === 'json') {
- const todayData = buildPeriodData('today', await parseAllSessions(getDateRange('today').range, pf))
- const monthData = buildPeriodData('month', await parseAllSessions(getDateRange('month').range, pf))
+ const todayData = buildPeriodData('today', fp(await parseAllSessions(getDateRange('today').range, pf)))
+ const monthData = buildPeriodData('month', fp(await parseAllSessions(getDateRange('month').range, pf)))
const { code, rate } = getCurrency()
console.log(JSON.stringify({
currency: code,
@@ -154,7 +478,7 @@ program
return
}
- const monthProjects = await parseAllSessions(getDateRange('month').range, pf)
+ const monthProjects = fp(await parseAllSessions(getDateRange('month').range, pf))
console.log(renderStatusBar(monthProjects))
})
@@ -162,18 +486,32 @@ program
.command('today')
.description('Today\'s usage dashboard')
.option('--provider ', 'Filter by provider: all, claude, codex, cursor', 'all')
+ .option('--format ', 'Output format: tui, json', 'tui')
+ .option('--project ', 'Show only projects matching name (repeatable)', collect, [])
+ .option('--exclude ', 'Exclude projects matching name (repeatable)', collect, [])
.option('--refresh ', 'Auto-refresh interval in seconds', parseInt)
.action(async (opts) => {
- await renderDashboard('today', opts.provider, opts.refresh)
+ if (opts.format === 'json') {
+ await runJsonReport('today', opts.provider, opts.project, opts.exclude)
+ return
+ }
+ await renderDashboard('today', opts.provider, opts.refresh, opts.project, opts.exclude)
})
program
.command('month')
.description('This month\'s usage dashboard')
.option('--provider ', 'Filter by provider: all, claude, codex, cursor', 'all')
+ .option('--format ', 'Output format: tui, json', 'tui')
+ .option('--project ', 'Show only projects matching name (repeatable)', collect, [])
+ .option('--exclude ', 'Exclude projects matching name (repeatable)', collect, [])
.option('--refresh ', 'Auto-refresh interval in seconds', parseInt)
.action(async (opts) => {
- await renderDashboard('month', opts.provider, opts.refresh)
+ if (opts.format === 'json') {
+ await runJsonReport('month', opts.provider, opts.project, opts.exclude)
+ return
+ }
+ await renderDashboard('month', opts.provider, opts.refresh, opts.project, opts.exclude)
})
program
@@ -182,13 +520,16 @@ program
.option('-f, --format ', 'Export format: csv, json', 'csv')
.option('-o, --output ', 'Output file path')
.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, [])
.action(async (opts) => {
await loadPricing()
const pf = opts.provider
+ const fp = (p: ProjectSummary[]) => filterProjectsByName(p, opts.project, opts.exclude)
const periods: PeriodExport[] = [
- { label: 'Today', projects: await parseAllSessions(getDateRange('today').range, pf) },
- { label: '7 Days', projects: await parseAllSessions(getDateRange('week').range, pf) },
- { label: '30 Days', projects: await parseAllSessions(getDateRange('30days').range, pf) },
+ { label: 'Today', projects: fp(await parseAllSessions(getDateRange('today').range, pf)) },
+ { label: '7 Days', projects: fp(await parseAllSessions(getDateRange('week').range, pf)) },
+ { label: '30 Days', projects: fp(await parseAllSessions(getDateRange('30days').range, pf)) },
]
if (periods.every(p => p.projects.length === 0)) {
@@ -200,29 +541,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
@@ -326,4 +675,16 @@ program
console.log(` Config: ${getConfigFilePath()}\n`)
})
-program.parse()
\ No newline at end of file
+program
+ .command('optimize')
+ .description('Find token waste and get exact fixes')
+ .option('-p, --period ', 'Analysis period: today, week, 30days, month, all', '30days')
+ .option('--provider ', 'Filter by provider: all, claude, codex, cursor', 'all')
+ .action(async (opts) => {
+ await loadPricing()
+ const { range, label } = getDateRange(opts.period)
+ const projects = await parseAllSessions(range, opts.provider)
+ await runOptimize(projects, label, range)
+ })
+
+program.parse()
diff --git a/src/context-budget.ts b/src/context-budget.ts
new file mode 100644
index 0000000..b5c72d6
--- /dev/null
+++ b/src/context-budget.ts
@@ -0,0 +1,149 @@
+import { readdir } from 'fs/promises'
+import { existsSync } from 'fs'
+import { join } from 'path'
+import { homedir } from 'os'
+
+import { readSessionFile } from './fs-utils.js'
+
+const CHARS_PER_TOKEN = 4
+const SYSTEM_BASE_TOKENS = 10400
+const TOOL_TOKENS_OVERHEAD = 400
+const SKILL_FRONTMATTER_TOKENS = 80
+
+export type ContextBudget = {
+ systemBase: number
+ mcpTools: { count: number; tokens: number }
+ skills: { count: number; tokens: number }
+ memory: { count: number; tokens: number; files: Array<{ name: string; tokens: number }> }
+ total: number
+ modelContext: number
+}
+
+function estimateTokens(text: string): number {
+ return Math.ceil(text.length / CHARS_PER_TOKEN)
+}
+
+async function readConfigFile(path: string): Promise | null> {
+ if (!existsSync(path)) return null
+ const raw = await readSessionFile(path)
+ if (raw === null) return null
+ try { return JSON.parse(raw) } catch { return null }
+}
+
+async function countMcpTools(projectPath?: string): Promise {
+ const home = homedir()
+ const configPaths = [
+ join(home, '.claude', 'settings.json'),
+ join(home, '.claude', 'settings.local.json'),
+ ]
+ if (projectPath) {
+ configPaths.push(join(projectPath, '.mcp.json'))
+ configPaths.push(join(projectPath, '.claude', 'settings.json'))
+ configPaths.push(join(projectPath, '.claude', 'settings.local.json'))
+ }
+
+ const servers = new Set()
+ let toolCount = 0
+
+ for (const p of configPaths) {
+ const config = await readConfigFile(p)
+ if (!config) continue
+ const mcpServers = (config.mcpServers ?? {}) as Record
+ for (const name of Object.keys(mcpServers)) {
+ if (servers.has(name)) continue
+ servers.add(name)
+ toolCount += 5
+ }
+ }
+
+ return toolCount
+}
+
+async function countSkills(projectPath?: string): Promise {
+ const dirs = [join(homedir(), '.claude', 'skills')]
+ if (projectPath) dirs.push(join(projectPath, '.claude', 'skills'))
+
+ let count = 0
+ for (const dir of dirs) {
+ if (!existsSync(dir)) continue
+ try {
+ const entries = await readdir(dir)
+ for (const entry of entries) {
+ const skillFile = join(dir, entry, 'SKILL.md')
+ if (existsSync(skillFile)) count++
+ }
+ } catch { continue }
+ }
+
+ return count
+}
+
+async function scanMemoryFiles(projectPath?: string): Promise> {
+ const home = homedir()
+ const files: Array<{ name: string; tokens: number }> = []
+ const paths: Array<{ path: string; name: string }> = [
+ { path: join(home, '.claude', 'CLAUDE.md'), name: '~/.claude/CLAUDE.md' },
+ ]
+
+ if (projectPath) {
+ paths.push({ path: join(projectPath, 'CLAUDE.md'), name: 'CLAUDE.md' })
+ paths.push({ path: join(projectPath, '.claude', 'CLAUDE.md'), name: '.claude/CLAUDE.md' })
+ paths.push({ path: join(projectPath, 'CLAUDE.local.md'), name: 'CLAUDE.local.md' })
+ }
+
+ for (const { path, name } of paths) {
+ if (!existsSync(path)) continue
+ const content = await readSessionFile(path)
+ if (content === null) continue
+ files.push({ name, tokens: estimateTokens(content) })
+ }
+
+ return files
+}
+
+export async function estimateContextBudget(projectPath?: string, modelContext = 1_000_000): Promise {
+ const mcpToolCount = await countMcpTools(projectPath)
+ const skillCount = await countSkills(projectPath)
+ const memoryFiles = await scanMemoryFiles(projectPath)
+
+ const mcpTokens = mcpToolCount * TOOL_TOKENS_OVERHEAD
+ const skillTokens = skillCount * SKILL_FRONTMATTER_TOKENS
+ const memoryTokens = memoryFiles.reduce((s, f) => s + f.tokens, 0)
+ const total = SYSTEM_BASE_TOKENS + mcpTokens + skillTokens + memoryTokens
+
+ return {
+ systemBase: SYSTEM_BASE_TOKENS,
+ mcpTools: { count: mcpToolCount, tokens: mcpTokens },
+ skills: { count: skillCount, tokens: skillTokens },
+ memory: { count: memoryFiles.length, tokens: memoryTokens, files: memoryFiles },
+ total,
+ modelContext,
+ }
+}
+
+export async function estimateBudgetsByProject(projectPaths: Map): Promise