Merge remote-tracking branch 'origin/main' into feat/tui-workflow-titles

# Conflicts:
#	src/dashboard.tsx
This commit is contained in:
iamtoruk 2026-08-10 04:51:55 -07:00
commit 44082c3d1f
225 changed files with 22499 additions and 1174 deletions

View file

@ -0,0 +1,45 @@
name: Build Windows Store package
on:
workflow_dispatch:
pull_request:
paths:
- .github/workflows/build-windows-store.yml
- app/**
- src/**
- package.json
- package-lock.json
permissions:
contents: read
jobs:
appx:
runs-on: windows-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 22.13.0
cache: npm
cache-dependency-path: |
package-lock.json
app/package-lock.json
- name: Install CLI dependencies
run: npm ci
- name: Install desktop dependencies
run: npm ci --prefix app
- name: Build Microsoft Store package
run: npm --prefix app run package:store
- name: Upload Store package
uses: actions/upload-artifact@v6
with:
name: CodeBurn-Microsoft-Store
path: app/release/CodeBurn-Store-*.appx
if-no-files-found: error
retention-days: 14

36
.github/workflows/tests.yml vendored Normal file
View file

@ -0,0 +1,36 @@
name: Tests
on:
pull_request:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v4
with:
node-version: 22.13.0
cache: npm
- run: npm ci
- name: Typecheck
run: npx tsc --noEmit
# The cache-refresh-lock files exercise a cross-process file lock and are
# parallelism-sensitive (they fail under full worker pressure and pass serially -
# reproduced repeatedly on unmodified main), so they run in their own serial step
# below instead of making every PR roll dice.
# Scoping (tests/ only, app/ excluded) lives in the package.json test
# script since #948, so CI and a contributor's `npm test` can never drift.
- name: Test suite (parallel)
run: npm test
# Single forked worker, so lock contention comes only from the child processes the
# tests spawn deliberately. Quarantined (reports, never gates): the process
# suite still races its own takeover window even serially on slow runners -
# tracked in #904; drop continue-on-error once that race is settled.
- name: Cache-lock suite (serial, quarantined)
continue-on-error: true
run: npm run test:locks

5
.gitignore vendored
View file

@ -1,4 +1,7 @@
node_modules/
# No trailing slash: the slash form only ignores directories, so a
# node_modules SYMLINK (common in linked worktrees) slips into git add -A.
# One did exactly that in db018f7 and had to be removed again in c642787.
node_modules
dist/
*.tgz

41
.gitleaks.toml Normal file
View file

@ -0,0 +1,41 @@
# gitleaks config for codeburn.
#
# Extends the default ruleset and allowlists the cases a full-history audit
# (2026-08-04) confirmed are NOT secrets, so scans stay green and a real leak
# is never buried under recurring false positives. See each entry for why the
# match is safe; nothing here suppresses a live credential.
[extend]
useDefault = true
[[allowlists]]
description = "Test fixtures: obviously-fake API keys used as parser/validator input."
# sk-live-0123456789abcdef... and sk-live-AKIA1234567890SECRETKEY live in
# packages/core and root test suites purely as decode/redaction fixtures.
condition = "AND"
regexes = [
'''sk-live-0123456789abcdef''',
'''sk-live-AKIA1234567890SECRETKEY''',
]
paths = ['''(^|/)tests?/''']
[[allowlists]]
description = "Public OAuth client IDs (PKCE public-client flow, public by design, no client_secret)."
# Claude Code and Codex OAuth client identifiers. Client IDs travel in the
# authorization request and are not credentials; the flows carry no secret.
regexes = [
'''9d1c250a-e61b-44d9-88ed-5944d1962f5e''',
'''app_EMoamEEZ73f0CkXaXp7hrann''',
]
[[allowlists]]
description = "Non-secret identifiers the generic-api-key rule mis-fires on."
# e.g. dedup keys like 'synth-retain-89d' in parser fixtures.
condition = "AND"
regexes = ['''synth-[a-z0-9-]+''']
paths = ['''(^|/)tests?/''']
[[allowlists]]
description = "Canonical example JWT header used as a redaction/parse fixture (decodes to {\"alg\":\"HS256\",\"typ\":\"JWT\"}, carries no claims or signature)."
condition = "AND"
regexes = ['''eyJhbGciOiJIUzI1NiIsIn''']
paths = ['''(^|/)tests?/''']

1
.nvmrc Normal file
View file

@ -0,0 +1 @@
22.13.0

View file

@ -1,5 +1,32 @@
# Changelog
## Unreleased
### Added
- **Credit-metered ChatGPT workspaces (Business / Edu / Enterprise) now show their limit.** These plans report no rate-limit windows, so the admin-set monthly allowance from `spend_control.individual_limit` is shown as a "Monthly usage limit" bar in the desktop app and the menubar. (#833)
- **Combined-device scope in the desktop Dashboard**, mirroring the menu bar. A Local / Combined toggle aggregates paired-device usage in the Overview hero and the menu bar badge, degrading gracefully to the local figure when a peer is unreachable; the badge then shows a dimmed `reachable/total` marker so a momentary drop to the local number reads as "a peer is unreachable" rather than a glitch. (#866, #867, thanks @marcreynolds)
### Added (CLI)
- **Cline CLI provider.** The Cline command-line agent (npm `cline`, 3.x) stores sessions as `~/.cline/data/sessions/<id>/<id>.json` + `<id>.messages.json`, a layout the existing Cline provider never scanned — it requires `tasks/<id>/ui_messages.json` — so every CLI session was silently reported as $0.00, with no warning even under `--verbose`. Added as its own `cline-cli` provider so the shared Cline-family parser (Roo Code, KiloCode, IBM Bob) is untouched; it mirrors the CLI's own root resolution (`CLINE_SESSION_DATA_DIR``CLINE_DATA_DIR``CLINE_DIR``~/.cline`) and reports its probed root through `codeburn doctor`. Per-message cost is metered by the CLI, so `cline-cli` joins the reported-cost pass-through allowlist rather than being re-priced from tokens. (#874)
- **Codex throughput tracking**: per-model Tok/s in the dashboard and report, active time excludes tool wait. (#805, thanks @ihearttokyo)
- `codeburn sync push --attribution` (opt-in): sends git attribution spans — the session→commit correlation from `codeburn yield` (`codeburn.session.attribution` and `codeburn.commit` span types with normalized repo remote, commit SHAs, merged/reverted state, and PR links). Nothing new is sent without the flag; local-only repos and Windows filesystem paths are never emitted as repo identities, and sessions whose project path no longer resolves never inherit the push-time working directory's repo. See docs/sync/README.md "Git attribution".
### Fixed (CLI)
- **Copilot CLI sessions report their input and cache tokens.** The Copilot CLI writes the same `producer: 'copilot-agent'` in its `session.start` events that VS Code transcripts carry, so content-based detection classified every CLI session as a transcript and skipped its `session.shutdown` rollup — the only place the CLI records input, cache-read and cache-write tokens — leaving cache hit rate at 0.0% and dramatically underreporting cost. Whether a file is a transcript is now decided by where discovery found it, never by its contents. Resumed sessions, whose legs each append a cumulative rollup, are billed as per-leg deltas so a growing session never double-counts or goes stale; the GitHub Copilot desktop app writes the same session store, so its usage is covered by the same fix. The copilot session cache takes a parse-version bump and the daily cache bumps from v16 to v17 for the one-time re-parse that heals already-recorded days whose logs still exist. (#944)
- **Copilot CLI subagent runs are attributed to their agent.** Newer CLIs announce delegation with `subagent.started`/`subagent.completed` rather than `subagent.selected`, so delegated turns lost their agent label; the label now also clears when the subagent completes instead of bleeding onto the parent's later turns. Rides the #944 re-parse, so already-cached sessions gain the attribution. (#944)
- **`--project` / `--exclude` now apply to the headline totals, not just the detail panels.** The durable headline unions the carry-forward daily cache with today's live parse, and the cached days were sliced to the requested provider but never to the requested project — so the Overview panel counted excluded projects while By Project / By Activity / By Model (built from the name-filtered parse) left them out, and the two could not be reconciled. Cost, calls, sessions and savings are now sliced out of the per-project day stats the cache has carried since v15. Tokens, models and categories have no per-project split in the cache, so under a project filter they come from the (project-filtered) live parse instead; cached days — or provider slices — carried from before v15 have no project split at all, so they cannot be attributed to a filtered project, and the terminal overview now states how much was set aside rather than folding it into the total. (#864)
- **Codex parser corrections**: fork-replay no longer double-counts `patch_apply_end` and `mcp_tool_call_end`; `exec` is normalized to Bash; `custom_tool_call` events are handled; token_count lines larger than 32 KiB now parse exact token counts instead of estimating. Codex session cache bumps from v7 to v8 for a one-time re-parse. Only tool attribution changes for ordinary sessions, leaving their cost identical; sessions that logged an oversized token_count line are repriced from exact counts instead of an estimate. (#805)
- **Midnight-straddling turns keep both halves.** A turn whose calls span local midnight was attributed whole to its start day, so `codeburn today` under-reported until the turn ended and multi-day totals mis-split it. Calls are now range-filtered inside the turn so each day gets the calls that belong to it, and By Activity and the daily turn counts reconcile with the headline. (#853, thanks @KENSHI601)
- **`--provider <x>` no longer leaks Claude spend into the detail panels.** A provider-filtered run still ran the Claude scan, whose orphan pass re-injected every cached Claude session, so By Project / By Model / By Activity showed Claude usage under, e.g., `--provider cursor` while the headline was correct. (#872, thanks @ozymandiashh)
- **A degraded session parse no longer freezes daily history.** A read-only parse that served a stale or missing session file was treated as complete and finalized days it never covered, freezing warm-cache ingestion; a corrupt refresh lock is now recovered rather than ending ingestion, and a legitimately idle tail is no longer re-derived on every launch. (#856, thanks @avs-io)
- **Pi / Oh My Pi transcripts with a leading title record are discovered.** OMP writes a `type: "title"` line before the session header; discovery now scans a bounded number of leading lines for the first session record instead of requiring it on the first physical line. (#846, #859, thanks @jbspeakr, @avs-io)
- **Nine providers served silently stale numbers after you pointed their env override at a different profile or root.** Kiro, Grok, Kimi, Mux, Mistral Vibe, Zerostack, Codebuff, Goose and Crush each honor an env var that relocates where discovery looks, but the var was never declared in the provider env fingerprint, so the cache section survived the change and kept reporting sessions parsed from the old root — with no diagnostic anywhere. The fix declares those vars, the adjacent OS-set path variables that resolve a discovery root for Claude, IBM Bob, Open Design and Kilo Code on Windows and Linux, Cursor's parse-budget override, and the Vercel AI Gateway credential — which must invalidate the fingerprint because a read-only refresh serves the cached report and would otherwise keep reporting the previous account's usage after a swap. Your next run re-parses the fourteen file-backed providers whose declarations changed — the nine above plus Claude, Cursor, Open Design, IBM Bob and Kilo Code — once, and only once; the Vercel AI Gateway declaration is a read-only-path correction, not a migration (its report is re-fetched on every writable run anyway); Copilot is deliberately NOT included, because declaring its overrides would force a re-parse that can drop OTel history only the cache still holds; `codeburn doctor` names deliberate overrides including the XDG_* vars, never the Windows ambient APPDATA / LOCALAPPDATA, and redacts credential values. (#920)
### Fixed
- Claude Desktop and Cowork sessions are discovered for Windows Microsoft Store (MSIX) installs. (#611)
- Cline tasks are discovered in every VS Code variant (VS Code, VS Code Insiders, VSCodium), not just stable VS Code. (#874)
## 0.9.19 - 2026-07-20
One version across every surface: CLI, macOS menubar, and the desktop app all ship as 0.9.19.

View file

@ -24,17 +24,24 @@ There is no separate build step required to run the dev CLI. `npm run dev` runs
| Command | What it does |
|---|---|
| `npm test` | Runs the vitest suite (42 test files, 568 tests). |
| `npm test` | Runs the vitest suite under `tests/` (189 of the 192 files, 2,494 tests). |
| `npm run test:locks` | Runs the three parallelism-sensitive `cache-refresh-lock` suites serially. |
| `npm run test:watch` | Same scope as `npm test`, in watch mode. |
| `npm run dev -- status` | Runs the CLI in dev mode against your real data. |
| `npm run build` | Bundles the litellm pricing snapshot, then runs `tsup` to produce `dist/cli.js`. |
| `npm run bundle-litellm` | Refreshes `src/data/litellm-snapshot.json` from the upstream litellm repo. |
To test a specific suite, pass a path:
To test a specific suite, run vitest directly with a path:
```bash
npm test -- tests/providers/codex.test.ts
npx vitest run tests/providers/codex.test.ts
```
`npm test` is scoped to `tests/` on purpose. The Electron app under `app/` carries its
own vitest config and its own `jsdom` dependency in `app/node_modules`; letting vitest's
default glob reach those specs from a root install fails with
`ERR_MODULE_NOT_FOUND: jsdom`. To run the app's tests, install and run them from `app/`.
## What to Read Before Editing
- `docs/architecture.md` for the high-level codebase map.
@ -65,9 +72,14 @@ See `docs/architecture.md` for a fuller map.
## Tests
- Each new provider should ship with a fixture-based test under `tests/providers/`. The five providers without test files today (claude, gemini, goose, qwen, antigravity) are a known gap; new code should not add to that list.
- Each new provider should ship with a fixture-based test under `tests/providers/`. The three providers without test files today (claude, goose, qwen) are a known gap; new code should not add to that list.
- Each new optimize detector in `src/optimize.ts` needs at least one positive and one negative case in `tests/optimize.test.ts`.
- If your change affects the menubar JSON contract, update `tests/menubar-json.test.ts`.
- A new test that exercises the cross-process refresh lock must be named
`tests/cache-refresh-lock-<what>.test.ts` **and** added to the `test:locks` script in
`package.json`. `npm test` excludes that prefix, so a lock test placed anywhere else
runs under the full worker pool and fails intermittently; one that matches the prefix
but is missing from `test:locks` never runs at all.
## Commit Message Format

View file

@ -1,5 +1,5 @@
<p align="center">
<a href="https://claude.com/open-source-max"><img src="https://img.shields.io/badge/Claude_for_Open_Source-Recipient-da7756?style=for-the-badge&labelColor=1a1a1a" alt="Claude for Open Source Recipient" /></a>
<a href="https://claude.com/open-source-max"><img src="https://raw.githubusercontent.com/getagentseal/codeburn/main/assets/open-source-recipient.png" alt="Codex and Claude for Open Source Recipient" width="720" /></a>
</p>
<p align="center">
@ -27,7 +27,7 @@
<img src="https://raw.githubusercontent.com/getagentseal/codeburn/main/assets/desktop.jpg" alt="CodeBurn Desktop" /><br/>
<a href="https://github.com/getagentseal/codeburn/releases/download/desktop-v0.9.19/CodeBurn-0.9.19-arm64.dmg"><img src="https://img.shields.io/badge/macOS-Apple_Silicon-F97316?logo=apple&logoColor=white" alt="Download for macOS (Apple Silicon)" /></a>
<a href="https://github.com/getagentseal/codeburn/releases/download/desktop-v0.9.19/CodeBurn-0.9.19.dmg"><img src="https://img.shields.io/badge/macOS-Intel-F97316?logo=apple&logoColor=white" alt="Download for macOS (Intel)" /></a>
<a href="https://github.com/getagentseal/codeburn/releases/download/desktop-v0.9.19/CodeBurn-Setup-0.9.19.exe"><img src="https://img.shields.io/badge/Windows-Setup-F97316?logoColor=white" alt="Download for Windows" /></a>
<a href="https://apps.microsoft.com/detail/9P0R4ZL5XMB8"><img src="https://img.shields.io/badge/Windows-Microsoft_Store-F97316?logo=microsoft&logoColor=white" alt="Get CodeBurn from the Microsoft Store" /></a>
<a href="https://github.com/getagentseal/codeburn/releases/download/desktop-v0.9.19/codeburn-desktop_0.9.19_amd64.deb"><img src="https://img.shields.io/badge/Linux-.deb-F97316?logo=debian&logoColor=white" alt="Download for Linux (.deb)" /></a>
<a href="https://github.com/getagentseal/codeburn/releases/download/desktop-v0.9.19/codeburn-desktop-0.9.19.x86_64.rpm"><img src="https://img.shields.io/badge/Linux-.rpm-F97316?logo=redhat&logoColor=white" alt="Download for Linux (.rpm)" /></a>
<a href="https://github.com/getagentseal/codeburn/releases/download/desktop-v0.9.19/CodeBurn-0.9.19.AppImage"><img src="https://img.shields.io/badge/Linux-AppImage-F97316?logo=linux&logoColor=white" alt="Download for Linux (AppImage)" /></a>
@ -306,6 +306,14 @@ defaults write org.agentseal.codeburn-menubar CodeBurnMenubarRefreshSeconds -int
Seconds between refreshes: `60`, `300`, or `900`; `0` is Manual and `-1` is Auto. Takes effect on the next refresh tick, no relaunch needed.
**Preferred terminal** decides where Full Report and Optimize open. Set it in Settings → General → Terminal, or from Terminal:
```bash
defaults write org.agentseal.codeburn-menubar CodeBurnPreferredTerminal -string iterm2
```
Allowed values are `terminal` (macOS Terminal.app, the default) and `iterm2`. Anything else falls back to `terminal`. Only terminals that can script a command into a live window are offered; if the chosen app is missing or fails to accept the command, CodeBurn tries Terminal.app and then runs the command in the background, logging each step to Console.app. Takes effect on the next launch of a command, no relaunch needed.
### Linux (GNOME)
Linux gets the same ambient view through a GNOME Shell extension (GNOME 45+): spend in the top panel, period switcher, compact mode, and daily budget alerts. It lives in [`gnome/`](gnome/):
@ -401,7 +409,7 @@ Run `codeburn` for the dashboard, or use a subcommand below. Most commands also
| `codeburn report -p all` | Every recorded session |
| `codeburn report --from 2026-04-01 --to 2026-04-10` | An exact date range |
| `codeburn report --format json` | Full dashboard data as JSON, printed to stdout |
| `codeburn report --refresh 60` | Auto-refresh every 60s (default 30s; `--refresh 0` disables) |
| `codeburn report --refresh 60` | Auto-refresh every 60s (the minimum and default; `--refresh 0` disables) |
**Status & export**
@ -473,7 +481,7 @@ Sync sends token counts, costs, models, and projects, never prompts or code. Thi
| `codeburn models --task feature` | Filter to feature-development work |
| `codeburn models --provider claude` | Filter to a single provider |
Left/right arrow keys switch between Today, 7 Days, 30 Days, Month, 6 Months, and Lifetime (use `--from` / `--to` for an exact historical window). The main Daily Activity panel always shows scrollable full history: use up/down to move one day, Page Up/Page Down (or Shift+Space/Space) to page, and `g`/`G` to jump to either end. These keys update the panel in place instead of moving terminal scrollback. Press `q` to quit, `1` `2` `3` `4` `5` `6` as period shortcuts, `c` to open model comparison, or `o` to open optimize. The dashboard auto-refreshes every 30 seconds by default (`--refresh 0` to disable). It also shows average cost per session and the five most expensive sessions across all projects.
Left/right arrow keys switch between Today, 7 Days, 30 Days, Month, 6 Months, and Lifetime (use `--from` / `--to` for an exact historical window). Up/down scroll the full dashboard one line, Page Up/Page Down move one screen, and Home/End jump to either end. The main Daily Activity panel shows at least 10 dates from scrollable full history: use `j`/`k` to move one day, Shift+Space/Space to page, and `g`/`G` to jump to either end. Panels flow in the same order across three columns at maximum width, two at medium width, and one when narrow. In the three-column layout, all panels widen equally by one character for every three additional terminal columns until the dashboard reaches the lesser of 256 characters or the widest renderable source row. Press `q` to quit, `1` `2` `3` `4` `5` `6` as period shortcuts, `c` to open model comparison, or `o` to open optimize. Today, 7 Days, and concrete-day views refresh in place at most once per minute by default (`--refresh 0` to disable) without changing the active view or scroll position. The heavier aggregate views remain static between deliberate navigation changes. The dashboard also shows average cost per session and the five most expensive sessions across all projects.
</details>
@ -659,12 +667,14 @@ These are starting points, not verdicts. A 60% cache hit on a single experimenta
| **Kiro** | `.chat` JSON files | Token counts are estimated from content length. The model is not exposed, so sessions are labeled `kiro-auto` and costed at Sonnet rates. |
| **Mistral Vibe** | `~/.vibe/logs/session/` (or `$VIBE_HOME/logs/session/`); each folder has `meta.json` + `messages.jsonl` | Reads cumulative prompt/completion totals and model pricing from `meta.json`, then the first user prompt and tool calls from `messages.jsonl`. Emits one record per session (source data is cumulative, not per turn); subagent sessions under `agents/` are counted separately. |
| **OpenClaw** | `~/.openclaw/agents/*.jsonl` (legacy `.clawdbot`, `.moltbot`, `.moldbot`) | Token usage comes from assistant message `usage` blocks; the model from `modelId` or `message.model`. |
| **OpenClaude** | `~/.openclaude/projects/<slug>/*.jsonl` (or `$CODEBURN_OPENCLAUDE_DIR/projects/`) | Claude Code fork routed to any LLM backend; transcripts are Claude-Code schema. Only usage-bearing assistant lines become calls; no cost field exists, so every call is priced from the shared tables and flagged estimated. Sidechain (subagent) lines are counted as real spend. |
| **Warp** | `~/Library/Group Containers/2BBY89MBSN.dev.warp/Library/Application Support/dev.warp.Warp-Stable/warp.sqlite` (Preview fallback) | Reads `agent_conversations`, `ai_queries`, and `blocks`, emitting one call per finalized exchange. Exchange token share is estimated from prompt-size weighting normalized to conversation totals; `run_command` blocks attach to the nearest preceding exchange by timestamp. |
| **Zed** | SQLite `~/Library/Application Support/Zed/threads/threads.db` (Linux `~/.local/share/zed/threads/`) | One row per agent thread; the blob is zstd-compressed JSON with per-request token usage (input, output, cache read, cache write) and the thread's model. Threads are topped up to the exact cumulative counter so totals match the store. Needs Node 22.15+ for built-in zstd. |
| **Forge** | SQLite `~/.forge/.forge.db` | Queries `conversations` read-only and parses `context.messages`. Assistant usage entries provide prompt, completion, and cached counts; CodeBurn subtracts cached from prompt for input pricing, emits one call per assistant message, and extracts tool calls plus shell commands. |
| **Pi / OMP** | `~/.pi/agent/sessions/<sanitized-cwd>/*.jsonl` (Pi), `~/.omp/agent/sessions/<sanitized-cwd>/*.jsonl` (OMP) | Each assistant message carries usage (input, output, cacheRead, cacheWrite) plus inline `toolCall` blocks. Tool names normalize to the standard set (`bash``Bash`, `dispatch_agent``Agent`); bash commands come from `toolCall.arguments.command`. |
| **Codebuff** (formerly Manicode) | `~/.config/manicode/projects/<project>/chats/<chatId>/chat-messages.json` (honors `CODEBUFF_DATA_DIR`; walks `manicode-dev` / `manicode-staging`) | Bills in credits, so each completed assistant message is costed at the public rate of $0.01/credit via `msg.credits`. When an upstream provider's stashed RunState records token-level usage (`message.metadata.runState.sessionState.mainAgentState.messageHistory[*].providerOptions`), the real tokens and LiteLLM cost take precedence. Native tool names (`read_files`, `str_replace`, `run_terminal_command`, `spawn_agents`) normalize to `Read`, `Edit`, `Bash`, `Agent`. |
| **Cline / Roo Code / KiloCode** | VS Code `globalStorage`: Cline at `saoudrizwan.claude-dev` and `~/.cline/data`; Roo Code and KiloCode across VS Code, VS Code Insiders, and VSCodium | Cline-family agents. CodeBurn reads `ui_messages.json` from each task directory, extracting token counts from `type: "say"` entries with `say: "api_req_started"`. |
| **Cline / Roo Code / KiloCode** | VS Code `globalStorage` across VS Code, VS Code Insiders, and VSCodium (Cline at `saoudrizwan.claude-dev`, plus `~/.cline/data`) | Cline-family agents. CodeBurn reads `ui_messages.json` from each task directory, extracting token counts from `type: "say"` entries with `say: "api_req_started"`. |
| **Cline CLI** | `~/.cline/data/sessions/<session-id>/` (honors `CLINE_SESSION_DATA_DIR`, `CLINE_DATA_DIR`, `CLINE_DIR`) | The Cline command-line agent, whose layout is unrelated to the VS Code extension's. Reads `<session-id>.json` for session metadata and the rolled-up `usage`, and `<session-id>.messages.json` for the per-message `metrics` block (input, output, cacheRead, cacheWrite, cost) that becomes one call each. |
| **CodeWhale** | `~/.codewhale/sessions/*.json` plus unmigrated legacy `~/.deepseek/sessions/*.json`; `$CODEWHALE_HOME/sessions` is an exact override | Emits one cumulative record per saved session. CodeWhale exposes only `total_tokens`, so CodeBurn preserves that aggregate in the input column rather than inventing an input/output split. Cost is the exact stored parent-session plus subagent USD total; model pricing is used only when the cost snapshot is absent. Tool blocks, shell commands, skills, and subagent types are retained. |
| **IBM Bob** | `User/globalStorage/ibm.bob-code/tasks/<task-id>/` (GA `IBM Bob` and preview `Bob-IDE` app folders) | Reads `ui_messages.json` for API request token/cost records and `api_conversation_history.json` for the selected model. |
| **Kimi Code CLI** | `$KIMI_SHARE_DIR/sessions/<workdir-hash>/<session-id>/` or `~/.kimi/sessions/<workdir-hash>/<session-id>/` | Reads `wire.jsonl` `StatusUpdate.token_usage` records, mapping `input_other`, `input_cache_read`, `input_cache_creation`, and `output` into the standard token columns; includes subagents under each session's `subagents/` folder. |

View file

@ -14,8 +14,13 @@ Run the test suite to catch any regressions:
```bash
npm test
npm run test:locks
```
`npm test` covers `tests/`. `npm run test:locks` runs the three parallelism-sensitive
`cache-refresh-lock` suites serially; CI treats them as reporting-only, so check them by
hand here.
Verify that the build completes without errors:
```bash

111
SUBMISSION.md Normal file
View file

@ -0,0 +1,111 @@
# Submission Statement
## Proposed title
Stabilize TUI refresh, scrolling, responsive layout, and dashboard data density
## Summary
This pull request repairs the terminal dashboard as one coherent rendering surface. Background refresh no longer replaces the active Optimize view or resets the viewport. The full application can scroll. The eight dashboard panels retain their order while reflowing through one, two, and three columns. Metric headings and values remain visible before labels are shortened, and Daily Activity grows to match the relevant neighboring panels.
The branch is rebased on upstream `main` at `2c3319b`. The implementation reuses Ink and the existing dashboard state rather than adding a dependency or a second layout engine.
## Maintainer review reconciliation
The maintainer review identified a Windows ConPTY risk in the branch's custom synchronized-update write. A maintainer supplied a narrower escape-chunk fix in `7716f95`; this reconciliation preserves its intended Windows safety while removing the application-owned terminal protocol entirely:
- `src/ink-win.ts` is restored to the upstream implementation.
- The dashboard emits no manual begin/end synchronized-update sequence and no manual clear-and-home write.
- Ink remains the sole owner of terminal synchronization.
- CodeBurn's prepended resize handler only captures the new column count and rerenders React before Ink's ordinary resize listener paints.
This removes the reviewed ConPTY failure path instead of maintaining another platform-specific escape protocol. The Windows filter was checked with a mocked `win32` source-path test, and the pull request's AppX job remains the authoritative Windows package gate because no physical Windows host was available locally.
The same reconciliation restored the existing heavy-period refresh policy and made the CLI help truthful: Today, 7 Days, and concrete-day views may refresh automatically; 30 Days, Month, All, and Lifetime remain static between deliberate navigation changes. Every enabled interval is clamped to at least 60 seconds, and `--refresh 0` disables it.
## User-visible behavior
### Stable refresh and navigation
- A background result cannot replace the Optimize view after the user enters it.
- Background work retains the current frame instead of replacing it with a loading or blank screen.
- Refresh and resize rerenders preserve the application scroll offset.
- Up and down move one application row, Page Up and Page Down move one viewport, and Home and End jump to the bounds.
- Deliberate navigation to a different view, period, provider, or day begins at the top.
### Responsive dashboard
- The eight panels retain source order through one column at 89 characters or fewer, two columns from 90 through 134, and three columns from 135 upward.
- Three-column rows use the requested 3/3/2 arrangement.
- All three panels in a row widen equally by one character for every three additional terminal characters.
- Growth stops at the lesser of 256 characters or the widest row the current source data can render.
- Windows wider than 256 characters retain a populated capped dashboard.
- Colored bars remain at the left edge of every data section; Daily Activity places its bar before the date.
### Complete, compact data rows
- Metric widths are derived from their full headings and rendered values.
- Adjacent metric cells use exactly one separating character.
- `Tok/s` and every other metric column always render; unavailable values display `-`.
- Costs, including the estimated-cost `~` marker, render in full whenever the panel can hold them.
- The project heading spells out `session`.
- Project labels yield space before any heading or metric. Shortening removes the parent-folder prefix first, then the year in a date folder, and only then truncates the project title with a macOS-style ellipsis.
### Adaptive Daily Activity history
- One-column layout displays 10 dates.
- Two-column layout displays `MAX(10, visible By Project rows)`.
- Three-column layout displays `MAX(10, visible By Project rows, visible By Activity rows)`.
- Day mode remains one date, and available history remains the upper bound.
- Rendering, `j`/`k`, Space paging, `g`/`G`, final-page clamping, and the `Showing X-Y of Z` status share the same page-size calculation.
- By Activity row counting and rendering share the same aggregation, so the calculated height cannot drift from the displayed panel.
## TDDRGR and post-implementation bug-fix rounds
The adaptive-row contract first failed for the intended reason: a two-column lifetime fixture with 14 visible projects rendered 10 dates. The smallest production change introduced one shared page-size calculation. After the first green run, the refactor reused the existing project-row limit and Activity aggregation, and the focused contract stayed green.
The maintainer reconciliation also began red. Tests proved that the maintainer head still contained application-owned synchronized writes, scheduled refreshes for four heavy periods, and advertised a 30-second interval in three CLI help surfaces. Removing the writes, restoring the period gate, and updating the help produced 59 passing focused tests.
Dedicated bug-fix rounds then repeated the relevant regression checks and real user path:
1. Daily Activity paging and bounds used the calculated 10/14/18-row sizes.
2. Full-application End scrolling remained at the bottom after a live 89-to-100-column resize.
3. Optimize remained mounted across live 100-to-89-column reflow, while its fake-timer refresh regression retained the view with no loading frame.
4. An unsuccessful `incrementalRendering` experiment was removed after measurement showed no improvement; the smaller Ink-owned design remained.
Correctness review found no issue in the final production diff. Ponytail review concluded: `Lean already. Ship.`
## Validation
### Deterministic and build gates
- Focused refresh, resize, layout, scrolling, metric, and CLI-help matrix: **59/59**.
- Relevant dashboard, model, overview, and CLI-help matrix: **72/72**.
- Complete dashboard suite: **56/56**.
- Desktop application suite: **462/462**.
- Root `tests/` suite: **2,481 passed**, **3 failed**, and **5 skipped**. The same three failures reproduce at unmodified upstream `2c3319b`: two Copilot durable-orphan assertions and one provider-filter durable-total assertion. None touches this dashboard diff.
- TypeScript checks for the CLI and desktop application: passed.
- CLI, browser dashboard, and desktop application production builds: passed. The existing Vite warning for a browser chunk above 500 KB is unchanged.
- `git diff --check`: passed.
Running root Vitest without limiting it to `tests/` also discovers the nested desktop tests under the root configuration. That unsupported combined invocation lacks the desktop setup and produces matcher/environment failures; the canonical desktop command above passes all 462 tests.
### Native Ghostty inspection
- **241** deterministic width frames from 60 through 300 columns confirmed the 89/90 and 134/135 breakpoints, symmetric three-column growth, the 256-character cap, and populated frames above the cap.
- **40** window-bounded Ghostty captures covered two font zoom levels, multiple window shapes, top, scrolled, and Optimize states, with most captures below 260 columns as requested.
- **105** final settled captures shrank one column at a time from 146 through 42. All contained rendered content; no settled frame was blank.
- **20** repeated 120-to-110-column shrink cycles rendered successfully.
- Final live screenshots confirmed scroll-position preservation across a one-to-two-column resize and Optimize preservation across the reverse breakpoint.
All visual evidence used the Ghostty window ID with native `screencapture -l`; no full-display capture and no Computer Use session was used. The user's Ghostty shell was returned to its original `~` prompt, size, and position after validation.
## Deliberate non-changes
- Compare keeps its existing two-column composition; redesigning it is outside this dashboard repair.
- The status/help bar remains part of the scrollable content, as requested during review.
- Existing aggregation memoization and viewport-measurement behavior remain unchanged where the accepted design did not require them.
## Reviewer focus
The highest-value review is the interaction among the shared metric row, the calculated Daily Activity page size, and existing scroll state. Acceptance requires that background refresh never changes the active view or position, supported widths never lose a metric, each settled resize preserves panel order and content, and Daily Activity navigation uses the same page size shown on screen.

View file

@ -66,6 +66,7 @@ npm --prefix app run package # macOS, both arm64 and x64
npm --prefix app run package:arm64 # macOS arm64 only (faster on Apple Silicon)
npm --prefix app run package:x64 # macOS x64 only
npm --prefix app run package:win # Windows NSIS installer, x64
npm --prefix app run package:store # Microsoft Store AppX, x64 (Windows host only)
npm --prefix app run package:linux # Linux AppImage, x64
```
@ -184,6 +185,27 @@ shows **"Windows protected your PC"**. Users click **"More info" → "Run
anyway"** to launch it. This is expected for an unsigned build; the only fix is
a purchased code-signing (Authenticode/EV) certificate.
### Microsoft Store (`package:store`)
The Store build is a separate AppX target so the GitHub NSIS installer remains
unchanged. AppX packaging requires Windows 10 or newer and is built by the
manual `Build Windows Store package` GitHub Actions workflow on
`windows-latest`. Download its `CodeBurn-Microsoft-Store` workflow artifact and
upload the contained `CodeBurn-Store-<version>-x64.appx` file in Partner Center.
The manifest identity must exactly match the reserved Partner Center product:
- Identity name: `Codeburn.CodeBurn`
- Publisher: `CN=3EFA3336-87E1-46F2-9DFA-2EB5A7693F89`
- Publisher display name: `Codeburn`
- Store ID: `9P0R4ZL5XMB8`
The Store package is intentionally unsigned: Microsoft signs it during Store
submission. Direct sideloading requires a separate trusted or development
certificate. The AppX declares `runFullTrust` (electron-builder's required
default for Electron apps), so CodeBurn retains access to the user's local
provider session files rather than running in a UWP application sandbox.
### Linux (`package:linux`)
`electron-builder --linux` produces a single artifact in `app/release/`:

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View file

@ -89,6 +89,12 @@ const ARGV_CASES: Array<{ channel: string; args: unknown[]; argv: string[] }> =
{ channel: 'codeburn:getOverview', args: ['30days', 'all', { from: '2026-07-01', to: '2026-07-11' }], argv: ['status', '--format', 'menubar-json', '--period', '30days', '--no-timeline', '--from', '2026-07-01', '--to', '2026-07-11'] },
{ channel: 'codeburn:getOverview', args: ['30days', 'all', undefined, 'claude-config:91dda17e8cf35193'], argv: ['status', '--format', 'menubar-json', '--period', '30days', '--no-timeline', '--claude-config-source', 'claude-config:91dda17e8cf35193'] },
{ channel: 'codeburn:getOverview', args: ['month', 'claude', { from: '2026-07-01', to: '2026-07-11' }, 'claude-desktop:980e1e488a654830'], argv: ['status', '--format', 'menubar-json', '--period', 'month', '--no-timeline', '--provider', 'claude', '--from', '2026-07-01', '--to', '2026-07-11', '--claude-config-source', 'claude-desktop:980e1e488a654830'] },
// Combined scope emits --scope combined; an explicit local scope is identical
// to the default (no flag). The CLI rejects --scope with --provider, so a
// provider passed alongside combined is dropped (the renderer forces 'all').
{ channel: 'codeburn:getOverview', args: ['30days', 'all', undefined, undefined, undefined, 'combined'], argv: ['status', '--format', 'menubar-json', '--period', '30days', '--no-timeline', '--scope', 'combined'] },
{ channel: 'codeburn:getOverview', args: ['30days', 'claude', undefined, undefined, undefined, 'combined'], argv: ['status', '--format', 'menubar-json', '--period', '30days', '--no-timeline', '--scope', 'combined'] },
{ channel: 'codeburn:getOverview', args: ['30days', 'claude', undefined, undefined, undefined, 'local'], argv: ['status', '--format', 'menubar-json', '--period', '30days', '--no-timeline', '--provider', 'claude'] },
{ channel: 'codeburn:getModels', args: ['week', 'claude', true, { from: '2026-07-01', to: '2026-07-11' }], argv: ['models', '--format', 'json', '--period', 'week', '--provider', 'claude', '--by-task', '--from', '2026-07-01', '--to', '2026-07-11'] },
{ channel: 'codeburn:getYield', args: ['today', 'all', { from: '2026-07-01', to: '2026-07-11' }], argv: ['yield', '--format', 'json', '--period', 'today', '--from', '2026-07-01', '--to', '2026-07-11'] },
{ channel: 'codeburn:getSpendFlow', args: ['month', 'all', { from: '2026-07-01', to: '2026-07-11' }], argv: ['spend', '--format', 'flow-json', '--period', 'month', '--from', '2026-07-01', '--to', '2026-07-11'] },
@ -212,6 +218,7 @@ describe('createBridgeHandlers (IPC input validation)', () => {
{ name: 'remove price override model that looks like a flag', channel: 'codeburn:removePriceOverride', args: ['--all'] },
{ name: 'claude config source that looks like a flag', channel: 'codeburn:getOverview', args: ['30days', 'all', undefined, '-rf'] },
{ name: 'claude config source with shell metacharacters', channel: 'codeburn:getOverview', args: ['30days', 'all', undefined, 'id; rm -rf'] },
{ name: 'unknown scope', channel: 'codeburn:getOverview', args: ['30days', 'all', undefined, undefined, undefined, 'everything'] },
]
it.each(REJECTIONS)('rejects $name with a bad-args envelope and never spawns', async ({ channel, args }) => {

View file

@ -165,6 +165,11 @@ function vConfigSource(source: string | null | undefined): string | null {
if (!/^[A-Za-z0-9][A-Za-z0-9:_-]*$/.test(source)) throw new CliError('bad-args', 'invalid claude config source')
return source
}
function vScope(scope: string | undefined): 'local' | 'combined' {
if (scope === 'combined') return 'combined'
if (scope === undefined || scope === 'local') return 'local'
throw new CliError('bad-args', 'invalid scope')
}
function vOutPath(outPath: string): string {
if (outPath.startsWith('-') || !path.isAbsolute(outPath)) throw new CliError('bad-args', 'export path must be absolute')
return outPath
@ -269,19 +274,28 @@ export function createBridgeHandlers(deps: Deps = { spawnCli, spawnCliAction, re
// The desktop never renders the granular timeline, so it always passes
// --no-timeline (skips buildGranularHistory on every poll). The Swift menubar
// omits the flag and keeps the timeline unchanged.
const buildOverviewArgs = (period: string, provider: string, range?: DateRange, configSource?: string | null): string[] => [
'status', '--format', 'menubar-json', '--period', vPeriod(period), '--no-timeline',
...providerArgs(vProvider(provider)), ...rangeArgs(vRange(range)), ...configSourceArgs(vConfigSource(configSource)),
]
//
// Combined scope aggregates paired-device usage: the CLI rejects --scope
// combined alongside --provider/--project/--exclude (paired devices report
// unfiltered usage), so the provider filter is dropped in that mode. The
// caller (renderer) forces provider='all' when combined, so nothing is lost.
const buildOverviewArgs = (period: string, provider: string, range?: DateRange, configSource?: string | null, scope?: string): string[] => {
const vScopeValue = vScope(scope)
return [
'status', '--format', 'menubar-json', '--period', vPeriod(period), '--no-timeline',
...(vScopeValue === 'combined' ? ['--scope', 'combined'] : providerArgs(vProvider(provider))),
...rangeArgs(vRange(range)), ...configSourceArgs(vConfigSource(configSource)),
]
}
// `background` (renderer prefetch only) drops this fetch to background priority
// so it yields the CLI's run slots to any interactive poll or click. Optional
// and defaulting to interactive, so an older preload that omits it is unchanged.
const getOverview: Handler = async (period: string, provider: string, range?: DateRange, configSource?: string | null, background?: boolean) => {
const getOverview: Handler = async (period: string, provider: string, range?: DateRange, configSource?: string | null, background?: boolean, scope?: string) => {
coldStartBegan ??= Date.now()
const priority: SpawnPriority | undefined = background ? 'background' : undefined
try {
const args = buildOverviewArgs(period, provider, range, configSource)
const args = buildOverviewArgs(period, provider, range, configSource, scope)
if (overviewWarmed) return { ok: true, value: await deps.spawnCli(args, priority ? { priority } : undefined) }
const value = await deps.spawnCli(args, {
timeoutMs: WARMUP_TIMEOUT_MS,

View file

@ -20,7 +20,7 @@ async function invoke<T>(channel: string, ...args: unknown[]): Promise<T> {
// renderer-side where `window.codeburn` is declared as CodeburnBridge.
const bridge = {
getQuota: (force?: boolean) => invoke('codeburn:getQuota', force),
getOverview: (period: string, provider: string, range?: DateRange, configSource?: string | null, background?: boolean) => invoke('codeburn:getOverview', period, provider, range, configSource, background),
getOverview: (period: string, provider: string, range?: DateRange, configSource?: string | null, background?: boolean, scope?: string) => invoke('codeburn:getOverview', period, provider, range, configSource, background, scope),
getPlans: (period: string) => invoke('codeburn:getPlans', period),
getActReport: () => invoke('codeburn:getActReport'),
getModels: (period: string, provider: string, byTask: boolean, range?: DateRange) => invoke('codeburn:getModels', period, provider, byTask, range),

View file

@ -38,6 +38,153 @@ describe('Codex quota', () => {
expect(quota.details).toHaveLength(1)
})
// Shape captured from a live ChatGPT Enterprise workspace.
const enterpriseBody = {
plan_type: 'business',
rate_limit: null,
additional_rate_limits: null,
credits: { has_credits: false, unlimited: false, balance: null },
spend_control: {
reached: false,
individual_limit: {
source: 'workspace_spend_controls',
limit: '10000',
used: '3028.9909675121307',
remaining: '6971.009032487869',
used_percent: 30,
remaining_percent: 70,
reset_after_seconds: 441_896,
reset_at: 1_785_542_400,
},
},
rate_limit_reset_credits: { available_count: 0 },
}
it('surfaces the spend-control credit limit when there are no rate windows', () => {
const quota = decodeCodexUsage(enterpriseBody)
expect(quota.primary).toEqual({
label: 'Monthly usage limit · 3,029 / 10,000 credits',
percent: 0.3,
resetsAt: new Date(1_785_542_400 * 1000).toISOString(),
})
expect(quota.details).toEqual([quota.primary])
expect(quota.planLabel).toBe('Business')
expect(quota.footerLines).toEqual([])
})
it('keeps rate windows primary and appends the credit limit alongside them', () => {
const quota = decodeCodexUsage({
...enterpriseBody,
rate_limit: { primary_window: { used_percent: 20, reset_at: 1_800_000_000, limit_window_seconds: 18_000 } },
})
expect(quota.primary?.label).toBe('5-hour')
expect(quota.details.map(row => row.label)).toEqual([
'5-hour',
'Monthly usage limit · 3,029 / 10,000 credits',
])
})
it.each([
['top level', (limit: unknown) => ({ individual_limit: limit })],
['camelCase key', (limit: unknown) => ({ spend_control: { individualLimit: limit } })],
['nested in rate_limit', (limit: unknown) => ({ rate_limit: { individual_limit: limit } })],
])('reads the credit limit positioned at %s', (_name, wrap) => {
const quota = decodeCodexUsage(wrap({ limit: 10_000, used: 2500, used_percent: 25 }))
expect(quota.primary?.percent).toBe(0.25)
expect(quota.primary?.label).toBe('Monthly usage limit · 2,500 / 10,000 credits')
})
it('derives the percent from remaining_percent, then from used/limit', () => {
const fromRemaining = decodeCodexUsage({ spend_control: { individual_limit: { limit: 10_000, remaining_percent: 70 } } })
expect(fromRemaining.primary?.percent).toBeCloseTo(0.3)
expect(fromRemaining.primary?.label).toBe('Monthly usage limit · 3,000 / 10,000 credits')
const fromRatio = decodeCodexUsage({ spend_control: { individual_limit: { limit: 400, used: 100 } } })
expect(fromRatio.primary?.percent).toBeCloseTo(0.25)
})
it('ignores a spend control with no usable limit', () => {
for (const individual_limit of [{ limit: 0, used: 5 }, { limit: null }, { used_percent: 40 }, null]) {
const quota = decodeCodexUsage({ spend_control: { individual_limit } })
expect(quota.primary).toBeNull()
expect(quota.details).toEqual([])
}
})
it('renders no row when the allowance is known but the draw on it is not', () => {
const quota = decodeCodexUsage({ spend_control: { individual_limit: { limit: 10_000, reset_at: 1_785_542_400 } } })
expect(quota.primary).toBeNull()
expect(quota.details).toEqual([])
})
it('treats a blank numeric string as absent, not as zero', () => {
const quota = decodeCodexUsage({ spend_control: { individual_limit: { limit: '10000', used: ' ', used_percent: 30 } } })
expect(quota.primary?.label).toBe('Monthly usage limit · 3,000 / 10,000 credits')
expect(decodeCodexUsage({ spend_control: { individual_limit: { limit: '' } } }).primary).toBeNull()
})
it('marks a spent-out allowance as reached', () => {
const quota = decodeCodexUsage({
spend_control: { reached: true, individual_limit: { limit: 10_000, used: 10_000, used_percent: 100 } },
})
expect(quota.primary?.label).toBe('Monthly usage limit · 10,000 / 10,000 credits · limit reached')
expect(quota.primary?.percent).toBe(1)
})
it('keeps overage counts truthful while clamping the bar', () => {
const quota = decodeCodexUsage({ spend_control: { individual_limit: { limit: 10_000, used: 12_000, used_percent: 120 } } })
expect(quota.primary?.label).toBe('Monthly usage limit · 12,000 / 10,000 credits')
expect(quota.primary?.percent).toBe(1)
})
it('keeps the implied overage when only the percent is given', () => {
const quota = decodeCodexUsage({ spend_control: { individual_limit: { limit: 10_000, used_percent: 120 } } })
expect(quota.primary?.label).toBe('Monthly usage limit · 12,000 / 10,000 credits')
expect(quota.primary?.percent).toBe(1)
})
it('skips a garbage alias instead of letting it mask a valid one', () => {
const quota = decodeCodexUsage({
spend_control: { individual_limit: 'bad', individualLimit: { limit: 100, usedPercent: 25 } },
})
expect(quota.primary?.label).toBe('Monthly usage limit · 25 / 100 credits')
const perField = decodeCodexUsage({
spend_control: { individual_limit: { limit: 100, used_percent: 'bad', usedPercent: 25 } },
})
expect(perField.primary?.percent).toBe(0.25)
})
it('survives a reset timestamp beyond the Date range', () => {
const quota = decodeCodexUsage({ spend_control: { individual_limit: { limit: 100, used_percent: 10, reset_at: 9_000_000_000_000 } } })
expect(quota.primary?.resetsAt).toBeNull()
expect(quota.primary?.percent).toBe(0.1)
})
it('says so when the account is credit-metered but uncapped', () => {
const quota = decodeCodexUsage({ plan_type: 'business', credits: { has_credits: true, unlimited: true } })
expect(quota.footerLines).toEqual(['Credits · Unlimited'])
const capped = decodeCodexUsage({ credits: { unlimited: true }, spend_control: { individual_limit: { limit: 10_000, used_percent: 30 } } })
expect(capped.footerLines).toEqual([])
})
it('normalizes credit-based-pricing plan tiers', () => {
const label = (plan_type: string) => decodeCodexUsage({ plan_type }).planLabel
expect(label('enterprise_cbp_usage_based')).toBe('Enterprise')
expect(label('self_serve_business_usage_based')).toBe('Business')
expect(label('enterprise')).toBe('Enterprise')
expect(label('some_future_tier')).toBe('Some Future Tier')
})
it('labels a credit-settled balance in credits, not dollars', () => {
const inCredits = decodeCodexUsage({ credits: { has_credits: true, balance: 3410.4 } })
expect(inCredits.footerLines).toEqual(['Credits remaining · 3,410'])
const inDollars = decodeCodexUsage({ credits: { has_credits: false, balance: 3.5 } })
expect(inDollars.footerLines).toEqual(['Credits remaining · $3.50'])
// Thousands separators must match the menubar's en_US currency formatter.
const inDollarsLarge = decodeCodexUsage({ credits: { has_credits: false, balance: 12500 } })
expect(inDollarsLarge.footerLines).toEqual(['Credits remaining · $12,500.00'])
})
it('returns disconnected without credentials', async () => {
const fetchMock = vi.fn()
const result = await fetchCodexQuota({ fetch: fetchMock, readFile: vi.fn(async () => null) })

View file

@ -133,10 +133,27 @@ function windowOf(value: unknown, override?: string): QuotaWindow | null {
return { label: override ?? labelForSeconds(row.limit_window_seconds), percent, resetsAt: reset }
}
// chatgpt.com mixes encodings inside one payload. `Number('')` is 0, not NaN,
// so blank is rejected or an absent `used` decodes as a confident zero.
function num(value: unknown): number | null {
if (typeof value === 'string' && !value.trim()) return null
const parsed = typeof value === 'number' ? value : typeof value === 'string' ? Number(value.trim()) : NaN
return Number.isFinite(parsed) ? parsed : null
}
// Credit-based-pricing tiers arrive composite (`enterprise_cbp_usage_based`).
function normalizePlanType(value: string): string {
return value
.replace(/[_-]usage[_-]based$/, '')
.replace(/^self[_-]serve[_-]/, '')
.replace(/[_-]cbp$/, '')
.replace(/[_-]cbp[_-]/g, '_')
}
function planLabel(value: unknown): string | null {
if (typeof value !== 'string' || !value.trim()) return null
const raw = value.trim()
const lower = raw.toLowerCase()
const lower = normalizePlanType(raw.toLowerCase())
const known: Record<string, string> = {
guest: 'Guest', free: 'Free', go: 'Go', plus: 'Plus', pro: 'Pro',
prolite: 'Pro Lite', pro_lite: 'Pro Lite', 'pro-lite': 'Pro Lite',
@ -146,6 +163,44 @@ function planLabel(value: unknown): string | null {
return known[lower] ?? lower.replace(/(^|[_-])\w/g, match => match.replace(/[_-]/, ' ').toUpperCase())
}
// The admin-set monthly allowance, the only limit a credit-metered workspace
// has. `spend_control` is the live position, the others forward-compat. `any`
// because this walks six optional-chained hops, all validated by `num()`.
function spendControlWindow(data: Record<string, any>): QuotaWindow | null {
// `find`/`num` per alias, not `??`: a non-null garbage value would stop `??`
// and mask a valid alias further down. Object-shaped garbage still wins the
// position, matching Swift, which likewise commits to the first that decodes.
const row = [
data.spend_control?.individual_limit,
data.spend_control?.individualLimit,
data.individual_limit,
data.individualLimit,
data.rate_limit?.individual_limit,
data.rate_limit?.individualLimit,
].find(candidate => candidate && typeof candidate === 'object')
if (!row) return null
const limit = num(row.limit)
if (limit === null || limit <= 0) return null
const remainingPercent = num(row.remaining_percent) ?? num(row.remainingPercent)
const used = num(row.used)
const rawPercent = num(row.used_percent) ?? num(row.usedPercent)
?? (remainingPercent === null ? null : 100 - remainingPercent)
?? (used === null ? null : (used / limit) * 100)
if (rawPercent === null) return null
const percent = Math.min(1, Math.max(0, rawPercent / 100))
const resetRaw = num(row.reset_at) ?? num(row.resets_at) ?? num(row.resetsAt)
// Past 8.64e15 ms `toISOString()` throws RangeError.
const resetsAt = resetRaw !== null && resetRaw > 0 && resetRaw * 1000 <= 8.64e15
? new Date(resetRaw * 1000).toISOString()
: null
// Unclamped percent, so a 120% draw still reports 12,000 of 10,000.
const spent = used ?? limit * Math.max(0, rawPercent) / 100
const round = (n: number) => Math.round(n).toLocaleString('en-US')
const reached = data.spend_control?.reached === true
const label = `Monthly usage limit · ${round(spent)} / ${round(limit)} credits`
return { label: reached ? `${label} · limit reached` : label, percent, resetsAt }
}
export function decodeCodexUsage(body: unknown): QuotaProvider {
const data = body && typeof body === 'object' ? body as Record<string, any> : {}
const primaryRaw = windowOf(data.rate_limit?.primary_window)
@ -165,12 +220,21 @@ export function decodeCodexUsage(body: unknown): QuotaProvider {
}
}
}
const rawBalance = data.credits?.balance
const balance = typeof rawBalance === 'number' ? rawBalance : typeof rawBalance === 'string' ? Number(rawBalance) : NaN
const credits = spendControlWindow(data)
if (credits) details.push(credits)
const balance = num(data.credits?.balance)
// Credit-settled accounts denominate in credits, so no currency symbol.
const hasCredits = data.credits?.has_credits === true
const footerLines: string[] = []
if (balance !== null && balance > 0) {
footerLines.push(`Credits remaining · ${hasCredits ? Math.round(balance).toLocaleString('en-US') : balance.toLocaleString('en-US', { style: 'currency', currency: 'USD' })}`)
}
// Uncapped on purpose, so a bar-less card does not read as a failed fetch.
if (!credits && data.credits?.unlimited === true) footerLines.push('Credits · Unlimited')
return {
provider: 'codex', connection: 'connected', primary, details,
provider: 'codex', connection: 'connected', primary: primary ?? credits, details,
planLabel: planLabel(data.plan_type),
footerLines: Number.isFinite(balance) && balance > 0 ? [`Credits remaining · $${balance.toFixed(2)}`] : [],
footerLines,
}
}

View file

@ -16,6 +16,7 @@
"package:arm64": "npm run stage-cli && npm run build && electron-builder --mac --arm64",
"package:x64": "npm run stage-cli && npm run build && electron-builder --mac --x64",
"package:win": "npm run stage-cli && npm run build && electron-builder --win",
"package:store": "npm run stage-cli && npm run build && electron-builder --win appx --x64",
"package:linux": "npm run stage-cli && npm run build && electron-builder --linux"
},
"dependencies": {
@ -100,6 +101,20 @@
"perMachine": false,
"artifactName": "CodeBurn-Setup-${version}.${ext}"
},
"appx": {
"applicationId": "CodeBurn",
"identityName": "Codeburn.CodeBurn",
"publisher": "CN=3EFA3336-87E1-46F2-9DFA-2EB5A7693F89",
"publisherDisplayName": "Codeburn",
"displayName": "CodeBurn",
"artifactName": "CodeBurn-Store-${version}-${arch}.${ext}",
"backgroundColor": "#15100D",
"languages": [
"en-US"
],
"minVersion": "10.0.17763.0",
"maxVersionTested": "10.0.26100.0"
},
"linux": {
"target": [
{

View file

@ -1,6 +1,6 @@
// @vitest-environment jsdom
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { App, overviewMemoKey, topCategoryByModel, usageSnapshotProps } from './App'
import { sanitizeProps } from '../electron/telemetry'
@ -17,7 +17,7 @@ vi.stubGlobal('localStorage', {
})
const mocks = vi.hoisted(() => ({
getOverview: vi.fn<(period: string, provider: string, range?: DateRange, configSource?: string | null, background?: boolean) => Promise<MenubarPayload>>(),
getOverview: vi.fn<(period: string, provider: string, range?: DateRange, configSource?: string | null, background?: boolean, scope?: string) => Promise<MenubarPayload>>(),
getSpendFlow: vi.fn<(period: string, provider: string, range?: DateRange) => Promise<SpendFlow>>(),
getOptimizeReport: vi.fn<(period: string, provider: string, range?: DateRange) => Promise<OptimizeJsonReport>>(),
getModels: vi.fn(),
@ -52,6 +52,16 @@ function setVisibility(state: 'visible' | 'hidden') {
Object.defineProperty(document, 'hidden', { configurable: true, get: () => state === 'hidden' })
}
// The shortcut code (lib/platform.ts) reads `window.codeburn.platform` at call
// time; stub it per test and always restore so no test leaks platform state.
function setPlatform(platform: string): void {
;(window as unknown as { codeburn?: { platform?: string } }).codeburn = { platform }
}
function clearPlatform(): void {
delete (window as unknown as { codeburn?: { platform?: string } }).codeburn
}
function overviewPayload(): MenubarPayload {
const now = new Date()
return {
@ -181,6 +191,11 @@ describe('App shortcuts', () => {
// the app-wide default ('today'); tests that exercise the default set it.
localStorage.setItem('codeburn.defaultPeriod', '30days')
document.documentElement.removeAttribute('data-theme')
setPlatform('darwin')
})
afterEach(() => {
clearPlatform()
})
it('applies the persisted theme on app boot before Settings mounts', async () => {
@ -211,47 +226,63 @@ describe('App shortcuts', () => {
expect(await screen.findByText('No sessions in this range yet.')).toBeInTheDocument()
})
it('keeps command navigation, settings, and refresh shortcuts active without stale hints', async () => {
it.each([
['darwin', { metaKey: true }, '⌘'],
['win32', { ctrlKey: true }, 'Ctrl+'],
] as const)('keeps %s navigation, settings, and refresh shortcuts active without stale hints', async (platform, chord, mod) => {
setPlatform(platform)
render(<App />)
expect(await screen.findByText('Most expensive sessions')).toBeInTheDocument()
expect(screen.getByText('⌘1-7')).toBeInTheDocument()
expect(screen.getAllByText('⌘,').length).toBeGreaterThan(0)
expect(screen.getByText('⌘R')).toBeInTheDocument()
expect(screen.getByText(`${mod}1-8`)).toBeInTheDocument()
expect(screen.getAllByText(`${mod},`).length).toBeGreaterThan(0)
expect(screen.getByText(`${mod}R`)).toBeInTheDocument()
expect(screen.queryByText('Command')).not.toBeInTheDocument()
expect(screen.queryByText('Export view')).not.toBeInTheDocument()
fireEvent.keyDown(document, { key: '2', metaKey: true })
fireEvent.keyDown(document, { key: '2', ...chord })
expect(await screen.findByText('No sessions in this range yet.')).toBeInTheDocument()
fireEvent.keyDown(document, { key: '3', metaKey: true })
fireEvent.keyDown(document, { key: '3', ...chord })
expect(await screen.findByText(/PR links are captured as sessions are parsed/)).toBeInTheDocument()
fireEvent.keyDown(document, { key: '4', ...chord })
expect(await screen.findByText('Cost flow · model → project')).toBeInTheDocument()
fireEvent.keyDown(document, { key: '4', metaKey: true })
fireEvent.keyDown(document, { key: '5', ...chord })
expect(await screen.findByText('No waste findings in this range yet.')).toBeInTheDocument()
fireEvent.keyDown(document, { key: '5', metaKey: true })
fireEvent.keyDown(document, { key: '6', ...chord })
expect(await screen.findByText('No model usage in this range yet.')).toBeInTheDocument()
fireEvent.keyDown(document, { key: '6', metaKey: true })
fireEvent.keyDown(document, { key: '7', ...chord })
expect(await screen.findByText('Need at least two models with usage in this range to compare.')).toBeInTheDocument()
fireEvent.keyDown(document, { key: '7', metaKey: true })
fireEvent.keyDown(document, { key: '8', ...chord })
expect(await screen.findByText('Not connected. Log in with the Claude CLI.')).toBeInTheDocument()
fireEvent.keyDown(document, { key: ',', metaKey: true })
fireEvent.keyDown(document, { key: ',', ...chord })
expect((await screen.findAllByText('Settings')).length).toBeGreaterThan(0)
expect(screen.queryByText('Back')).not.toBeInTheDocument()
const overviewCalls = mocks.getOverview.mock.calls.length
fireEvent.keyDown(document, { key: 'r', metaKey: true })
fireEvent.keyDown(document, { key: 'r', ...chord })
await waitFor(() => expect(mocks.getOverview.mock.calls.length).toBeGreaterThan(overviewCalls))
})
it('ignores Ctrl+2 on mac', async () => {
render(<App />)
expect(await screen.findByText('Most expensive sessions')).toBeInTheDocument()
fireEvent.keyDown(document, { key: '2', ctrlKey: true })
expect(screen.queryByText('No sessions in this range yet.')).not.toBeInTheDocument()
})
it('re-polls visible section data when period or provider changes', async () => {
render(<App />)
fireEvent.keyDown(document, { key: '3', metaKey: true })
fireEvent.keyDown(document, { key: '4', metaKey: true })
expect(await screen.findByText('Cost flow · model → project')).toBeInTheDocument()
fireEvent.click(screen.getByText('Today'))
@ -270,6 +301,25 @@ describe('App shortcuts', () => {
})
})
it('drives combined-scope overview fetches and persists the Scope setting', async () => {
render(<App />)
await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('30days', 'all'))
fireEvent.keyDown(document, { key: ',', metaKey: true })
fireEvent.click(await screen.findByLabelText('Scope'))
fireEvent.click(await screen.findByRole('option', { name: 'Combined' }))
// Combined scope forces provider='all' and passes --scope combined (6th arg).
await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('30days', 'all', undefined, undefined, undefined, 'combined'))
expect(localStorage.getItem('codeburn.scope')).toBe('combined')
})
it('boots in combined scope from the persisted Scope setting', async () => {
localStorage.setItem('codeburn.scope', 'combined')
render(<App />)
await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('30days', 'all', undefined, undefined, undefined, 'combined'))
})
it('builds the provider picker from providerDetails so display-name providers round-trip their internal id', async () => {
// grok's display name is "Grok Build"; the picker must show the label but
// send the internal id `grok` as --provider (which assertProvider accepts).
@ -389,7 +439,7 @@ describe('App shortcuts', () => {
it('applies a calendar range to overview and visible section polls', async () => {
render(<App />)
fireEvent.keyDown(document, { key: '3', metaKey: true })
fireEvent.keyDown(document, { key: '4', metaKey: true })
expect(await screen.findByText('Cost flow · model → project')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Choose date range' }))
@ -459,6 +509,48 @@ describe('App shortcuts', () => {
})
})
describe('win32 shortcut chords', () => {
beforeEach(() => {
installDefaultMocks()
localStorage.clear()
localStorage.setItem('codeburn.defaultPeriod', '30days')
document.documentElement.removeAttribute('data-theme')
setPlatform('win32')
})
afterEach(() => {
clearPlatform()
})
it('navigates with Ctrl+2 and refreshes with Ctrl+R', async () => {
render(<App />)
expect(await screen.findByText('Most expensive sessions')).toBeInTheDocument()
fireEvent.keyDown(document, { key: '2', ctrlKey: true })
expect(await screen.findByText('No sessions in this range yet.')).toBeInTheDocument()
const overviewCalls = mocks.getOverview.mock.calls.length
fireEvent.keyDown(document, { key: 'r', ctrlKey: true })
await waitFor(() => expect(mocks.getOverview.mock.calls.length).toBeGreaterThan(overviewCalls))
})
it('ignores Meta+2 on win32', async () => {
render(<App />)
expect(await screen.findByText('Most expensive sessions')).toBeInTheDocument()
fireEvent.keyDown(document, { key: '2', metaKey: true })
expect(screen.queryByText('No sessions in this range yet.')).not.toBeInTheDocument()
})
it('ignores Ctrl+Alt+2 (the AltGr shape) on win32', async () => {
render(<App />)
expect(await screen.findByText('Most expensive sessions')).toBeInTheDocument()
fireEvent.keyDown(document, { key: '2', ctrlKey: true, altKey: true })
expect(screen.queryByText('No sessions in this range yet.')).not.toBeInTheDocument()
})
})
describe('provider prefetch storm', () => {
const PROVIDERS = [
'claude', 'codex', 'gemini', 'grok', 'copilot', 'droid',
@ -596,6 +688,11 @@ describe('currency correctness', () => {
// independent of the app-wide default ('today').
localStorage.setItem('codeburn.defaultPeriod', '30days')
__resetPolledMemo()
setPlatform('darwin')
})
afterEach(() => {
clearPlatform()
})
it('never regresses the applied currency to a memo-served (stale) payload during a switch', async () => {

View file

@ -16,17 +16,19 @@ import { readDailyBudget } from './lib/budget'
import { formatCompact, formatUsd, setActiveCurrency } from './lib/format'
import { motionClass } from './lib/motion'
import { codeburn } from './lib/ipc'
import { isModifierChord, shortcutLabel } from './lib/platform'
import { localDateKey } from './lib/period'
import { persistRefreshValue, readRefreshValue, refreshValueToMs, RefreshCadenceContext, type RefreshCadence } from './lib/refreshCadence'
import { OverviewContent } from './sections/Overview'
import { OptimizeContent } from './sections/Optimize'
import { Models } from './sections/Models'
import { Sessions } from './sections/Sessions'
import { PullRequestsContent } from './sections/PullRequests'
import { Compare } from './sections/Compare'
import { Plans } from './sections/Plans'
import { Settings, type SettingsPane } from './sections/Settings'
import { SpendContent } from './sections/Spend'
import type { DateRange, MenubarPayload, ModelReportRow, Period, TelemetryStatus } from './lib/types'
import type { DateRange, MenubarPayload, ModelReportRow, Period, Scope, TelemetryStatus } from './lib/types'
// Bucket raw dollar amounts before they leave the machine: telemetry carries
// coarse ranges, never exact spend.
@ -106,6 +108,7 @@ export function usageSnapshotProps(payload: MenubarPayload, modelCategories?: Ma
const SECTION_TITLES: Record<Section, string> = {
overview: 'Overview',
sessions: 'Sessions',
pullRequests: 'Pull requests',
spend: 'Spend',
optimize: 'Optimize',
models: 'Models',
@ -128,8 +131,8 @@ const STANDARD_PERIODS: Period[] = ['today', 'week', '30days', 'month', 'all', '
// Instant-switch memo key for an overview result. Shared by the overview poll
// and the provider prefetcher so the two never drift out of sync. Exported so
// the prefetch-storm test can assert warmed keys survive between polls.
export function overviewMemoKey(provider: string, period: Period, range: DateRange | null, configSource: string | null): string {
return `overview|${provider}|${period}|${range?.from ?? ''}-${range?.to ?? ''}|${configSource ?? ''}`
export function overviewMemoKey(provider: string, period: Period, range: DateRange | null, configSource: string | null, scope: Scope = 'local'): string {
return `overview|${provider}|${period}|${range?.from ?? ''}-${range?.to ?? ''}|${configSource ?? ''}|${scope}`
}
// Prefetch pacing: wait a short idle after the first paint, then warm one
@ -170,6 +173,15 @@ function persistConfigSource(id: string | null): void {
} catch { /* storage can be unavailable */ }
}
/** Boot scope = the persisted dashboard Scope setting, else local. */
function initialScope(): Scope {
try { return globalThis.localStorage?.getItem('codeburn.scope') === 'combined' ? 'combined' : 'local' } catch { return 'local' }
}
function persistScope(scope: Scope): void {
try { globalThis.localStorage?.setItem('codeburn.scope', scope) } catch { /* storage can be unavailable */ }
}
function providerName(provider: string): string {
if (provider === 'all') return 'All providers'
return provider
@ -216,20 +228,27 @@ function AppMain() {
const [detectedProviders, setDetectedProviders] = useState<Array<{ id: string; label: string }>>([])
const [customRange, setCustomRange] = useState<DateRange | null>(null)
const [claudeConfigSource, setClaudeConfigSource] = useState<string | null>(initialConfigSource)
const [scope, setScopeState] = useState<Scope>(initialScope)
const [refreshToken, setRefreshToken] = useState(0)
const [now, setNow] = useState(() => Date.now())
const [, setCurrencyTick] = useState(0)
// Preserve the 2/3-arg call shapes when no config is scoped so the CLI argv
// stays flag-free; only add --claude-config-source once a config is picked.
// Combined scope aggregates paired-device usage; the CLI rejects it alongside
// a provider/config filter, so onScopeChange forces provider='all' and clears
// the config scope before this poll runs. Passing scope='local' produces the
// same flag-free argv as before, so local users are unaffected.
const overview = usePolled<MenubarPayload>(
() => claudeConfigSource
() => scope === 'combined'
? codeburn.getOverview(period, 'all', customRange ?? undefined, undefined, undefined, 'combined')
: claudeConfigSource
? codeburn.getOverview(period, provider, customRange ?? undefined, claudeConfigSource)
: customRange
? codeburn.getOverview(period, provider, customRange)
: codeburn.getOverview(period, provider),
[period, provider, customRange?.from, customRange?.to, claudeConfigSource],
{ memoKey: overviewMemoKey(provider, period, customRange, claudeConfigSource) },
[period, provider, customRange?.from, customRange?.to, claudeConfigSource, scope],
{ memoKey: overviewMemoKey(provider, period, customRange, claudeConfigSource, scope) },
)
const refreshOverview = overview.refresh
@ -271,7 +290,7 @@ function AppMain() {
// fails we still emit the snapshot, just without the model x category cross.
const snapshotDayRef = useRef<string | null>(null)
useEffect(() => {
if (!overview.data || provider !== 'all' || customRange || claudeConfigSource) return
if (!overview.data || provider !== 'all' || customRange || claudeConfigSource || scope !== 'local') return
const today = localDateKey(new Date())
if (snapshotDayRef.current === today) return
snapshotDayRef.current = today
@ -283,7 +302,7 @@ function AppMain() {
} catch { /* degrade: emit the snapshot without per-model topCategory */ }
trackEvent('usage_snapshot', usageSnapshotProps(payload, modelCategories))
})()
}, [overview.data, provider, customRange, claudeConfigSource, period, trackEvent])
}, [overview.data, provider, customRange, claudeConfigSource, scope, period, trackEvent])
useEffect(() => {
let saved: string | null = null
@ -358,7 +377,9 @@ function AppMain() {
overviewBusyRef.current = overview.loading
const warmedKeys = useRef<Set<string>>(new Set())
useEffect(() => {
if (!ready || overview.data == null || customRange || claudeConfigSource) return
// Combined scope has no provider picker to warm — it always shows unfiltered
// all-device usage — so the per-provider prefetch is local-scope only.
if (!ready || overview.data == null || customRange || claudeConfigSource || scope !== 'local') return
const targets = detectedProviders.map(entry => entry.id).filter(id => id !== provider)
if (targets.length === 0) return
let cancelled = false
@ -389,7 +410,7 @@ function AppMain() {
// `overview.data == null` (a boolean) gates on first-resolution without
// re-running every poll; the data content itself is intentionally not a dep.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ready, period, provider, customRange, claudeConfigSource, detectedProviders, overview.data == null])
}, [ready, period, provider, customRange, claudeConfigSource, scope, detectedProviders, overview.data == null])
useEffect(() => {
const id = window.setInterval(() => setNow(Date.now()), 1000)
@ -420,15 +441,16 @@ function AppMain() {
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (!event.metaKey || event.altKey || event.ctrlKey || event.shiftKey) return
if (!isModifierChord(event)) return
const key = event.key.toLowerCase()
if (key === '1') navigate('overview')
else if (key === '2') navigate('sessions')
else if (key === '3') navigate('spend')
else if (key === '4') navigate('optimize')
else if (key === '5') navigate('models')
else if (key === '6') navigate('compare')
else if (key === '7') navigate('plans')
else if (key === '3') navigate('pullRequests')
else if (key === '4') navigate('spend')
else if (key === '5') navigate('optimize')
else if (key === '6') navigate('models')
else if (key === '7') navigate('compare')
else if (key === '8') navigate('plans')
else if (key === ',') navigate('settings')
else if (key === 'r') refreshVisible()
else return
@ -448,17 +470,22 @@ function AppMain() {
// A Claude config scopes Claude usage only, so a non-Claude provider filter
// would make the CLI reject the flag: reset it to 'all' first (a 'claude'
// filter is already compatible and is left alone).
// filter is already compatible and is left alone). Picking a config also
// implies a device-specific view, so drop combined scope back to local.
const onConfigSelect = (id: string) => {
const next = id || null
if (next && provider !== 'all' && provider !== 'claude') setProvider('all')
if (next && scope === 'combined') { setScopeState('local'); persistScope('local') }
setClaudeConfigSource(next)
persistConfigSource(next)
}
// Symmetric direction: picking a non-Claude provider while a config is
// scoped would hit the same CLI rejection, so drop the config scope.
// scoped would hit the same CLI rejection, so drop the config scope. A
// specific provider filter is a device-specific view, so it also drops
// combined scope back to local (combined reports unfiltered usage).
const onProviderSelect = (value: string) => {
if (value !== 'all' && scope === 'combined') { setScopeState('local'); persistScope('local') }
if (claudeConfigSource && value !== 'all' && value !== 'claude') {
setClaudeConfigSource(null)
persistConfigSource(null)
@ -466,6 +493,19 @@ function AppMain() {
setProvider(value)
}
// Combined scope reports unfiltered, all-provider usage across paired devices,
// so switching to it resets the provider filter and Claude-config scope (which
// the CLI would otherwise reject), mirroring the menubar's setMenubarScope.
const onScopeChange = (value: string) => {
const next: Scope = value === 'combined' ? 'combined' : 'local'
if (next === 'combined') {
if (provider !== 'all') setProvider('all')
if (claudeConfigSource) { setClaudeConfigSource(null); persistConfigSource(null) }
}
setScopeState(next)
persistScope(next)
}
const claudeConfigs = overview.data?.claudeConfigs
const providerOptions = [
{ value: 'all', label: 'All providers' },
@ -475,7 +515,11 @@ function AppMain() {
const activeConfigLabel = claudeConfigSource
? claudeConfigs?.options.find(option => option.id === claudeConfigSource)?.label ?? null
: null
const scope = `${customRange ? rangeLabel(customRange) : PERIOD_LABELS[period]} · ${providerLabel}${activeConfigLabel ? ` · ${activeConfigLabel}` : ''}`
// Combined scope reports unfiltered all-device usage, so the caption reads
// "Combined" in place of the (forced-'all') provider label.
const scopeCaption = scope === 'combined'
? `${customRange ? rangeLabel(customRange) : PERIOD_LABELS[period]} · Combined`
: `${customRange ? rangeLabel(customRange) : PERIOD_LABELS[period]} · ${providerLabel}${activeConfigLabel ? ` · ${activeConfigLabel}` : ''}`
return (
<Window>
@ -491,12 +535,12 @@ function AppMain() {
{section === 'plans' ? (
<Plans period={period} refreshToken={refreshToken} onNavigate={navigate} ready={ready} />
) : section === 'settings' ? (
<Settings period={period} refreshToken={refreshToken} onNavigate={navigate} initialPane={settingsPane} claudeConfigs={claudeConfigs} claudeConfigSource={claudeConfigSource} onConfigMutated={onConfigMutated} />
<Settings period={period} refreshToken={refreshToken} onNavigate={navigate} initialPane={settingsPane} claudeConfigs={claudeConfigs} claudeConfigSource={claudeConfigSource} onConfigMutated={onConfigMutated} scope={scope} onScopeChange={onScopeChange} />
) : (
<>
<TopBar
title={SECTION_TITLES[section]}
scope={scope}
scope={scopeCaption}
period={period}
onPeriodChange={onPeriodChange}
customRange={customRange}
@ -511,9 +555,11 @@ function AppMain() {
/>
<div className={motionClass('body', 'section-fade')}>
{section === 'overview' ? (
<OverviewContent period={period} provider={provider} range={customRange} overview={overview} onNavigate={navigate} ready={ready} />
<OverviewContent period={period} provider={provider} range={customRange} overview={overview} onNavigate={navigate} ready={ready} scope={scope} />
) : section === 'sessions' ? (
<Sessions period={period} provider={provider} range={customRange} refreshToken={refreshToken} detectedProviders={detectedProviders} onProviderChange={onProviderSelect} ready={ready} />
) : section === 'pullRequests' ? (
<PullRequestsContent overview={overview} />
) : section === 'spend' ? (
<SpendContent period={period} provider={provider} range={customRange} overview={overview} refreshToken={refreshToken} ready={ready} />
) : section === 'optimize' ? (
@ -532,9 +578,9 @@ function AppMain() {
{section !== 'settings' && (
<Hint
items={[
{ k: '⌘1-7', label: 'Navigate' },
{ k: '⌘,', label: 'Settings' },
{ k: '⌘R', label: 'Refresh' },
{ k: shortcutLabel('1-8'), label: 'Navigate' },
{ k: shortcutLabel(','), label: 'Settings' },
{ k: shortcutLabel('R'), label: 'Refresh' },
]}
right={refreshedLabel(overview.lastSuccessAt, overview.loading, now)}
/>

View file

@ -1,17 +1,31 @@
// @vitest-environment jsdom
import { describe, it, expect, vi } from 'vitest'
import { afterEach, describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { Sidebar } from './Sidebar'
function setPlatform(platform: string): void {
;(window as unknown as { codeburn?: { platform?: string } }).codeburn = { platform }
}
describe('Sidebar', () => {
it('renders all eight nav items in the desktop order', () => {
afterEach(() => {
delete (window as unknown as { codeburn?: { platform?: string } }).codeburn
})
it.each([
['darwin', '⌘'],
['win32', 'Ctrl+'],
] as const)('renders all nine nav items in the desktop order with %s keycaps', (platform, mod) => {
setPlatform(platform)
render(<Sidebar active="overview" onNavigate={() => {}} />)
const labels = screen.getAllByRole('button').map(item => item.textContent?.replace(/⌘[\d,]/, ''))
expect(labels).toEqual(['Overview', 'Sessions', 'Spend', 'Optimize', 'Models', 'Compare', 'Plans', 'Settings'])
expect(screen.getByRole('button', { name: /Sessions.*⌘2/ })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Compare.*⌘6/ })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Plans.*⌘7/ })).toBeInTheDocument()
const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const labels = screen.getAllByRole('button').map(item => item.textContent?.replace(/(⌘|Ctrl\+)[\d,]/, ''))
expect(labels).toEqual(['Overview', 'Sessions', 'Pull requests', 'Spend', 'Optimize', 'Models', 'Compare', 'Plans', 'Settings'])
expect(screen.getByRole('button', { name: new RegExp(`Sessions.*${esc(mod)}2`) })).toBeInTheDocument()
expect(screen.getByRole('button', { name: new RegExp(`Pull requests.*${esc(mod)}3`) })).toBeInTheDocument()
expect(screen.getByRole('button', { name: new RegExp(`Compare.*${esc(mod)}7`) })).toBeInTheDocument()
expect(screen.getByRole('button', { name: new RegExp(`Plans.*${esc(mod)}8`) })).toBeInTheDocument()
})
it('calls onNavigate with the section id when a nav item is clicked', () => {

View file

@ -1,34 +1,38 @@
import { useState, type ReactNode } from 'react'
import { codeburn } from '../lib/ipc'
import { shortcutLabel } from '../lib/platform'
import { AboutModal, type SocialLink } from './AboutModal'
import { FlameMark } from './FlameMark'
export type Section = 'overview' | 'sessions' | 'spend' | 'optimize' | 'models' | 'compare' | 'plans' | 'settings'
export type Section = 'overview' | 'sessions' | 'pullRequests' | 'spend' | 'optimize' | 'models' | 'compare' | 'plans' | 'settings'
export const NAV_ITEMS: Array<{ id: Section; label: string; key: string; icon: ReactNode }> = [
{ id: 'overview', label: 'Overview', key: '1', icon: (
{ id: 'overview', label: 'Overview', key: '1', icon: (
<svg viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="9" rx="1" /><rect x="14" y="3" width="7" height="5" rx="1" /><rect x="14" y="12" width="7" height="9" rx="1" /><rect x="3" y="16" width="7" height="5" rx="1" /></svg>
) },
{ id: 'sessions', label: 'Sessions', key: '2', icon: (
{ id: 'sessions', label: 'Sessions', key: '2', icon: (
<svg viewBox="0 0 24 24"><rect x="4" y="4" width="16" height="4" rx="1"/><rect x="4" y="10" width="16" height="4" rx="1"/><rect x="4" y="16" width="16" height="4" rx="1"/></svg>
) },
{ id: 'spend', label: 'Spend', key: '⌘3', icon: (
{ id: 'pullRequests', label: 'Pull requests', key: '3', icon: (
<svg viewBox="0 0 24 24"><circle cx="6" cy="6" r="3"/><circle cx="18" cy="18" r="3"/><path d="M13 6h3a2 2 0 0 1 2 2v7"/><line x1="6" y1="9" x2="6" y2="21"/></svg>
) },
{ id: 'spend', label: 'Spend', key: '4', icon: (
<svg viewBox="0 0 24 24"><line x1="6" y1="20" x2="6" y2="13" /><line x1="12" y1="20" x2="12" y2="4" /><line x1="18" y1="20" x2="18" y2="9" /></svg>
) },
{ id: 'optimize', label: 'Optimize', key: '⌘4', icon: (
{ id: 'optimize', label: 'Optimize', key: '5', icon: (
<svg viewBox="0 0 24 24"><circle cx="10.5" cy="10.5" r="3.4"/><path d="M10.5 3v1.7M10.5 16.3V18M3 10.5h1.7M16.3 10.5H18M5.3 5.3l1.2 1.2M14.5 14.5l1.2 1.2M15.7 5.3l-1.2 1.2M6.5 14.5l-1.2 1.2"/><line x1="15.5" y1="15.5" x2="20" y2="20"/></svg>
) },
{ id: 'models', label: 'Models', key: '⌘5', icon: (
{ id: 'models', label: 'Models', key: '6', icon: (
<svg viewBox="0 0 24 24"><path d="M21 16V8a2 2 0 0 0-1-1.7l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.7l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" /><path d="M3.3 7 12 12l8.7-5M12 22V12" /></svg>
) },
{ id: 'compare', label: 'Compare', key: '⌘6', icon: (
{ id: 'compare', label: 'Compare', key: '7', icon: (
<svg viewBox="0 0 24 24"><path d="M8 3 4 7l4 4"/><path d="M4 7h16"/><path d="M16 21l4-4-4-4"/><path d="M20 17H4"/></svg>
) },
{ id: 'plans', label: 'Plans', key: '⌘7', icon: (
{ id: 'plans', label: 'Plans', key: '8', icon: (
<svg viewBox="0 0 24 24"><rect x="2" y="5" width="20" height="14" rx="2" /><line x1="2" y1="10" x2="22" y2="10" /></svg>
) },
{ id: 'settings', label: 'Settings', key: ',', icon: (
{ id: 'settings', label: 'Settings', key: ',', icon: (
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3" /><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" /></svg>
) },
]
@ -72,7 +76,7 @@ export function Sidebar({
>
{item.icon}
{item.label}
<span className="k">{item.key}</span>
<span className="k">{shortcutLabel(item.key)}</span>
</div>
))}
<div className="push" />

View file

@ -4,7 +4,7 @@ vi.mock('../lib/ipc', () => ({
codeburn: { platform: 'linux', arch: 'x64' },
}))
import { directDownloadUrl, releasePageUrl, updateDownloadUrl } from './useUpdateStatus'
import { directDownloadUrl, MICROSOFT_STORE_URL, releasePageUrl, updateDownloadUrl } from './useUpdateStatus'
const TAG = 'desktop-v0.9.19'
const BASE = 'https://github.com/getagentseal/codeburn/releases/download/desktop-v0.9.19'
@ -18,9 +18,9 @@ describe('directDownloadUrl', () => {
expect(directDownloadUrl(TAG, 'darwin', 'x64')).toBe(`${BASE}/CodeBurn-0.9.19.dmg`)
})
it('maps Windows to the Setup exe regardless of arch', () => {
expect(directDownloadUrl(TAG, 'win32', 'x64')).toBe(`${BASE}/CodeBurn-Setup-0.9.19.exe`)
expect(directDownloadUrl(TAG, 'win32', undefined)).toBe(`${BASE}/CodeBurn-Setup-0.9.19.exe`)
it('maps Windows to the official Microsoft Store regardless of arch', () => {
expect(directDownloadUrl(TAG, 'win32', 'x64')).toBe(MICROSOFT_STORE_URL)
expect(directDownloadUrl(TAG, 'win32', undefined)).toBe(MICROSOFT_STORE_URL)
})
it('returns null for Linux (three formats, the user picks on the page)', () => {

View file

@ -30,12 +30,16 @@ export function releasePageUrl(tag: string): string {
return `https://github.com/getagentseal/codeburn/releases/tag/${tag}`
}
/** Official signed Windows distribution. The Store handles installation and
* updates, avoiding the unsigned GitHub installer and SmartScreen warning. */
export const MICROSOFT_STORE_URL = 'https://apps.microsoft.com/detail/9P0R4ZL5XMB8'
/**
* Direct asset download for the running platform, so Download saves the file
* instead of landing on a GitHub page. Filenames mirror the electron-builder
* output (app/DISTRIBUTION.md) and are version-derived from the tag. Returns
* null (callers fall back to the release page) for Linux three formats, the
* user picks and for unknown platforms or a preload without `arch`.
* Preferred install target for the running platform. macOS downloads the
* matching release asset; Windows opens the signed Microsoft Store listing.
* Returns null (callers fall back to the release page) for Linux three
* formats, the user picks and for unknown platforms or a preload without
* `arch`.
*/
export function directDownloadUrl(tag: string, platform: string | undefined, arch: string | undefined): string | null {
const version = tag.startsWith('desktop-v') ? tag.slice('desktop-v'.length) : null
@ -45,7 +49,7 @@ export function directDownloadUrl(tag: string, platform: string | undefined, arc
if (!arch) return null
file = arch === 'arm64' ? `CodeBurn-${version}-arm64.dmg` : `CodeBurn-${version}.dmg`
} else if (platform === 'win32') {
file = `CodeBurn-Setup-${version}.exe`
return MICROSOFT_STORE_URL
}
if (!file) return null
return `https://github.com/getagentseal/codeburn/releases/download/${tag}/${file}`

View file

@ -0,0 +1,46 @@
// Single source of truth for platform-aware shortcut behaviour. The preload
// exposes `window.codeburn.platform` (process.platform); when the bridge is
// absent (unit tests, vite in a plain browser) fall back to the user agent.
// All functions read platform state at call time, never at module load, so
// the preload bridge may appear after this module is imported.
function bridgePlatform(): string | undefined {
if (typeof window === 'undefined') return undefined
return (window as unknown as { codeburn?: { platform?: string } }).codeburn?.platform
}
function userAgentPlatform(): string | undefined {
if (typeof navigator === 'undefined') return undefined
if (/mac/i.test(navigator.userAgent)) return 'darwin'
const platform = navigator.platform
if (typeof platform === 'string' && /mac/i.test(platform)) return 'darwin'
return undefined
}
/** True when the Electron preload reports darwin (or the UA matches a Mac). */
export function isMacPlatform(): boolean {
const platform = bridgePlatform()
if (platform) return platform === 'darwin'
return userAgentPlatform() === 'darwin'
}
/** The modifier keycap label: '⌘' on mac, 'Ctrl+' elsewhere. */
export function modKeyLabel(): string {
return isMacPlatform() ? '⌘' : 'Ctrl+'
}
/** A full shortcut label, e.g. '⌘R' on mac, 'Ctrl+R' on Windows. */
export function shortcutLabel(key: string): string {
return modKeyLabel() + key
}
/**
* True when the event is the platform's modifier chord and no other modifier
* is held. On mac: Meta (Cmd) without Ctrl. Elsewhere: Ctrl without Meta.
* altKey stays rejected on every platform: AltGr on European layouts arrives
* as Ctrl+Alt, and Ctrl+Alt+<key> must not hijack a typed character.
*/
export function isModifierChord(event: { metaKey: boolean; ctrlKey: boolean; altKey: boolean; shiftKey: boolean }): boolean {
if (event.altKey || event.shiftKey) return false
return isMacPlatform() ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey
}

View file

@ -6,6 +6,10 @@
export type Period = 'today' | 'week' | '30days' | 'month' | 'all' | 'lifetime'
// Dashboard usage scope: this device only ('local') or the aggregate across
// every paired device ('combined'). Mirrors the macOS menubar's Scope setting.
export type Scope = 'local' | 'combined'
export type DateRange = { from: string; to: string }
export type CliErrorKind = 'not-found' | 'nonzero' | 'bad-json' | 'timeout' | 'too-large' | 'bad-args'
@ -178,6 +182,20 @@ export type MenubarPayload = {
calls: number
date: string
}>
// Workflow-intelligence rollups (src/menubar-json.ts buildWorkflow /
// buildTopReworkedFiles). Optional: older CLIs omit them, so the Overview
// workflow card renders only when they are present with real signal.
workflow?: {
corrections: number
correctionRate: number | null
medianTimeToFirstEditMs: number | null
}
// Files most reworked by edit-family calls, basename-only, ranked by
// distinct sessions then edits (src/menubar-json.ts buildTopReworkedFiles).
topReworkedFiles?: Array<{ path: string; sessions: number; edits: number }>
// Share (0-1) of cost-bearing calls that resolved a price. Below 1 means some
// usage priced against no table entry; null when not computable.
pricingCoverage?: number | null
retryTax: {
totalUSD: number
retries: number
@ -206,6 +224,37 @@ export type MenubarPayload = {
skills: Array<{ name: string; turns: number; cost: number }>
subagents: Array<{ name: string; calls: number; cost: number }>
mcpServers: Array<{ name: string; calls: number }>
// Spend by referenced pull request (every PR, cost-descending), attributed at turn
// granularity. Optional: older CLIs omit it, and it is absent when no PR links
// were observed. Rows carry attributed cost/calls and ARE summable;
// `attributedCost + unattributedCost === distinctCost`. `approx` marks a row
// fed by the legacy whole-session even split (transcript expired). `models` is
// the short model names that processed the PR (cost-desc); `categories` is the
// per-task-category attributed cost (cost-desc), omitted for legacy rows.
// `attributedCost`/`unattributedCost` are optional so a payload from an older
// CLI (by-reference rows, not summable) still type-checks and can be detected.
pullRequests?: {
rows: Array<{
url: string
label: string
cost: number
savingsUSD: number
sessions: number
calls: number
firstStarted: string
lastEnded: string
approx?: boolean
models?: string[]
categories?: Array<{ name: string; cost: number }>
}>
distinctCost: number
distinctSessions: number
// Count of subagent (sidechain) runs folded into the PR-linked parent
// sessions. Optional (absent when none folded, or from an older producer).
subagentSessions?: number
attributedCost?: number
unattributedCost?: number
}
}
optimize: {
findingCount: number
@ -421,6 +470,10 @@ export type ActReportJson = {
// ————— src/sessions-report.ts —————
export type SessionRow = {
sessionId: string
// Captured human title (src/sessions-report.ts). Empty string when the
// transcript produced none; optional so older CLIs that predate the field
// render unchanged (the row falls back to the project as its primary label).
title?: string
project: string
provider: string
models: string[]
@ -588,7 +641,9 @@ export interface CodeburnBridge {
getQuota(force?: boolean): Promise<QuotaProvider[]>
// `background` (prefetch only) requests background CLI-spawn priority; optional
// so an older preload that ignores it degrades to interactive priority.
getOverview(period: Period, provider: string, range?: DateRange, configSource?: string | null, background?: boolean): Promise<MenubarPayload>
// `scope` selects local-device usage ('local', default) or paired-device
// aggregate ('combined'); optional so an older preload degrades to local.
getOverview(period: Period, provider: string, range?: DateRange, configSource?: string | null, background?: boolean, scope?: string): Promise<MenubarPayload>
getPlans(period: Period): Promise<StatusJson>
getActReport(): Promise<ActReportJson>
readonly platform: string

View file

@ -520,6 +520,49 @@ describe('Overview', () => {
expect(screen.queryByText('Saved to date')).not.toBeInTheDocument()
})
it('shows paired-device aggregate totals in the hero under combined scope', async () => {
const now = new Date()
const payload = makePayload(now)
// Local device: $312.40 / 4200 calls / 88 sessions (from makePayload).
// Combined swaps the hero to the cross-device aggregate and lists devices.
payload.combined = {
perDevice: [
{ id: 'local', name: 'laptop', local: true, cost: 312.4, calls: 4200, sessions: 88, inputTokens: 0, outputTokens: 0, cacheCreateTokens: 0, cacheReadTokens: 0, totalTokens: 0 },
{ id: 'fp-workstation', name: 'workstation', local: false, cost: 187.6, calls: 2100, sessions: 40, inputTokens: 0, outputTokens: 0, cacheCreateTokens: 0, cacheReadTokens: 0, totalTokens: 0 },
],
combined: { cost: 500, calls: 6300, sessions: 128, inputTokens: 0, outputTokens: 0, cacheCreateTokens: 0, cacheReadTokens: 0, totalTokens: 0, deviceCount: 2, reachableCount: 2 },
}
const { container } = render(<OverviewContent period="30days" provider="all" overview={polled(payload)} scope="combined" />)
const kpis = container.querySelector('.ov-hero-main') as HTMLElement
// Hero cost is the combined $500, not the local $312.40.
expect(within(kpis).getByText('$500.00')).toBeInTheDocument()
expect(within(kpis).getByText(/6,300 calls · 128 sessions/)).toBeInTheDocument()
expect(within(kpis).getByText('Combined · Last 30 days')).toBeInTheDocument()
expect(within(kpis).getByText('2 of 2 devices')).toBeInTheDocument()
expect(within(kpis).getByText('workstation')).toBeInTheDocument()
expect(within(kpis).getByText('laptop · this device')).toBeInTheDocument()
// Combined mode hides the local savings lines (they are device-specific).
expect(within(kpis).queryByText('Saved via local models')).not.toBeInTheDocument()
})
it('keeps local hero totals when scope is local even if a combined payload is present', async () => {
const now = new Date()
const payload = makePayload(now)
payload.combined = {
perDevice: [],
combined: { cost: 999, calls: 1, sessions: 1, inputTokens: 0, outputTokens: 0, cacheCreateTokens: 0, cacheReadTokens: 0, totalTokens: 0, deviceCount: 2, reachableCount: 2 },
}
const { container } = render(<OverviewContent period="30days" provider="all" overview={polled(payload)} scope="local" />)
const kpis = container.querySelector('.ov-hero-main') as HTMLElement
expect(within(kpis).getByText('$312.40')).toBeInTheDocument()
expect(within(kpis).queryByText('$999.00')).not.toBeInTheDocument()
expect(within(kpis).queryByText(/devices/)).not.toBeInTheDocument()
})
it('shows a stale banner when last-good data is present but the latest poll failed', async () => {
const now = new Date()
const overview: Polled<MenubarPayload> = {
@ -700,3 +743,113 @@ describe('Overview', () => {
expect(within(outcome).getByText('€36.00')).toBeInTheDocument()
})
})
type WorkflowOverrides = {
workflow?: MenubarPayload['current']['workflow']
topReworkedFiles?: MenubarPayload['current']['topReworkedFiles']
pricingCoverage?: MenubarPayload['current']['pricingCoverage']
}
function workflowPayload(now: Date, over: WorkflowOverrides): MenubarPayload {
const base = makePayload(now)
return { ...base, current: { ...base.current, ...over } }
}
describe('Overview workflow card', () => {
beforeEach(() => {
setActiveCurrency({ code: 'USD', symbol: '$', rate: 1 })
getOverview.mockReset()
getActReport.mockReset()
getYield.mockReset()
getActReport.mockResolvedValue({ totals: { realizedCostUSD: 0, measuredActions: 0 } })
getYield.mockResolvedValue(makeYieldReport())
})
afterEach(() => vi.useRealTimers())
function workflowRegion(): HTMLElement {
return screen.getByRole('heading', { name: 'Workflow' }).closest('.ov-workflow-widget') as HTMLElement
}
it('renders correction rate, time to first edit, top rework, coverage chip, and a coaching note', async () => {
const now = new Date()
getOverview.mockResolvedValue(workflowPayload(now, {
workflow: { corrections: 7, correctionRate: 0.2, medianTimeToFirstEditMs: 45_000 },
topReworkedFiles: [{ path: 'parser.ts', sessions: 4, edits: 12 }],
pricingCoverage: 0.92,
}))
render(<Overview period="30days" provider="all" />)
const card = await waitFor(() => workflowRegion())
expect(within(card).getByText('Correction rate')).toBeInTheDocument()
expect(within(card).getByText('20%')).toBeInTheDocument()
expect(within(card).getByText('7 corrections')).toBeInTheDocument()
expect(within(card).getByText('Time to first edit')).toBeInTheDocument()
// Under 60s renders as seconds, not minutes.
expect(within(card).getByText('45s')).toBeInTheDocument()
expect(within(card).getByText(/Top rework:/)).toHaveTextContent('Top rework: parser.ts · 4 sessions · 12 edits')
// pricingCoverage 0.92 → a "92% priced" caveat chip.
expect(within(card).getByText('92% priced')).toBeInTheDocument()
// Corrections clears its bar first, so its coaching line wins.
expect(within(card).getByText(/You corrected the assistant on 20% of prompts \(7 times\)/)).toBeInTheDocument()
})
it('does not render at all when the payload carries no workflow signal', async () => {
const now = new Date()
// makePayload omits workflow/topReworkedFiles/pricingCoverage entirely.
getOverview.mockResolvedValue(makePayload(now))
render(<Overview period="30days" provider="all" />)
await screen.findByText('$312.40')
expect(screen.queryByRole('heading', { name: 'Workflow' })).not.toBeInTheDocument()
})
it('stays hidden when workflow exists but every metric is empty', async () => {
const now = new Date()
getOverview.mockResolvedValue(workflowPayload(now, {
workflow: { corrections: 0, correctionRate: null, medianTimeToFirstEditMs: null },
topReworkedFiles: [],
pricingCoverage: 1,
}))
render(<Overview period="30days" provider="all" />)
await screen.findByText('$312.40')
expect(screen.queryByRole('heading', { name: 'Workflow' })).not.toBeInTheDocument()
})
it('picks the churn note and formats minutes when corrections are below the bar', async () => {
const now = new Date()
getOverview.mockResolvedValue(workflowPayload(now, {
workflow: { corrections: 1, correctionRate: 0.05, medianTimeToFirstEditMs: 8 * 60 * 1000 },
topReworkedFiles: [{ path: 'router.ts', sessions: 5, edits: 30 }],
pricingCoverage: null,
}))
render(<Overview period="30days" provider="all" />)
const card = await waitFor(() => workflowRegion())
// >= 60s renders as whole minutes.
expect(within(card).getByText('8m')).toBeInTheDocument()
// Corrections (5%) is below 0.15, so the churn note wins over TTFE.
expect(within(card).getByText(/router\.ts was reworked across 5 sessions \(30 edits\)/)).toBeInTheDocument()
// pricingCoverage null → no chip.
expect(within(card).queryByText(/priced/)).not.toBeInTheDocument()
})
it('falls back to a neutral caption and hides the chip at full coverage when no note fires', async () => {
const now = new Date()
getOverview.mockResolvedValue(workflowPayload(now, {
workflow: { corrections: 1, correctionRate: 0.05, medianTimeToFirstEditMs: 30_000 },
topReworkedFiles: [{ path: 'small.ts', sessions: 1, edits: 2 }],
pricingCoverage: 1,
}))
render(<Overview period="30days" provider="all" />)
const card = await waitFor(() => workflowRegion())
expect(within(card).getByText('Corrections, first-edit latency, and file churn across your sessions.')).toBeInTheDocument()
expect(within(card).queryByText(/priced/)).not.toBeInTheDocument()
})
})

View file

@ -15,10 +15,12 @@ import { codeburn } from '../lib/ipc'
import { contiguousDailyWindow, dataStartKey, formatChartDate, localDateKey, sliceDailyToPeriod, sliceDailyToRange } from '../lib/period'
import type {
ActReportJson,
CombinedUsage,
DailyHistoryEntry,
DateRange,
MenubarPayload,
Period,
Scope,
YieldJsonReport,
} from '../lib/types'
@ -129,6 +131,88 @@ function CostPerOutcome({ outcome }: { outcome: Polled<YieldJsonReport> }) {
)
}
// Coaching-note thresholds, mirrored from the CLI so the card and the CLI never
// disagree (src/workflow-insights.ts buildCoachingNotes).
const WORKFLOW_CORRECTION_RATE = 0.15
const WORKFLOW_CORRECTION_COUNT = 3
const WORKFLOW_CHURN_SESSIONS = 3
const WORKFLOW_TTFE_SLOW_MS = 5 * 60 * 1000
/** Median time to first edit: `<60s → Ns`, else `Nm` (src/workflow-insights.ts formatDurationShort). */
function formatWorkflowDuration(ms: number): string {
if (ms >= 60_000) return `${Math.round(ms / 60_000)}m`
return `${Math.round(ms / 1000)}s`
}
type WorkflowRollup = NonNullable<MenubarPayload['current']['workflow']>
type ReworkedFile = { path: string; sessions: number; edits: number }
/**
* One coaching line derived with the CLI's thresholds and dry copy voice
* (src/workflow-insights.ts buildCoachingNotes): corrections, then file churn,
* then time-to-first-edit; the first that fires. Null when none clears its bar.
*/
function workflowCoachingNote(workflow: WorkflowRollup, topReworked?: ReworkedFile): string | null {
const { correctionRate, corrections, medianTimeToFirstEditMs } = workflow
if (correctionRate !== null && correctionRate >= WORKFLOW_CORRECTION_RATE && corrections >= WORKFLOW_CORRECTION_COUNT) {
return `You corrected the assistant on ${Math.round(correctionRate * 100)}% of prompts (${corrections} times). State the requirements in the first message to cut the back and forth.`
}
if (topReworked && topReworked.sessions >= WORKFLOW_CHURN_SESSIONS) {
return `${topReworked.path} was reworked across ${topReworked.sessions} sessions (${topReworked.edits} edits). A focused pass on it may cost less than the repeated churn.`
}
if (medianTimeToFirstEditMs !== null && medianTimeToFirstEditMs >= WORKFLOW_TTFE_SLOW_MS) {
return `Median time to first edit is ${formatWorkflowDuration(medianTimeToFirstEditMs)}. Point the assistant at the target file to cut the exploration before it starts editing.`
}
return null
}
function WorkflowCard({ current }: { current: MenubarPayload['current'] }) {
const workflow = current.workflow
const topReworked = current.topReworkedFiles?.[0]
// Hide when there is no real signal: never show a card of zeros.
const hasSignal = !!workflow && (
workflow.correctionRate !== null ||
workflow.medianTimeToFirstEditMs !== null ||
workflow.corrections > 0 ||
!!topReworked
)
if (!workflow || !hasSignal) return null
const coverage = current.pricingCoverage
const showCoverage = typeof coverage === 'number' && coverage < 1
const note = workflowCoachingNote(workflow, topReworked)
const { correctionRate, corrections, medianTimeToFirstEditMs } = workflow
return (
<div className="ov-card ov-panel ov-workflow-widget">
<div className="ov-panel-head">
<h3>Workflow</h3>
{showCoverage && <span className="ov-priced-chip">{Math.min(99, Math.round(coverage * 100))}% priced</span>}
</div>
<div className="ov-panel-body">
<div className="ov-outcome-metrics">
<div>
<span>Correction rate</span>
<strong>{correctionRate === null ? '—' : `${Math.round(correctionRate * 100)}%`}</strong>
{correctionRate !== null && <span>{corrections} {corrections === 1 ? 'correction' : 'corrections'}</span>}
</div>
<div>
<span>Time to first edit</span>
<strong>{medianTimeToFirstEditMs === null ? '—' : formatWorkflowDuration(medianTimeToFirstEditMs)}</strong>
<span>median</span>
</div>
</div>
{topReworked && (
<div className="ov-workflow-rework">
Top rework: <strong>{topReworked.path}</strong> · {topReworked.sessions} {topReworked.sessions === 1 ? 'session' : 'sessions'} · {topReworked.edits} {topReworked.edits === 1 ? 'edit' : 'edits'}
</div>
)}
<p className="ov-widget-caption">{note ?? 'Corrections, first-edit latency, and file churn across your sessions.'}</p>
</div>
</div>
)
}
export type Signal = { text: string; trailing?: string }
export type SignalGroups = { wins: Signal[]; improvements: Signal[]; risks: Signal[] }
@ -568,6 +652,23 @@ export function Overview({ period, provider }: { period: Period; provider: strin
return <OverviewContent period={period} provider={provider} overview={overview} />
}
/** Combined-scope hero footer: a per-device cost breakdown plus a reachable/
* total device count, mirroring the menubar's combined view. An unreachable
* device (powered off, off-network) shows its error in place of a cost. */
function CombinedDevices({ usage }: { usage: CombinedUsage }) {
return (
<div className="ov-combined-devices">
<div className="ov-combined-head">{usage.combined.reachableCount} of {usage.combined.deviceCount} devices</div>
{usage.perDevice.map(device => (
<div className={device.error ? 'ov-combined-row err' : 'ov-combined-row'} key={device.id}>
<span className="ov-combined-name">{device.local ? `${device.name} · this device` : device.name}</span>
<span className="ov-combined-val">{device.error ?? formatUsd(device.cost)}</span>
</div>
))}
</div>
)
}
export function OverviewContent({
period,
provider = 'all',
@ -575,6 +676,7 @@ export function OverviewContent({
overview,
onNavigate,
ready = true,
scope = 'local',
}: {
period: Period
provider?: string
@ -582,6 +684,7 @@ export function OverviewContent({
overview: Polled<MenubarPayload>
onNavigate?: (section: 'optimize' | 'sessions') => void
ready?: boolean
scope?: Scope
}) {
// Gate secondary spawns on the app-level readiness (first overview resolved),
// so the cold hydration runs once (via overview) rather than 3 parses at once
@ -598,7 +701,14 @@ export function OverviewContent({
const now = new Date()
const rangeActive = !!range
const animateKey = `${period}|${provider}|${range?.from ?? ''}|${range?.to ?? ''}`
// Combined scope shows the paired-device aggregate in the hero KPIs, mirroring
// the menubar. Only the hero totals are aggregated; the detailed panels below
// (daily chart, models) stay local — the combined payload carries totals only.
const combined = scope === 'combined' ? data.combined : undefined
const heroCost = combined ? combined.combined.cost : data.current.cost
const heroCalls = combined ? combined.combined.calls : data.current.calls
const heroSessions = combined ? combined.combined.sessions : data.current.sessions
const animateKey = `${period}|${provider}|${range?.from ?? ''}|${range?.to ?? ''}|${scope}`
const stats = deriveStats(data, now)
const periodDaily = sliceDailyToPeriod(data.history.daily, period, now)
// Daily chart: contiguous zero-filled calendar window. A custom range spans
@ -633,15 +743,21 @@ export function OverviewContent({
{error && <StaleBanner error={error} />}
<div className="ov-card ov-hero-split" aria-label="Key performance indicators">
<div className="ov-hero-main">
<div className="ov-hero-top"><span className="ov-label">{data.current.label}</span><span className="ov-streak"><b>{streakDays(data.history.daily, now)}</b>-day streak</span></div>
<CountUp value={data.current.cost} animateKey={animateKey} />
<div className="ov-hero-sub">{data.current.calls.toLocaleString('en-US')} calls · {data.current.sessions.toLocaleString('en-US')} sessions</div>
{saved > 0 && (
<div className="ov-saved-line"><span>Saved by applied fixes</span><strong>{formatUsd(saved)}</strong><small>across {applied} {applied === 1 ? 'fix' : 'fixes'}</small></div>
)}
{localSaved > 0 && (
<div className="ov-saved-line"><span>Saved via local models</span><strong>{formatUsd(localSaved)}</strong><small>local-model routing</small></div>
)}
<div className="ov-hero-top"><span className="ov-label">{combined ? `Combined · ${data.current.label}` : data.current.label}</span><span className="ov-streak"><b>{streakDays(data.history.daily, now)}</b>-day streak</span></div>
<CountUp value={heroCost} animateKey={animateKey} />
<div className="ov-hero-sub">{heroCalls.toLocaleString('en-US')} calls · {heroSessions.toLocaleString('en-US')} sessions</div>
{combined
? <CombinedDevices usage={combined} />
: (
<>
{saved > 0 && (
<div className="ov-saved-line"><span>Saved by applied fixes</span><strong>{formatUsd(saved)}</strong><small>across {applied} {applied === 1 ? 'fix' : 'fixes'}</small></div>
)}
{localSaved > 0 && (
<div className="ov-saved-line"><span>Saved via local models</span><strong>{formatUsd(localSaved)}</strong><small>local-model routing</small></div>
)}
</>
)}
</div>
<ActivityHeatmap daily={data.history.daily} bare />
<EfficiencyScorecard current={data.current} bare />
@ -659,6 +775,8 @@ export function OverviewContent({
<div className="ov-panel-body">{data.history.daily.length ? <DailyChart daily={chartDaily} dataStart={dataStartKey(data.history.daily)} animateKey={animateKey} /> : <EmptyNote>No spend yet.</EmptyNote>}</div>
</div>
<WorkflowCard current={data.current} />
<div className="ov-insight-band">
<div className="ov-coach">
<svg viewBox="0 0 24 24" aria-hidden="true"><polyline points="3 17 9 11 13 15 21 7"/><polyline points="15 7 21 7 21 13"/></svg>

View file

@ -0,0 +1,298 @@
// @vitest-environment jsdom
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { formatDayShort } from '../lib/format'
import type { MenubarPayload } from '../lib/types'
import { PullRequests } from './PullRequests'
type PrPayload = NonNullable<MenubarPayload['current']['pullRequests']>
const { getOverview, openExternal } = vi.hoisted(() => ({
getOverview: vi.fn<(period: string, provider: string) => Promise<MenubarPayload>>(),
openExternal: vi.fn<(url: string) => Promise<void>>(),
}))
vi.mock('../lib/ipc', async orig => {
const actual = await orig<typeof import('../lib/ipc')>()
return { ...actual, codeburn: { getOverview, openExternal } }
})
// Mirror the component's span rule so the assertion stays timezone-safe.
function expectedSpan(first: string, last: string): string {
const start = formatDayShort(first)
const end = formatDayShort(last)
return start === end ? start : `${start} - ${end}`
}
function makePayload(pullRequests?: PrPayload): MenubarPayload {
return {
generated: '2026-07-20T00:00:00Z',
current: {
label: 'Lifetime', cost: 0, calls: 0, sessions: 0, oneShotRate: null,
inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, cacheHitPercent: 0,
codexCredits: 0, topActivities: [], topModels: [],
localModelSavings: { totalUSD: 0, calls: 0, byModel: [], byProvider: [] },
providers: {}, topProjects: [], modelEfficiency: [], topSessions: [],
retryTax: { totalUSD: 0, retries: 0, editTurns: 0, byModel: [] },
routingWaste: { totalSavingsUSD: 0, baselineModel: '', baselineCostPerEdit: 0, byModel: [] },
tools: [], skills: [], subagents: [], mcpServers: [],
...(pullRequests ? { pullRequests } : {}),
},
optimize: { findingCount: 0, savingsUSD: 0, topFindings: [] },
history: { daily: [] },
}
}
const SAMPLE: PrPayload = {
rows: [
{ url: 'https://github.com/getagentseal/codeburn/pull/780', label: 'getagentseal/codeburn#780', cost: 240.5, savingsUSD: 0, sessions: 3, calls: 512, firstStarted: '2026-07-01T10:00:00Z', lastEnded: '2026-07-03T18:00:00Z', models: ['fable', 'opus', 'haiku'], categories: [{ name: 'Feature work', cost: 180.25 }, { name: 'Debugging', cost: 60.25 }] },
{ url: 'https://github.com/getagentseal/codeburn/pull/781', label: 'getagentseal/codeburn#781', cost: 90.25, savingsUSD: 0, sessions: 1, calls: 120, firstStarted: '2026-07-05T13:00:00Z', lastEnded: '2026-07-05T15:00:00Z', models: ['sonnet'], categories: [{ name: 'Refactoring', cost: 90.25 }] },
],
distinctCost: 376.05,
distinctSessions: 3,
attributedCost: 330.75,
unattributedCost: 45.3,
}
// Get the button-role row wrapping a given PR link, for click/keyboard toggling.
function rowForLink(link: HTMLElement): HTMLElement {
const row = link.closest('[role="button"]')
if (!row) throw new Error('expected a button-role row around the PR link')
return row as HTMLElement
}
describe('PullRequests', () => {
beforeEach(() => {
getOverview.mockReset()
openExternal.mockReset()
openExternal.mockResolvedValue(undefined)
})
it('renders PR cards with linked labels, cost, activity, and a date span', async () => {
getOverview.mockResolvedValue(makePayload(SAMPLE))
render(<PullRequests period="lifetime" provider="all" />)
const link = await screen.findByRole('link', { name: 'getagentseal/codeburn#780' })
expect(link).toHaveAttribute('href', 'https://github.com/getagentseal/codeburn/pull/780')
expect(screen.getByText('$240.50')).toBeInTheDocument()
expect(screen.getByText('512 calls')).toBeInTheDocument()
expect(screen.getByText(expectedSpan(SAMPLE.rows[0]!.firstStarted, SAMPLE.rows[0]!.lastEnded))).toBeInTheDocument()
// A same-day PR collapses its span to a single label.
expect(screen.getByText(expectedSpan(SAMPLE.rows[1]!.firstStarted, SAMPLE.rows[1]!.lastEnded))).toBeInTheDocument()
})
it('renders every model explicitly instead of hiding models behind an overflow count', async () => {
getOverview.mockResolvedValue(makePayload(SAMPLE))
render(<PullRequests period="lifetime" provider="all" />)
expect(await screen.findByText('fable')).toBeInTheDocument()
expect(screen.getByText('opus')).toBeInTheDocument()
expect(screen.getByText('haiku')).toBeInTheDocument()
expect(screen.queryByText('+1')).toBeNull()
expect(screen.getByText('sonnet')).toBeInTheDocument()
})
it('opens the PR URL externally without navigating or toggling the row', async () => {
getOverview.mockResolvedValue(makePayload(SAMPLE))
render(<PullRequests period="lifetime" provider="all" />)
const link = await screen.findByRole('link', { name: 'getagentseal/codeburn#780' })
await userEvent.click(link)
expect(openExternal).toHaveBeenCalledWith('https://github.com/getagentseal/codeburn/pull/780')
// Clicking the link must not expand its row.
expect(rowForLink(link)).toHaveAttribute('aria-expanded', 'false')
expect(screen.queryByText('Feature work')).toBeNull()
})
it('expands a row to its category breakdown on click, then collapses', async () => {
getOverview.mockResolvedValue(makePayload(SAMPLE))
render(<PullRequests period="lifetime" provider="all" />)
const link = await screen.findByRole('link', { name: 'getagentseal/codeburn#780' })
const row = rowForLink(link)
expect(row).toHaveAttribute('aria-expanded', 'false')
await userEvent.click(row)
expect(row).toHaveAttribute('aria-expanded', 'true')
expect(screen.getByText('Feature work')).toBeInTheDocument()
expect(screen.getByText('$180.25')).toBeInTheDocument()
expect(screen.getByText('Debugging')).toBeInTheDocument()
await userEvent.click(row)
expect(row).toHaveAttribute('aria-expanded', 'false')
expect(screen.queryByText('Feature work')).toBeNull()
})
it('closes an open expansion when the period changes the PR set', async () => {
const changed: PrPayload = { ...SAMPLE, rows: [SAMPLE.rows[0]!] } // #781 dropped
getOverview.mockImplementation((period: string) => Promise.resolve(makePayload(period === 'lifetime' ? SAMPLE : changed)))
const { rerender } = render(<PullRequests period="lifetime" provider="all" />)
const link = await screen.findByRole('link', { name: 'getagentseal/codeburn#780' })
await userEvent.click(rowForLink(link))
expect(rowForLink(link)).toHaveAttribute('aria-expanded', 'true')
rerender(<PullRequests period="week" provider="all" />)
// The new period drops #781, so the PR set changes and the stale expansion
// resets once the new data lands (wait for the breakdown to disappear).
await waitFor(() => expect(screen.queryByText('Feature work')).toBeNull())
const link2 = await screen.findByRole('link', { name: 'getagentseal/codeburn#780' })
expect(rowForLink(link2)).toHaveAttribute('aria-expanded', 'false')
})
it('closes an open expansion on a period switch even when the PR set is identical', async () => {
getOverview.mockResolvedValue(makePayload(SAMPLE))
const { rerender } = render(<PullRequests period="lifetime" provider="all" />)
const link = await screen.findByRole('link', { name: 'getagentseal/codeburn#780' })
await userEvent.click(rowForLink(link))
expect(rowForLink(link)).toHaveAttribute('aria-expanded', 'true')
// Same rows come back for the new period; the expansion must still reset,
// since the row's underlying numbers may differ across periods.
rerender(<PullRequests period="week" provider="all" />)
await waitFor(() => expect(screen.queryByText('Feature work')).toBeNull())
const link2 = await screen.findByRole('link', { name: 'getagentseal/codeburn#780' })
expect(rowForLink(link2)).toHaveAttribute('aria-expanded', 'false')
})
it('toggles expansion from the keyboard with Enter', async () => {
getOverview.mockResolvedValue(makePayload(SAMPLE))
render(<PullRequests period="lifetime" provider="all" />)
const link = await screen.findByRole('link', { name: 'getagentseal/codeburn#780' })
const row = rowForLink(link)
row.focus()
await userEvent.keyboard('{Enter}')
expect(row).toHaveAttribute('aria-expanded', 'true')
expect(screen.getByText('Feature work')).toBeInTheDocument()
await userEvent.keyboard('{Enter}')
expect(row).toHaveAttribute('aria-expanded', 'false')
})
it('states the attributed-total footer and the summable framing', async () => {
getOverview.mockResolvedValue(makePayload(SAMPLE))
render(<PullRequests period="lifetime" provider="all" />)
expect(await screen.findByText('Attributed spend')).toBeInTheDocument()
expect(screen.getByText('$330.75')).toBeInTheDocument()
expect(screen.getByLabelText('Pull request attribution summary')).toHaveTextContent('Linked sessions3')
const note = screen.getByText(/attributed turn by turn/)
expect(note.textContent).toContain('without double counting')
expect(screen.getByText(/Not tied to a specific PR/).textContent).toContain('$45.30')
})
it('notes folded-in subagent runs in the footer when present', async () => {
getOverview.mockResolvedValue(makePayload({ ...SAMPLE, subagentSessions: 32 }))
render(<PullRequests period="lifetime" provider="all" />)
expect(await screen.findByText('Folded agent runs')).toBeInTheDocument()
expect(screen.getByText('32')).toBeInTheDocument()
const note = screen.getByText(/attributed turn by turn/)
expect(note.textContent).toContain('32 subagent runs are included')
})
it('omits the subagent note when none were folded', async () => {
getOverview.mockResolvedValue(makePayload(SAMPLE))
render(<PullRequests period="lifetime" provider="all" />)
const note = await screen.findByText(/attributed turn by turn/)
expect(note.textContent).not.toContain('subagent')
})
it('marks an approximate (legacy) row with a ~ prefix and a tooltip', async () => {
const approxPayload: PrPayload = {
rows: [
{ url: 'https://github.com/getagentseal/codeburn/pull/900', label: 'getagentseal/codeburn#900', cost: 12.5, savingsUSD: 0, sessions: 1, calls: 30, firstStarted: '2026-07-10T10:00:00Z', lastEnded: '2026-07-10T11:00:00Z', approx: true },
],
distinctCost: 12.5,
distinctSessions: 1,
attributedCost: 12.5,
unattributedCost: 0,
}
getOverview.mockResolvedValue(makePayload(approxPayload))
render(<PullRequests period="lifetime" provider="all" />)
const cost = await screen.findByText('~$12.50')
expect(cost).toHaveAttribute('title')
// A zero unattributed remainder hides the muted line.
expect(screen.queryByText(/Not tied to a specific PR/)).toBeNull()
})
it('expands a category-less (legacy) row to a muted note, not an empty box', async () => {
const approxPayload: PrPayload = {
rows: [
{ url: 'https://github.com/getagentseal/codeburn/pull/900', label: 'getagentseal/codeburn#900', cost: 12.5, savingsUSD: 0, sessions: 1, calls: 30, firstStarted: '2026-07-10T10:00:00Z', lastEnded: '2026-07-10T11:00:00Z', approx: true },
],
distinctCost: 12.5,
distinctSessions: 1,
attributedCost: 12.5,
unattributedCost: 0,
}
getOverview.mockResolvedValue(makePayload(approxPayload))
render(<PullRequests period="lifetime" provider="all" />)
const link = await screen.findByRole('link', { name: 'getagentseal/codeburn#900' })
await userEvent.click(rowForLink(link))
expect(screen.getByText(/No per-turn detail/)).toBeInTheDocument()
})
it('renders the old-CLI by-reference footer without NaN and never claims summable', async () => {
const oldPayload: PrPayload = {
rows: [
{ url: 'https://github.com/getagentseal/codeburn/pull/500', label: 'getagentseal/codeburn#500', cost: 120.4, savingsUSD: 0, sessions: 2, calls: 300, firstStarted: '2026-06-01T10:00:00Z', lastEnded: '2026-06-02T12:00:00Z' },
],
distinctCost: 120.4,
distinctSessions: 2,
}
getOverview.mockResolvedValue(makePayload(oldPayload))
render(<PullRequests period="lifetime" provider="all" />)
const note = await screen.findByText(/produced pull requests/)
expect(note.textContent).toContain('$120.40')
expect(note.textContent).toContain('by reference')
expect(note.textContent).toContain('not summed')
expect(note.textContent).not.toContain('summable')
// No optional field renders as NaN and no unattributed line appears.
expect(screen.queryByText(/NaN/)).toBeNull()
expect(screen.queryByText(/Not tied to a specific PR/)).toBeNull()
})
it('renders the complete PR list without an opaque Other row', async () => {
const manyRows = Array.from({ length: 32 }, (_, index) => ({
...SAMPLE.rows[0]!,
url: `https://github.com/getagentseal/codeburn/pull/${800 + index}`,
label: `getagentseal/codeburn#${800 + index}`,
}))
getOverview.mockResolvedValue(makePayload({
...SAMPLE,
rows: manyRows,
attributedCost: manyRows.reduce((sum, row) => sum + row.cost, 0),
}))
render(<PullRequests period="lifetime" provider="all" />)
expect(await screen.findByText('getagentseal/codeburn#800')).toBeInTheDocument()
expect(screen.getByText('getagentseal/codeburn#831')).toBeInTheDocument()
expect(screen.getByText('32 total')).toBeInTheDocument()
expect(screen.queryByText(/Other \(/)).toBeNull()
})
it('shows the quiet empty state (never a fake table) when no PR links exist', async () => {
getOverview.mockResolvedValue(makePayload())
render(<PullRequests period="lifetime" provider="all" />)
expect(await screen.findByText(/PR links are captured as sessions are parsed/)).toBeInTheDocument()
expect(screen.queryByRole('table')).toBeNull()
})
it('shows the empty state when the PR array is present but empty', async () => {
getOverview.mockResolvedValue(makePayload({ rows: [], distinctCost: 0, distinctSessions: 0, attributedCost: 0, unattributedCost: 0 }))
render(<PullRequests period="lifetime" provider="all" />)
expect(await screen.findByText(/PR links are captured as sessions are parsed/)).toBeInTheDocument()
expect(screen.queryByRole('table')).toBeNull()
})
})

View file

@ -0,0 +1,226 @@
import type { KeyboardEvent, MouseEvent } from 'react'
import { useEffect, useState } from 'react'
import { CliErrorPanel } from '../components/CliErrorPanel'
import { EmptyNote } from '../components/EmptyState'
import { Panel } from '../components/Panel'
import { SectionSkeleton } from '../components/Skeleton'
import { StaleBanner } from '../components/StaleBanner'
import { type Polled, usePolled } from '../hooks/usePolled'
import { formatDayShort, formatUsd } from '../lib/format'
import { codeburn } from '../lib/ipc'
import type { CliError, DateRange, MenubarPayload, Period } from '../lib/types'
type PullRequests = NonNullable<MenubarPayload['current']['pullRequests']>
type PrRow = PullRequests['rows'][number]
// A PR's active window: one day collapses to a single label, otherwise the two
// endpoints joined with a hyphen (never an en/em dash, per repo copy rules).
function spanLabel(firstStarted: string, lastEnded: string): string {
const start = formatDayShort(firstStarted)
const end = formatDayShort(lastEnded)
if (start === '—' && end === '—') return '—'
return start === end ? start : `${start} - ${end}`
}
function sessionWord(n: number): string {
return n === 1 ? 'session' : 'sessions'
}
function ModelChips({ models }: { models: string[] }) {
return (
<div className="pr-model-list" aria-label={models.length ? `Models used: ${models.join(', ')}` : 'No model data'}>
{models.map(model => <span className="pr-model-chip" key={model}>{model}</span>)}
</div>
)
}
function openPr(event: MouseEvent<HTMLAnchorElement>, url: string): void {
event.preventDefault()
event.stopPropagation()
void codeburn.openExternal(url)
}
// Keyboard activation for the button-role row, guarded so Enter/Space fired on
// the inner link (its own control) never doubles up as a row toggle.
function rowKeyDown(event: KeyboardEvent<HTMLDivElement>, toggle: () => void): void {
if (event.target !== event.currentTarget) return
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
toggle()
}
}
/** Standalone entry: self-fetches the overview payload (used in tests). The App
* passes its shared overview poll straight into PullRequestsContent instead. */
export function PullRequests({ period, provider, range = null }: { period: Period; provider: string; range?: DateRange | null }) {
const overview = usePolled<MenubarPayload>(
() => range ? codeburn.getOverview(period, provider, range) : codeburn.getOverview(period, provider),
[period, provider, range?.from, range?.to],
)
// The key remounts the content on a period/provider/range switch so row state
// (an open expansion) never survives onto the same PR rendered from new data.
return <PullRequestsContent key={`${period}|${provider}|${range?.from ?? ''}|${range?.to ?? ''}`} overview={overview} />
}
export function PullRequestsContent({ overview }: { overview: Polled<MenubarPayload> }) {
if (!overview.data) {
if (overview.error) return <CliErrorPanel error={overview.error} subject="pull requests" />
return <SectionSkeleton label="Scanning pull requests…" rows={5} />
}
return <PullRequestsPage pullRequests={overview.data.current.pullRequests} staleError={overview.error} />
}
function PullRequestsPage({ pullRequests, staleError }: { pullRequests?: PullRequests; staleError: CliError | null }) {
return (
<>
{staleError && <StaleBanner error={staleError} />}
<Panel title="Pull request spend">
{pullRequests && pullRequests.rows.length > 0
? <PrTable pullRequests={pullRequests} />
: <EmptyNote>PR links are captured as sessions are parsed. Once a session references a pull request, it appears here.</EmptyNote>}
</Panel>
</>
)
}
function PrTable({ pullRequests }: { pullRequests: PullRequests }) {
const { rows, distinctCost, distinctSessions, subagentSessions, attributedCost, unattributedCost } = pullRequests
const [expandedUrl, setExpandedUrl] = useState<string | null>(null)
// Reset any open expansion when the PR set changes (a period/provider switch or
// a refresh that alters the list): a stale expandedUrl would otherwise linger
// pointing at a row that is no longer present.
const rowKey = rows.map(row => row.url).join('|')
useEffect(() => { setExpandedUrl(null) }, [rowKey])
// A new-attribution payload carries `attributedCost`; an older by-reference
// payload omits it, so the rows are not summable and the footer must differ.
const summable = attributedCost !== undefined
const unattributed = unattributedCost ?? 0
// Reconcile to the visible numbers: every PR is present, so the summary is
// exactly the sum of the rounded cards a person can inspect below.
const displayedAttributed = rows.reduce((sum, row) => sum + Number(row.cost.toFixed(2)), 0)
return (
<>
<div className="pr-summary" aria-label="Pull request attribution summary">
<div className="pr-summary-item">
<span>Attributed spend</span>
<strong>{formatUsd(summable ? displayedAttributed : distinctCost)}</strong>
</div>
<div className="pr-summary-item">
<span>Pull requests</span>
<strong>{rows.length.toLocaleString('en-US')}</strong>
</div>
<div className="pr-summary-item">
<span>Linked sessions</span>
<strong>{distinctSessions.toLocaleString('en-US')}</strong>
</div>
<div className="pr-summary-item">
<span>Folded agent runs</span>
<strong>{(subagentSessions ?? 0).toLocaleString('en-US')}</strong>
</div>
</div>
<div className="pr-list-head">
<div>
<strong>Attributed pull requests</strong>
<span>Sorted by spend, highest first</span>
</div>
<span className="pr-list-count">{rows.length.toLocaleString('en-US')} total</span>
</div>
<div className="pr-list" aria-label="Spend by pull request">
{rows.map(pr => (
<PrRowView
key={pr.url}
pr={pr}
expanded={expandedUrl === pr.url}
onToggle={() => setExpandedUrl(current => current === pr.url ? null : pr.url)}
/>
))}
</div>
{summable ? (
<p className="pr-footnote">
Costs are attributed turn by turn, so every row adds up without double counting.
{subagentSessions ? ` ${subagentSessions.toLocaleString('en-US')} subagent ${subagentSessions === 1 ? 'run is' : 'runs are'} included in the PR where the work happened.` : ''}
</p>
) : (
<p className="pr-footnote">
{formatUsd(distinctCost)} across {distinctSessions.toLocaleString('en-US')} distinct {sessionWord(distinctSessions)} produced pull requests.
{' '}Attribution is by reference: a session referencing several PRs counts toward each, so the rows above are not summed.
</p>
)}
{unattributed > 0 && (
<p className="pr-unattributed">Not tied to a specific PR: {formatUsd(unattributed)}</p>
)}
</>
)
}
const APPROX_TITLE = 'Approximate: the transcript expired before per-turn capture, so this PRs share is an even split of the whole session.'
function PrRowView({ pr, expanded, onToggle }: { pr: PrRow; expanded: boolean; onToggle: () => void }) {
const models = pr.models ?? []
const categories = pr.categories ?? []
const catMax = categories.length ? Math.max(...categories.map(cat => cat.cost)) : 0
return (
<article className={expanded ? 'pr-card is-open' : 'pr-card'}>
<div
className="pr-card-trigger"
role="button"
tabIndex={0}
aria-expanded={expanded}
onClick={onToggle}
onKeyDown={event => rowKeyDown(event, onToggle)}
>
<div className="pr-card-identity">
<span className="pr-icon" aria-hidden="true">
<svg viewBox="0 0 24 24"><circle cx="6" cy="5" r="2.5"/><circle cx="18" cy="19" r="2.5"/><path d="M6 7.5V19M11 5h4a3 3 0 0 1 3 3v8.5"/></svg>
</span>
<div>
<a className="pr-link" href={pr.url} title={pr.url} onClick={event => openPr(event, pr.url)}>{pr.label}</a>
<div className="pr-card-meta">
<span>{spanLabel(pr.firstStarted, pr.lastEnded)}</span>
<span>{pr.sessions.toLocaleString('en-US')} {sessionWord(pr.sessions)}</span>
<span>{pr.calls.toLocaleString('en-US')} calls</span>
</div>
</div>
</div>
<div className="pr-card-models">
<span className="pr-card-label">Models</span>
<ModelChips models={models} />
</div>
<div className="pr-card-cost">
<span className="pr-card-label">Spend</span>
<strong {...(pr.approx ? { title: APPROX_TITLE } : {})}>{pr.approx ? '~' : ''}{formatUsd(pr.cost)}</strong>
</div>
<span className="pr-chevron" aria-hidden="true"></span>
</div>
{expanded && (
<div className="pr-detail-cell">
{categories.length > 0 ? (
<div className="pr-detail" role="region" aria-label={`${pr.label} cost breakdown`}>
<div className="pr-detail-head">
<span>Work breakdown</span>
<strong>{formatUsd(pr.cost)} total</strong>
</div>
<div className="pr-cats">
{categories.map(cat => (
<div className="pr-cat" key={cat.name}>
<span className="pr-cat-name">{cat.name}</span>
<div className="pr-cat-bar" aria-hidden="true">
<span style={{ width: `${catMax > 0 ? cat.cost / catMax * 100 : 0}%` }} />
</div>
<strong>{formatUsd(cat.cost)}</strong>
</div>
))}
</div>
</div>
) : (
<p className="pr-cat-empty">No per-turn detail (estimated from a whole-session split).</p>
)}
</div>
)}
</article>
)
}

View file

@ -299,4 +299,36 @@ describe('Sessions', () => {
expect(within(filter).getByRole('button', { name: 'Codex' })).toHaveAttribute('aria-pressed', 'true')
expect(within(filter).getByRole('button', { name: 'All' })).toHaveAttribute('aria-pressed', 'false')
})
it('headlines a captured title, demotes the id, and falls back to the project when untitled', async () => {
getSessions.mockResolvedValue([
session({ sessionId: 'claude-abc123456789xyz', project: 'codeburn', provider: 'claude', title: 'Fix lifetime period in menubar labels', cost: 5 }),
session({ sessionId: 'claude-untitled-000000', project: 'docs-site', provider: 'claude', title: '', cost: 3 }),
])
const { container } = render(<Sessions period="30days" provider="all" />)
await screen.findByText('2 sessions · $8.00 · 2K tokens')
const titles = [...container.querySelectorAll('.session-row .session-title')]
const ids = [...container.querySelectorAll('.session-row .session-project')]
// Titled row (higher cost, first): the title is the headline, the id its mono secondary line.
expect(titles[0]).toHaveTextContent('Fix lifetime period in menubar labels')
expect(ids[0]).toHaveTextContent('claude-abc')
// Untitled row: unchanged behavior, the project (via shortenProjectPath) stays the headline.
expect(titles[1]).toHaveTextContent('docs/site')
expect(ids[1]).toHaveTextContent('claude-untitled')
})
it('matches sessions by their captured title', async () => {
const user = userEvent.setup()
getSessions.mockResolvedValue([
session({ sessionId: 'claude-1', project: 'codeburn', provider: 'claude', title: 'Refactor the parser cache', cost: 5 }),
session({ sessionId: 'codex-2', project: 'client-api', provider: 'codex', title: 'Add billing webhook', cost: 3 }),
])
const { container } = render(<Sessions period="30days" provider="all" />)
const search = await screen.findByRole('textbox', { name: 'Search sessions' })
await user.type(search, 'webhook')
expect(container.querySelectorAll('.session-row')).toHaveLength(1)
expect(container.querySelector('.session-row .session-title')).toHaveTextContent('Add billing webhook')
})
})

View file

@ -125,6 +125,7 @@ export function Sessions({
const rows = report.data ?? []
const q = query.trim().toLowerCase()
const filtered = rows.filter(row => q === '' || [
row.title ?? '',
row.project,
row.sessionId,
row.models.join(' '),
@ -251,7 +252,7 @@ export function Sessions({
<span className="session-primary">
<span className="session-chevron" aria-hidden="true"></span>
<span className="session-project-copy">
<span className="session-title">{shortenProjectPath(entry.row.project)}</span>
<span className="session-title" title={entry.row.title || undefined}>{entry.row.title || shortenProjectPath(entry.row.project)}</span>
<span className="session-project">{entry.row.sessionId.slice(0, 18)}</span>
</span>
</span>

View file

@ -145,6 +145,17 @@ describe('Settings', () => {
expect(localStorage.getItem('codeburn.dailyBudget')).toBeFalsy()
})
it('reflects the current scope and reports a change through onScopeChange', async () => {
const user = userEvent.setup()
const onScopeChange = vi.fn()
render(<Settings period="month" scope="local" onScopeChange={onScopeChange} />)
const scope = screen.getByLabelText('Scope')
expect(scope).toHaveTextContent('Local')
await user.click(scope)
await user.click(screen.getByRole('option', { name: 'Combined' }))
expect(onScopeChange).toHaveBeenCalledWith('combined')
})
it('lists providers from the real overview payload', async () => {
const user = userEvent.setup()
render(<Settings period="week" />)

View file

@ -13,12 +13,13 @@ import { version as appVersion } from '../../package.json'
import { readDailyBudget } from '../lib/budget'
import { formatConverted, formatUsd } from '../lib/format'
import { codeburn } from '../lib/ipc'
import { shortcutLabel } from '../lib/platform'
import { motionClass } from '../lib/motion'
import { REFRESH_OPTIONS, useRefreshCadence } from '../lib/refreshCadence'
import { showToast } from '../lib/toast'
import { ToastHost } from '../components/ToastHost'
import { rateLimitedNote } from './Plans'
import type { ActionResult, AliasRow, ClaudeConfigSelector, CliError, CombinedUsage, DeviceScanResult, Identity, JsonPlanSummary, MenubarPayload, Period, PlanId, PlanProvider, PriceOverrideList, PriceOverrideRow, PriceRates, QuotaProvider, ShareStatus, StatusJson, TelemetryStatus } from '../lib/types'
import type { ActionResult, AliasRow, ClaudeConfigSelector, CliError, CombinedUsage, DeviceScanResult, Identity, JsonPlanSummary, MenubarPayload, Period, PlanId, PlanProvider, PriceOverrideList, PriceOverrideRow, PriceRates, QuotaProvider, Scope, ShareStatus, StatusJson, TelemetryStatus } from '../lib/types'
export type SettingsPane = 'general' | 'providers' | 'aliases' | 'pricing' | 'plans' | 'devices' | 'export' | 'privacy'
type Pane = SettingsPane
@ -97,7 +98,7 @@ function ConfirmButton({ label, prompt, onConfirm }: { label: string; prompt: st
)
}
export function Settings({ period, refreshToken = 0, onNavigate, initialPane, claudeConfigs, claudeConfigSource = null, onConfigMutated }: { period: Period; refreshToken?: number; onNavigate?: (section: Section) => void; initialPane?: SettingsPane; claudeConfigs?: ClaudeConfigSelector; claudeConfigSource?: string | null; onConfigMutated?: () => void }) {
export function Settings({ period, refreshToken = 0, onNavigate, initialPane, claudeConfigs, claudeConfigSource = null, onConfigMutated, scope = 'local', onScopeChange }: { period: Period; refreshToken?: number; onNavigate?: (section: Section) => void; initialPane?: SettingsPane; claudeConfigs?: ClaudeConfigSelector; claudeConfigSource?: string | null; onConfigMutated?: () => void; scope?: Scope; onScopeChange?: (scope: string) => void }) {
const [pane, setPane] = useState<Pane>(initialPane ?? 'general')
return (
@ -113,7 +114,7 @@ export function Settings({ period, refreshToken = 0, onNavigate, initialPane, cl
))}
</nav>
<main className="set-pane">
{pane === 'general' && <GeneralPane period={period} refreshToken={refreshToken} claudeConfigs={claudeConfigs} claudeConfigSource={claudeConfigSource} onConfigMutated={onConfigMutated} />}
{pane === 'general' && <GeneralPane period={period} refreshToken={refreshToken} claudeConfigs={claudeConfigs} claudeConfigSource={claudeConfigSource} onConfigMutated={onConfigMutated} scope={scope} onScopeChange={onScopeChange} />}
{pane === 'providers' && <ProvidersPane period={period} refreshToken={refreshToken} />}
{pane === 'aliases' && <AliasesPane refreshToken={refreshToken} onConfigMutated={onConfigMutated} />}
{pane === 'pricing' && <PricingPane refreshToken={refreshToken} onConfigMutated={onConfigMutated} />}
@ -123,12 +124,12 @@ export function Settings({ period, refreshToken = 0, onNavigate, initialPane, cl
{pane === 'privacy' && <PrivacyPane />}
</main>
</div>
<Hint items={[{ k: '⌘1-7', label: 'Navigate' }, { k: '⌘R', label: 'Refresh' }]} right="pairing uses mutual TLS · approve-style, no PIN" />
<Hint items={[{ k: shortcutLabel('1-8'), label: 'Navigate' }, { k: shortcutLabel('R'), label: 'Refresh' }]} right="pairing uses mutual TLS · approve-style, no PIN" />
</>
)
}
function GeneralPane({ period, refreshToken, claudeConfigs, claudeConfigSource, onConfigMutated }: { period: Period; refreshToken: number; claudeConfigs?: ClaudeConfigSelector; claudeConfigSource: string | null; onConfigMutated?: () => void }) {
function GeneralPane({ period, refreshToken, claudeConfigs, claudeConfigSource, onConfigMutated, scope = 'local', onScopeChange }: { period: Period; refreshToken: number; claudeConfigs?: ClaudeConfigSelector; claudeConfigSource: string | null; onConfigMutated?: () => void; scope?: Scope; onScopeChange?: (scope: string) => void }) {
const [currencyNonce, setCurrencyNonce] = useState(0)
const plans = usePolled<StatusJson>(() => codeburn.getPlans(period), [period, refreshToken, currencyNonce])
const [theme, setTheme] = useState<Theme>(() => {
@ -202,7 +203,8 @@ function GeneralPane({ period, refreshToken, claudeConfigs, claudeConfigSource,
<button className="set-text-button" onClick={() => void codeburn.resetCurrency().then(finishCurrency)}>Reset to USD</button>
</span></div>
<div className="about-row"><label className="tx" htmlFor="settings-period">Default period<small>Applied on next launch.</small></label><span className="r"><Dropdown id="settings-period" ariaLabel="Default period" value={defaultPeriod} options={[{ value: 'today', label: 'Today' }, { value: 'week', label: '7d' }, { value: '30days', label: '30d' }, { value: 'month', label: 'Month' }, { value: 'all', label: 'All' }]} onChange={value => { setDefaultPeriod(value); writeSetting('codeburn.defaultPeriod', value) }} width={92} /></span></div>
<div className="about-row"><label className="tx" htmlFor="settings-refresh">Refresh every<small>How often data auto-refreshes. Manual updates only on R.</small></label><span className="r"><Dropdown id="settings-refresh" ariaLabel="Refresh every" value={cadence.value} options={REFRESH_OPTIONS.map(option => ({ value: option.value, label: option.label }))} onChange={cadence.setValue} width={124} /></span></div>
<div className="about-row"><label className="tx" htmlFor="settings-scope">Scope<small>Combined aggregates usage across every paired device, like the menubar. Local shows this device only.</small></label><span className="r"><Dropdown id="settings-scope" ariaLabel="Scope" value={scope} options={[{ value: 'local', label: 'Local' }, { value: 'combined', label: 'Combined' }]} onChange={value => onScopeChange?.(value)} width={110} /></span></div>
<div className="about-row"><label className="tx" htmlFor="settings-refresh">Refresh every<small>How often data auto-refreshes. Manual updates only on {shortcutLabel('R')}.</small></label><span className="r"><Dropdown id="settings-refresh" ariaLabel="Refresh every" value={cadence.value} options={REFRESH_OPTIONS.map(option => ({ value: option.value, label: option.label }))} onChange={cadence.setValue} width={124} /></span></div>
<div className="about-row"><label className="tx" htmlFor="settings-budget">Daily budget<small>Warns at 80%, alerts at 100%.</small></label><span className="r"><Dropdown id="settings-budget" ariaLabel="Daily budget" value={budgetKind} options={[{ value: 'off', label: 'Off' }, { value: 'usd', label: 'USD amount' }, { value: 'tokens', label: 'Tokens' }]} onChange={value => { const kind = value as 'off' | 'usd' | 'tokens'; setBudgetKind(kind); persistBudget(kind, budgetInput) }} width={120} />{budgetKind !== 'off' && <input className="set-input" type="text" inputMode="decimal" aria-label="Daily budget amount" placeholder={budgetKind === 'usd' ? 'USD' : 'tokens'} value={budgetInput} onChange={event => { setBudgetInput(event.target.value); persistBudget(budgetKind, event.target.value) }} style={{ width: 90 }} />}</span></div>
{budgetError && <p className="set-action-msg error">{budgetError}</p>}
</div>

View file

@ -512,6 +512,12 @@ td:first-child { font-size: var(--fs-body); font-weight: var(--fw-body); }
.ov-saved-line { display: flex; flex-wrap: wrap; align-items: baseline; gap: 3px 7px; margin-top: 5px; padding-top: 9px; border-top: 1px solid var(--line2); color: var(--mut2); font-size: 10.5px; }
.ov-saved-line strong { color: var(--ok); font-family: var(--mono); font-size: 13px; font-weight: 650; font-variant-numeric: tabular-nums; }
.ov-saved-line small { color: var(--mut2); font-size: 10px; }
.ov-combined-devices { width: 100%; margin-top: 5px; padding-top: 9px; border-top: 1px solid var(--line2); display: flex; flex-direction: column; gap: 3px; }
.ov-combined-head { color: var(--mut2); font-size: 10.5px; font-weight: 560; text-transform: uppercase; letter-spacing: 0.03em; margin-bottom: 2px; }
.ov-combined-row { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; font-size: 11.5px; color: var(--mut); }
.ov-combined-row .ov-combined-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ov-combined-row .ov-combined-val { font-family: var(--mono); font-variant-numeric: tabular-nums; color: var(--ink); }
.ov-combined-row.err .ov-combined-val { color: var(--warn); font-family: inherit; }
.ov-hero-split .ov-heatmap-bare { display: flex; flex-direction: column; justify-content: space-between; gap: 8px; }
.ov-activity-head { display: flex; align-items: baseline; gap: 8px; }
.ov-hero-sub .neutral { color: var(--mut); font-weight: 560; }
@ -580,6 +586,57 @@ td:first-child { font-size: var(--fs-body); font-weight: var(--fw-body); }
.ov-models th:first-child, .ov-models td:first-child { width: 100%; text-align: left; }
.ov-models .ov-model-name { overflow: hidden; color: var(--ink); font-weight: var(--fw-medium); text-overflow: ellipsis; }
.ov-models td.mono { font-family: var(--mono); color: var(--ink); }
.pr-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; margin-bottom: 20px; }
.pr-summary-item { display: flex; min-width: 0; flex-direction: column; gap: 7px; padding: 14px 16px; border: 1px solid var(--line); border-radius: 9px; background: color-mix(in srgb, var(--panel) 92%, var(--fill)); box-shadow: 0 1px 2px rgba(0,0,0,.08), inset 0 1px 0 rgba(255,255,255,.025); }
.pr-summary-item span { color: var(--mut2); font-size: var(--fs-label); font-weight: var(--fw-strong); letter-spacing: .055em; text-transform: uppercase; }
.pr-summary-item strong { overflow: hidden; color: var(--ink); font-family: var(--mono); font-size: 20px; font-weight: var(--fw-kpi); font-variant-numeric: tabular-nums; letter-spacing: -.02em; text-overflow: ellipsis; white-space: nowrap; }
.pr-list-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; margin: 0 2px 10px; }
.pr-list-head > div { display: flex; flex-direction: column; gap: 3px; }
.pr-list-head strong { color: var(--ink); font-size: var(--fs-body); font-weight: var(--fw-subhead); }
.pr-list-head span { color: var(--mut2); font-size: var(--fs-meta); }
.pr-list-count { padding: 4px 8px; border: 1px solid var(--line); border-radius: 999px; background: var(--fill); font-family: var(--mono); font-variant-numeric: tabular-nums; }
.pr-list { display: flex; flex-direction: column; gap: 8px; }
.pr-card { overflow: hidden; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); box-shadow: 0 1px 2px rgba(0,0,0,.07); transition: border-color 140ms ease, box-shadow 140ms ease, transform 140ms ease; }
.pr-card:hover { border-color: color-mix(in srgb, var(--line) 55%, var(--mut2)); box-shadow: 0 4px 12px rgba(0,0,0,.10); transform: translateY(-1px); }
.pr-card.is-open { border-color: color-mix(in srgb, var(--accent) 32%, var(--line)); box-shadow: 0 6px 18px rgba(0,0,0,.12); }
.pr-card-trigger { display: grid; grid-template-columns: minmax(280px, 1.35fr) minmax(230px, 1fr) 100px 28px; min-height: 74px; align-items: center; gap: 18px; padding: 12px 14px; cursor: pointer; }
.pr-card-trigger:focus-visible { outline: none; box-shadow: inset 0 0 0 1px var(--accent); }
.pr-card-identity { display: flex; min-width: 0; align-items: center; gap: 11px; }
.pr-card-identity > div { min-width: 0; }
.pr-icon { display: grid; width: 32px; height: 32px; flex: 0 0 auto; place-items: center; border: 1px solid color-mix(in srgb, var(--accent) 25%, var(--line)); border-radius: 8px; background: color-mix(in srgb, var(--accent) 9%, var(--panel)); color: var(--accent-text); }
.pr-icon svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
.pr-link, .pr-link:visited { display: block; overflow: hidden; color: var(--ink); font-size: 12.5px; font-weight: var(--fw-subhead); text-decoration: none; text-overflow: ellipsis; white-space: nowrap; cursor: pointer; }
.pr-link:hover, .pr-link:focus-visible { color: var(--ink); text-decoration: underline; text-decoration-color: var(--mut2); text-underline-offset: 3px; }
.pr-card-meta { display: flex; min-width: 0; flex-wrap: wrap; align-items: center; gap: 0; margin-top: 6px; color: var(--mut2); font-size: var(--fs-meta); font-variant-numeric: tabular-nums; }
.pr-card-meta span + span::before { content: '•'; margin: 0 7px; color: var(--line); }
.pr-card-models { min-width: 0; }
.pr-card-label { display: block; margin-bottom: 6px; color: var(--mut2); font-size: var(--fs-micro); font-weight: var(--fw-strong); letter-spacing: .05em; text-transform: uppercase; }
.pr-model-list { display: flex; min-width: 0; flex-wrap: wrap; gap: 5px; }
.pr-model-chip { display: inline-flex; max-width: 100%; align-items: center; padding: 4px 8px; border: 1px solid var(--line); border-radius: 6px; background: var(--fill); color: var(--mut); font-family: var(--mono); font-size: 10.5px; line-height: 1; white-space: nowrap; }
.pr-card-cost { text-align: right; }
.pr-card-cost strong { color: var(--ink); font-family: var(--mono); font-size: 14px; font-weight: var(--fw-strong); font-variant-numeric: tabular-nums; }
.pr-chevron { display: grid; width: 26px; height: 26px; place-items: center; border: 1px solid var(--line); border-radius: 7px; background: var(--fill); color: var(--mut2); font-family: system-ui, sans-serif; font-size: 17px; line-height: 1; transition: transform 140ms ease, background 140ms ease; }
.pr-card.is-open .pr-chevron { transform: rotate(90deg); }
.pr-detail-cell { padding: 16px 18px 18px 57px; border-top: 1px solid var(--line2); background: color-mix(in srgb, var(--panel) 82%, var(--fill)); }
.pr-detail { max-width: 880px; }
.pr-detail-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
.pr-detail-head span { color: var(--ink); font-size: var(--fs-body); font-weight: var(--fw-subhead); }
.pr-detail-head strong { color: var(--mut); font-family: var(--mono); font-size: var(--fs-meta); font-weight: var(--fw-medium); font-variant-numeric: tabular-nums; }
.pr-cats { display: flex; flex-direction: column; gap: 9px; }
.pr-cat { display: grid; grid-template-columns: minmax(110px, 160px) minmax(120px, 1fr) 72px; align-items: center; gap: 12px; }
.pr-cat-bar { height: 5px; overflow: hidden; border-radius: 3px; background: color-mix(in srgb, var(--fill) 82%, var(--mut2)); }
.pr-cat-bar span { display: block; height: 100%; border-radius: inherit; background: color-mix(in srgb, var(--accent) 58%, white); }
.pr-cat-name { overflow: hidden; color: var(--mut); font-size: 12px; font-weight: var(--fw-medium); text-overflow: ellipsis; white-space: nowrap; }
.pr-cat > strong { color: var(--ink); font-family: var(--mono); font-size: 11.5px; font-variant-numeric: tabular-nums; text-align: right; }
.pr-cat-empty { margin: 0; color: var(--mut2); font-size: var(--fs-meta); }
.pr-footnote { margin: 14px 2px 2px; color: var(--mut); font-size: var(--fs-meta); line-height: 1.55; }
.pr-unattributed { margin: 4px 2px 2px; color: var(--mut2); font-size: var(--fs-meta); font-variant-numeric: tabular-nums; }
@media (max-width: 820px) {
.pr-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.pr-card-trigger { grid-template-columns: minmax(230px, 1fr) 96px 28px; }
.pr-card-models { grid-column: 1 / -1; grid-row: 2; padding-left: 43px; }
.pr-detail-cell { padding-left: 18px; }
}
.opt-waste { min-width: 0; }
.opt-summary { padding: 0 0 10px; color: var(--mut); font-size: 11.5px; font-variant-numeric: tabular-nums; }
.opt-findings { display: grid; min-width: 0; }
@ -622,6 +679,9 @@ td:first-child { font-size: var(--fs-body); font-weight: var(--fw-body); }
.ov-outcome-metrics span { color: var(--mut); font-size: 10px; line-height: 1.25; }
.ov-outcome-metrics strong { overflow: hidden; color: var(--ink); font-family: var(--mono); font-size: var(--fs-kpi); font-weight: var(--fw-kpi); font-variant-numeric: tabular-nums; text-overflow: ellipsis; white-space: nowrap; }
.ov-outcome-split { margin-top: 9px; color: var(--mut); font-size: 10.5px; font-variant-numeric: tabular-nums; line-height: 1.4; }
.ov-priced-chip { flex: 0 0 auto; margin-left: auto; display: inline-flex; align-items: center; border-radius: 999px; padding: 2px 8px; background: color-mix(in srgb, var(--warn) 14%, transparent); color: var(--warn); font-size: 10px; font-weight: 650; font-variant-numeric: tabular-nums; line-height: 1.4; }
.ov-workflow-rework { margin-top: 10px; color: var(--mut); font-size: 11.5px; font-variant-numeric: tabular-nums; line-height: 1.4; }
.ov-workflow-rework strong { color: var(--ink); font-family: var(--mono); font-weight: 600; }
.ov-routing { display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 11px 13px; }
.ov-routing p { margin: 4px 0 0; color: var(--mut); font-size: 11.5px; line-height: 1.4; }
.ov-routing p strong { color: var(--ink); font-weight: 620; }

View file

@ -68,7 +68,11 @@ writeFileSync(
// extraneous warnings, so capture stdout regardless of exit code.
let listed = ''
try {
listed = execFileSync('npm', ['ls', '--omit=dev', '--all', '--parseable'], {
// Execute npm's JavaScript entry point with the current Node binary. Windows
// exposes npm as a .cmd shim, which execFile cannot launch without a shell.
const npmCli = process.env.npm_execpath
if (!npmCli) throw new Error('npm_execpath is unavailable')
listed = execFileSync(process.execPath, [npmCli, 'ls', '--omit=dev', '--all', '--parseable'], {
cwd: root,
encoding: 'utf8',
maxBuffer: 64 * 1024 * 1024,
@ -81,11 +85,17 @@ try {
// Map each back to its top-level node_modules entry (`name` or `@scope/name`),
// then copy those dirs whole — a package's own nested node_modules comes with
// it, which is exactly the closure it needs at runtime.
const prefix = rootModules + '/'
// `npm ls --parseable` uses native separators on Windows. Normalize both sides
// before extracting the package name so Store builds do not treat a populated
// node_modules tree as empty merely because it uses `\\` instead of `/`.
const prefix = rootModules.replaceAll('\\', '/') + '/'
const comparisonPrefix = process.platform === 'win32' ? prefix.toLowerCase() : prefix
const topLevel = new Set()
for (const line of listed.split('\n')) {
if (!line.startsWith(prefix)) continue
const rest = line.slice(prefix.length)
const normalizedLine = line.trim().replaceAll('\\', '/')
const comparisonLine = process.platform === 'win32' ? normalizedLine.toLowerCase() : normalizedLine
if (!comparisonLine.startsWith(comparisonPrefix)) continue
const rest = normalizedLine.slice(prefix.length)
const match = rest.match(/^(@[^/]+\/[^/]+|[^/]+)/)
if (match) topLevel.add(match[1])
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 458 KiB

View file

@ -5,6 +5,19 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/png" href="/codeburn-logo.png" />
<title>CodeBurn - Local Dashboard</title>
<script>
// Apply the saved theme before first paint so the dashboard never flashes light.
(function () {
var saved = null
try {
saved = localStorage.getItem('codeburn-theme')
} catch (e) {
// storage disabled (some embeds/webviews): fall back to OS theme
}
var dark = saved === 'dark' || (saved !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches)
if (dark) document.documentElement.classList.add('dark')
})()
</script>
</head>
<body>
<div id="root"></div>

View file

@ -77,7 +77,7 @@ function DeviceView({ payload, isRemote, unit }: { payload?: Payload; isRemote:
const c = payload?.current
// Cache cards read the period-scoped `current` totals, matching Cost/Calls/
// Tokens. `history.daily` is the 365-day backfill that feeds the trend chart
// only; summing it here over-counted the cards for shorter periods (#583).
// only; summing it here over-counted the cards for shorter periods (issue 583).
const cacheWrite = c?.cacheWriteTokens ?? 0
const cacheRead = c?.cacheReadTokens ?? 0
const toolBars: BarItem[] = c
@ -290,7 +290,7 @@ function CombinedView({ devices, unit }: { devices: DeviceUsage[]; unit: Unit })
if (!c) continue
inTok += c.inputTokens
outTok += c.outputTokens
// Period-scoped per device (was summing each device's 365-day backfill, #583).
// Period-scoped per device (was summing each device's 365-day backfill, issue 583).
// `?? 0` mirrors DeviceView and guards the un-normalized bootstrap payload,
// where an older peer may not carry these fields yet (avoids NaN).
cacheWrite += c.cacheWriteTokens ?? 0
@ -376,6 +376,42 @@ function CombinedView({ devices, unit }: { devices: DeviceUsage[]; unit: Unit })
)
}
// Theme toggle: mirrors the .dark class set by the index.html pre-paint script
// and persists the choice to the same localStorage key.
function ThemeToggle() {
const [dark, setDark] = useState(() => document.documentElement.classList.contains('dark'))
const toggle = () => {
const next = !dark
setDark(next)
document.documentElement.classList.toggle('dark', next)
try {
localStorage.setItem('codeburn-theme', next ? 'dark' : 'light')
} catch {
// storage disabled (some embeds/webviews): persist nothing, OS theme wins next load
}
}
return (
<button
type="button"
onClick={toggle}
aria-label={dark ? 'Switch to light mode' : 'Switch to dark mode'}
title={dark ? 'Switch to light mode' : 'Switch to dark mode'}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md border border-border bg-card text-tertiary-foreground transition-colors hover:bg-interactive-secondary hover:text-foreground max-md:h-9 max-md:w-9 max-md:shrink-0"
>
{dark ? (
<svg viewBox="0 0 16 16" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 8.53A6 6 0 1 1 7.47 2 4.67 4.67 0 0 0 14 8.53Z" />
</svg>
) : (
<svg viewBox="0 0 16 16" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round">
<circle cx="8" cy="8" r="3.1" />
<path d="M8 1.8v1.7M8 12.5v1.7M1.8 8h1.7M12.5 8h1.7M3.5 3.5l1.2 1.2M11.3 11.3l1.2 1.2M12.5 3.5l-1.2 1.2M4.7 11.3l-1.2 1.2" />
</svg>
)}
</button>
)
}
export function App() {
const [page, setPage] = useState<'usage' | 'context'>('usage')
const [period, setPeriod] = useState<Period>('today')
@ -459,6 +495,25 @@ export function App() {
if (provider !== 'all' && c0 && !providerOptions.includes(provider)) setProvider('all')
}, [provider, providerOptions, c0])
// Follow the OS theme live while the user has no explicit preference, so
// flipping the system theme updates the dashboard without a reload.
useEffect(() => {
const mql = window.matchMedia('(prefers-color-scheme: dark)')
const apply = () => {
let saved: string | null = null
try {
saved = localStorage.getItem('codeburn-theme')
} catch {
// storage disabled: OS theme only
}
if (saved !== 'dark' && saved !== 'light') {
document.documentElement.classList.toggle('dark', mql.matches)
}
}
mql.addEventListener('change', apply)
return () => mql.removeEventListener('change', apply)
}, [])
const showCombined = multi && view === 'all'
const viewTitle = showCombined ? 'All devices' : (primary ? primary.name + (primary.local ? ' · this Mac' : '') : 'Loading…')
const label = local?.payload?.current?.label ?? ''
@ -466,7 +521,7 @@ export function App() {
return (
<div className="min-h-screen bg-outer-background p-2.5 max-md:min-h-[100dvh]">
<div className="flex h-[calc(100vh-20px)] flex-col gap-2.5 max-md:h-[calc(100dvh-20px)]">
<header className="flex h-12 shrink-0 items-center gap-4 rounded-md border border-border bg-card px-5 shadow-[0_2px_8px_rgba(0,0,0,0.03)] max-md:gap-3 max-md:px-3">
<header className="flex h-12 shrink-0 items-center gap-4 rounded-md border border-border bg-card px-5 shadow-[0_2px_8px_rgba(0,0,0,0.03)] dark:shadow-[0_2px_8px_rgba(0,0,0,0.5)] max-md:gap-3 max-md:px-3">
<button
type="button"
onClick={() => setSidebarOpen(true)}
@ -482,7 +537,7 @@ export function App() {
<div className="flex items-center gap-2 max-md:shrink-0">
<img src="/codeburn-logo.png" alt="CodeBurn" className="h-6 w-6" />
<span className="text-lg font-semibold tracking-[-0.02em] text-foreground">
Code<span className="text-[#e8553a]">Burn</span>
Code<span className="text-brand">Burn</span>
</span>
<span className="ml-1 text-[11px] font-light uppercase tracking-[0.14em] text-tertiary-foreground max-sm:hidden">usage</span>
</div>
@ -550,6 +605,7 @@ export function App() {
</select>
</>
)}
<ThemeToggle />
</div>
</header>
@ -638,7 +694,7 @@ export function App() {
type="checkbox"
checked={shareInfo.always}
onChange={() => void toggleAlways()}
className="h-3.5 w-3.5 accent-[#1f8a5b]"
className="h-3.5 w-3.5 accent-primary"
/>
Keep sharing always
</label>

View file

@ -106,7 +106,7 @@ function SessionDetails({ provider, id }: { provider: ContextProvider; id: strin
<span className="tabular-nums">{pct}%</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-interactive-secondary">
<div className={cn('h-full rounded-full', pct >= 80 ? 'bg-[#c8541f]' : 'bg-primary')} style={{ width: `${pct}%` }} />
<div className={cn('h-full rounded-full', pct >= 80 ? 'bg-chart-5' : 'bg-primary')} style={{ width: `${pct}%` }} />
</div>
</div>
)}

View file

@ -115,7 +115,7 @@ export function DeviceSearchModal({ onClose, onPaired }: { onClose: () => void;
)}
{status && <p className="mt-3 text-xs text-tertiary-foreground">{status}</p>}
{error && <p className="mt-3 text-xs text-[#b5403a]">{error}</p>}
{error && <p className="mt-3 text-xs text-chart-8">{error}</p>}
</div>
</div>
</div>

View file

@ -26,7 +26,7 @@ function makeTooltip(labels: Record<string, string>, fmt: (n: number) => string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const total = items.reduce((s: number, p: any) => s + p.value, 0)
return (
<div className="rounded-lg border border-border bg-popover px-3 py-2 text-xs shadow-xl ring-1 ring-black/5">
<div className="rounded-lg border border-border bg-popover px-3 py-2 text-xs shadow-xl ring-1 ring-border">
<div className="mb-1.5 font-medium text-foreground">{formatPeriod(String(lbl))}</div>
<div className="flex flex-col gap-1">
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
@ -252,7 +252,7 @@ function StackedBars({
tick={{ fontSize: 11, fill: 'var(--color-tertiary-foreground)' }}
tickFormatter={axisFmt}
/>
<Tooltip cursor={{ fill: 'rgba(0,0,0,0.04)' }} content={<Tip />} />
<Tooltip cursor={{ fill: 'var(--chart-hover-cursor)' }} content={<Tip />} />
{series.map((s, i) => (
<Bar
key={s.key}

View file

@ -35,6 +35,7 @@
--primary-foreground: #ffffff;
--ring: #1f8a5b;
--positive: #1f8a5b;
--brand: #e8553a;
--chart-1: #1f8a5b;
--chart-2: #4fd394;
@ -47,10 +48,59 @@
--chart-9: #3f8f6b;
--chart-10: #a98b4f;
--chart-grid-stroke: rgba(23, 27, 32, 0.07);
--chart-hover-cursor: rgba(0, 0, 0, 0.04);
color-scheme: light;
}
/*
* Dark surface: reuses the Electron app's dark tokens (app/renderer/styles/
* plain.css) so the dashboard and the desktop shell stay visually consistent.
* Chart colors are re-derived for dark-background contrast. The .dark class is
* applied to <html> by index.html before first paint and toggled in the header.
*/
.dark {
color-scheme: dark;
--background: #0e1013;
--outer-background: #0e1013;
--foreground: #e8eaee;
--card: #16181d;
--card-foreground: #e8eaee;
--popover: #16181d;
--popover-foreground: #e8eaee;
--muted: #1a1d22;
--muted-foreground: #959ca8;
--tertiary-foreground: #8b93a1;
--heading: #7aa86f;
--border: #282c33;
--input: #282c33;
--interactive-secondary: rgba(255, 255, 255, 0.06);
--interactive-secondary-hover: rgba(255, 255, 255, 0.1);
--active-primary: #2a2f38;
--accent: #1a1d22;
--accent-foreground: #e8eaee;
--subtle: #8b93a1;
--primary: #3ecf8e;
--primary-foreground: #0e1013;
--ring: #3ecf8e;
--positive: #3ecf8e;
--brand: #f2701c;
--chart-1: #3ecf8e;
--chart-2: #7ce0b0;
--chart-3: #2f9e6e;
--chart-4: #e8b93e;
--chart-5: #f2701c;
--chart-6: #5b9bef;
--chart-7: #8bb6a0;
--chart-8: #f26d6d;
--chart-9: #4fbf93;
--chart-10: #d5b26a;
--chart-grid-stroke: rgba(255, 255, 255, 0.08);
--chart-hover-cursor: rgba(255, 255, 255, 0.06);
}
@theme inline {
--color-background: var(--background);
--color-outer-background: var(--outer-background);
@ -75,6 +125,7 @@
--color-primary-foreground: var(--primary-foreground);
--color-ring: var(--ring);
--color-positive: var(--positive);
--color-brand: var(--brand);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);

View file

@ -36,11 +36,11 @@ export function compactUsd(n: number): string {
return sign + '$' + Math.round(a)
}
// Forest green -> gold -> terracotta ramp for stacked series (mirrors the
// --chart-* tokens). Warm and on-brand, distinct enough to read when stacked.
// Forest green -> gold -> terracotta ramp for stacked series. Referenced as CSS
// custom properties so the palette follows the active theme (light or dark).
export const CHART_COLORS = [
'#1f8a5b', '#4fd394', '#2c5242', '#d99a3c', '#c8541f',
'#2f5fd0', '#7aa86f', '#b5403a', '#3f8f6b', '#a98b4f',
'var(--chart-1)', 'var(--chart-2)', 'var(--chart-3)', 'var(--chart-4)', 'var(--chart-5)',
'var(--chart-6)', 'var(--chart-7)', 'var(--chart-8)', 'var(--chart-9)', 'var(--chart-10)',
]
const MODEL_LABELS: Record<string, string> = {

View file

@ -177,13 +177,21 @@ The `prepublishOnly` hook in `package.json` runs `npm run build` so `npm publish
## Tests
`npm test` runs vitest. Forty-two test files live under `tests/`:
`npm test` runs vitest, scoped to `tests/`. 192 test files live there:
- `tests/` root (27 files) covers CLI, parser, optimize, cache, format, models, plans.
- `tests/` root (141 files) covers CLI, parser, optimize, cache, format, models, plans.
- `tests/security/` (1 file) covers prototype-pollution guards.
- `tests/providers/` (15 files) covers per-provider parsing.
- `tests/providers/` (44 files) covers per-provider parsing.
- `tests/sharing/` (6 files) covers the share/export surface.
- `tests/setup/` holds the env-isolation setup file, not specs.
- `tests/fixtures/` holds redacted real-world session data.
Five providers ship without dedicated test files today: `antigravity`, `claude`, `gemini`, `goose`, `qwen`. Closing this gap is a standing good-first-issue.
The scope is deliberate: the Electron app under `app/` has its own vitest config and its
own `jsdom` dependency, so vitest's default glob must not reach it from a root install.
The three `cache-refresh-lock` suites are excluded from `npm test` and run serially via
`npm run test:locks`, because they exercise a cross-process file lock and fail under full
worker pressure.
CI runs Semgrep against `.semgrep/rules/no-bracket-assign-hot-paths.yml` over `src/providers/` and `src/parser.ts` (`.github/workflows/ci.yml`). It does not run vitest in CI today; tests run locally before publish.
Three providers ship without dedicated test files today: `claude`, `goose`, `qwen`. Closing this gap is a standing good-first-issue.
CI runs Semgrep against `.semgrep/rules/no-bracket-assign-hot-paths.yml` over `src/providers/` and `src/parser.ts` (`.github/workflows/ci.yml`). The vitest suite runs in CI too, via `.github/workflows/tests.yml`, on every pull request and every push to `main`.

View file

@ -184,7 +184,7 @@ Keep `main.ts:477484` (the `daysSelection`/`customRange`/`daySelection`/`peri
- [ ] **Step 4: Typecheck + parity test**
Run: `npx tsc --noEmit && npm test -- cli-status-menubar`
Run: `npx tsc --noEmit && npx vitest run cli-status-menubar`
Expected: clean typecheck; `tests/cli-status-menubar.test.ts` passes — i.e. `status --format menubar-json` output is unchanged (parity).
- [ ] **Step 5: Add a direct unit test for the aggregator**
@ -207,7 +207,7 @@ describe('buildMenubarPayloadForRange', () => {
})
```
Run: `npm test -- usage-aggregator`
Run: `npx vitest run usage-aggregator`
Expected: PASS (uses the empty real environment; `scanAndDetect` not called because `optimize:false`).
- [ ] **Step 6: Commit**
@ -272,7 +272,7 @@ describe('redact', () => {
- [ ] **Step 2: Run to verify failure**
Run: `npm test -- mcp-redact`
Run: `npx vitest run mcp-redact`
Expected: FAIL ("Cannot find module '../src/mcp/redact.js'").
- [ ] **Step 3: Implement**
@ -302,7 +302,7 @@ export function redactProjectNames(payload: MenubarPayload, includeNames: boolea
- [ ] **Step 4: Run to verify pass**
Run: `npm test -- mcp-redact`
Run: `npx vitest run mcp-redact`
Expected: PASS (3 tests).
- [ ] **Step 5: Commit**
@ -370,7 +370,7 @@ describe('tables', () => {
- [ ] **Step 2: Run to verify failure**
Run: `npm test -- mcp-tables`
Run: `npx vitest run mcp-tables`
Expected: FAIL ("Cannot find module '../src/mcp/tables.js'").
- [ ] **Step 3: Implement**
@ -434,7 +434,7 @@ export function renderSavingsTable(p: MenubarPayload): string {
- [ ] **Step 4: Run to verify pass**
Run: `npm test -- mcp-tables`
Run: `npx vitest run mcp-tables`
Expected: PASS (4 tests).
- [ ] **Step 5: Commit**
@ -521,7 +521,7 @@ describe('mcp server', () => {
- [ ] **Step 2: Run to verify failure**
Run: `npm test -- mcp-server`
Run: `npx vitest run mcp-server`
Expected: FAIL ("Cannot find module '../src/mcp/server.js'").
- [ ] **Step 3: Implement the server**
@ -660,7 +660,7 @@ export async function startStdioServer(version: string): Promise<void> {
- [ ] **Step 4: Run to verify pass**
Run: `npm test -- mcp-server`
Run: `npx vitest run mcp-server`
Expected: PASS (5 tests).
- [ ] **Step 5: Commit**

View file

@ -0,0 +1,46 @@
# New provider checklist
Guide for adding a new session-discovery provider to codeburn. Follow every item; most exist because a past provider broke without them.
## One provider, one product
- [ ] A provider is one product. Same-vendor IDE and CLI products get separate providers.
- [ ] Precedents: `kimi` / `kimicode`, `cursor` / `cursor-agent`, `cline` / `cline-cli`.
- [ ] Do not merge products under one provider name. `PROVIDER_PARSE_VERSIONS` and `PROVIDER_ENV_VARS` are keyed by provider name, so merging couples cache invalidation across products.
- [ ] Separate providers also keep `codeburn doctor` output legible.
## Required pieces
- [ ] `src/providers/<name>.ts` implementing the Provider contract.
- [ ] Registration in `src/providers/index.ts` `coreProviders` (or the lazy list).
- [ ] `PROVIDER_ENV_VARS` entry in `src/session-cache.ts` when discovery reads env overrides.
- [ ] `PROVIDER_PARSE_VERSIONS` entry when cached entries must re-parse after parser changes.
- [ ] `probeRoots()` is required for new providers, not optional. `codeburn doctor` uses it to tell "not installed" from "override points somewhere empty" - the silent-$0.00 class (#874, #899).
## Cost rules
- [ ] If the tool meters its own per-message cost, add the provider to the reported-cost allowlist in `src/parser.ts` (`providerCallToCachedCall`).
- [ ] Cost presence is a PRESENCE check, not truthiness: a metered $0 stays reported (free/cached calls).
- [ ] Computed costs go through `calculateCost` and set `costIsEstimated: true`.
## Parsing rules
- [ ] Defensive reads on every field - records may be any JSON.
- [ ] Dedup keys namespaced as `<provider>:<sessionId>:<messageId>`.
- [ ] Timestamps guard against seconds-vs-milliseconds: promote and reject implausible values (see `kiro.ts` / `cline-cli.ts`).
- [ ] Never let one corrupt file throw - skip it.
## Tests
- [ ] Fixture-based tests under `tests/providers/<name>.test.ts` covering discovery, parsing, cost semantics, and `probeRoots` resolution.
- [ ] The suite scrubs env in `tests/setup/env-isolation.ts`, so tests set their own overrides.
## PR expectations
- [ ] Proof of real local testing in the PR body: generated sessions from the actual tool, not only fixtures.
- [ ] No Claude/Anthropic co-author trailers - CI rejects them.
- [ ] Docs page under `docs/providers/<name>.md` describing the storage layout and any quirks.
## Fastest path
Read `src/providers/cline-cli.ts` end to end first. It is the most recent provider and demonstrates every rule above.

View file

@ -12,6 +12,7 @@ For the architectural picture, see `../architecture.md`.
|---|---|---|---|
| [Claude](claude.md) | JSONL (no parser) | `src/providers/claude.ts` | none (covered indirectly) |
| [Cline](cline.md) | JSON | `src/providers/cline.ts` | `tests/providers/cline.test.ts` |
| [Cline CLI](cline-cli.md) | JSON | `src/providers/cline-cli.ts` | `tests/providers/cline-cli.test.ts` |
| [CodeWhale](codewhale.md) | JSON | `src/providers/codewhale.ts` | `tests/providers/codewhale.test.ts` |
| [Codex](codex.md) | JSONL | `src/providers/codex.ts` | `tests/providers/codex.test.ts` |
| [Copilot](copilot.md) | JSONL + SQLite (OTel) + Nitrite .db (JetBrains) | `src/providers/copilot.ts` | `tests/providers/copilot.test.ts` |
@ -27,6 +28,7 @@ For the architectural picture, see `../architecture.md`.
| [LingTai TUI](lingtai-tui.md) | JSONL | `src/providers/lingtai-tui.ts` | `tests/providers/lingtai-tui.test.ts` |
| [Mistral Vibe](mistral-vibe.md) | JSON / JSONL | `src/providers/mistral-vibe.ts` | `tests/providers/mistral-vibe.test.ts` |
| [OpenClaw](openclaw.md) | JSONL | `src/providers/openclaw.ts` | `tests/providers/openclaw.test.ts` |
| [OpenClaude](openclaude.md) | JSONL | `src/providers/openclaude.ts` | `tests/providers/openclaude.test.ts` |
| [Pi](pi.md) | JSONL | `src/providers/pi.ts` | `tests/providers/pi.test.ts` |
| [OMP](omp.md) | JSONL | `src/providers/pi.ts` | `tests/providers/omp.test.ts` |
| [Qwen](qwen.md) | JSONL | `src/providers/qwen.ts` | none |

View file

@ -12,11 +12,26 @@ Anthropic Claude Code CLI and Claude Desktop's local agent mode.
|---|---|
| Claude Code CLI | `$CLAUDE_CONFIG_DIR` if set, otherwise `~/.claude/projects/` |
| Claude Desktop (macOS) | `~/Library/Application Support/Claude/local-agent-mode-sessions/` |
| Claude Desktop (Windows) | `%APPDATA%/Claude/local-agent-mode-sessions/` |
| Claude Desktop (Windows, classic) | `%APPDATA%/Claude/local-agent-mode-sessions/` |
| Claude Desktop (Windows, MSIX) | `%LOCALAPPDATA%/Packages/<Claude package>/LocalCache/Roaming/Claude/local-agent-mode-sessions/` |
| Claude Desktop (Linux) | `~/.config/Claude/local-agent-mode-sessions/` |
For Desktop, `findDesktopProjectDirs` walks up to 8 levels deep looking for `projects/` subdirectories, skipping `node_modules` and `.git`.
Desktop session roots are resolved in this order:
1. A non-empty `CODEBURN_DESKTOP_SESSIONS_DIR` overrides discovery and is the
only returned root.
2. macOS uses the single path shown above.
3. Windows always includes the classic path first. It then scans
`%LOCALAPPDATA%/Packages` for package directories whose names start with
`Claude_` or contain `.Claude_`, sorted by package name, and includes only
packages whose full MSIX sessions path exists as a directory.
4. Other platforms use the single Linux path shown above.
All returned roots are absolute, resolved, and deduplicated. Missing or
unreadable Windows package directories are ignored.
## Storage format
JSONL, one event per line, per session file. Sessions live under `<project>/<sessionId>.jsonl`.

View file

@ -0,0 +1,58 @@
# Cline CLI
The Cline command-line agent (npm `cline`, 3.x). Separate from the [Cline](cline.md) provider, which reads the VS Code extension's task tree.
- **Source:** `src/providers/cline-cli.ts`
- **Loading:** eager (`src/providers/index.ts`)
- **Test:** `tests/providers/cline-cli.test.ts`
## Where it reads from
One root, resolved exactly as the CLI resolves it — each level independently overridable:
| Level | Env var | Default |
|---|---|---|
| sessions | `CLINE_SESSION_DATA_DIR` | `<data>/sessions` |
| data | `CLINE_DATA_DIR` | `<root>/data` |
| root | `CLINE_DIR` | `~/.cline` |
A directory is a session only when it contains `<sessionId>/<sessionId>.json`. `probeRoots()` reports the resolved sessions dir, so `codeburn doctor` distinguishes "CLI not installed" from "override pointing somewhere else".
## Storage format
```
sessions/<sessionId>/
<sessionId>.json metadata + rolled-up usage
<sessionId>.messages.json per-message metrics
```
`<sessionId>.json` carries `session_id`, `provider`, `model`, `cwd`, `workspace_root`, `started_at` / `ended_at`, `messages_path`, and a `metadata.usage` rollup (`inputTokens`, `outputTokens`, `cacheReadTokens`, `cacheWriteTokens`, `totalCost`).
`<sessionId>.messages.json` holds `{ version, updated_at, agent, sessionId, messages[], system_prompt }`. Assistant messages carry Anthropic-style content blocks (`thinking` / `text` / `tool_use`) plus:
```jsonc
"modelInfo": { "id": "z-ai/glm-5.2", "provider": "cline-pass" },
"metrics": { "inputTokens": 6937, "outputTokens": 213,
"cacheReadTokens": 0, "cacheWriteTokens": 0, "cost": 0.002108502 }
```
One `metrics` block becomes one parsed call. Dedup key: `cline-cli:<sessionId>:<messageId>`.
## Caching
None at the provider level; the metadata file is the cached source path and the normal parser/cache layers apply.
## Quirks
- **`provider` in the session file is the upstream LLM route** (e.g. `cline-pass`), not the tool. The codeburn provider name is always `cline-cli`.
- **Model strings are not normalized by the CLI.** The same model appears as `z-ai/glm-5.2`, `cline-pass/glm-5.2`, and `GLM-5.2` across sessions, so pricing lookups may need a `model-alias`.
- **Cost is reported per message**, so `costIsEstimated` is false on the normal path; it falls back to `calculateCost` only when a message omits `cost`.
- **Rollup fallback.** A session whose messages carry no metrics (interrupted, or an older layout) emits a single call from `metadata.usage`. This reads `usage`, deliberately *not* `aggregateUsage` / `aggregatedAgentsCost`, which fold in spawned subagents that are themselves separate session directories and would double count.
- **`messages_path` is absolute** and goes stale when a session directory is copied between machines, so the co-located `<sessionId>.messages.json` is preferred and `messages_path` is only the fallback.
- **Tool names differ from the extension's.** `run_commands`, `read_files`, `search_codebase`, `editor`, `apply_patch`, `fetch_web_content`, `skills`, `spawn_agent`, and the `team_*` family. `run_commands` carries a JSON-encoded array of command lines in a single string field.
## When fixing a bug here
1. Reproduce with a minimal session directory: `<id>.json` plus `<id>.messages.json`.
2. Run `tests/providers/cline-cli.test.ts`.
3. This provider shares no code with `vscode-cline-parser.ts` — changes here cannot affect Cline, Roo Code, KiloCode, or IBM Bob.

View file

@ -2,18 +2,20 @@
Cline VS Code extension and Cline home-data task storage.
Sessions from the Cline **command-line** agent use an unrelated layout and are handled by [Cline CLI](cline-cli.md); this provider does not see them.
- **Source:** `src/providers/cline.ts`
- **Loading:** eager (`src/providers/index.ts:2`)
- **Test:** `tests/providers/cline.test.ts`
## Where it reads from
Two task roots are scanned:
These task roots are scanned:
1. VS Code extension globalStorage for `saoudrizwan.claude-dev`.
1. VS Code extension globalStorage for `saoudrizwan.claude-dev`, in every supported VS Code variant: stable (`Code`), Insiders (`Code - Insiders`), and VSCodium. The per-platform paths come from `getVSCodeGlobalStoragePaths` in `src/providers/vscode-cline-parser.ts`, the same helper Roo Code and KiloCode use.
2. Cline's home-data root at `~/.cline/data`.
Both roots are expected to contain a `tasks/` child directory. Discovery is delegated to `discoverClineTasks` in `src/providers/vscode-cline-parser.ts`, so a task is only included when it has a `ui_messages.json` file.
Every root is expected to contain a `tasks/` child directory. Discovery is delegated to `discoverClineTasks` in `src/providers/vscode-cline-parser.ts`, so a task is only included when it has a `ui_messages.json` file.
## Storage format
@ -35,12 +37,13 @@ None at the provider level; delegates to the shared helper and normal parser/cac
## Deduplication
Discovery deduplicates by task id across the two Cline roots so a migrated task is not scanned twice. If the same task id exists in multiple roots, the one with the newest `ui_messages.json` wins. Parsing still uses the shared per-call key: `<providerName>:<taskId>:<index>`.
Discovery deduplicates by task id across all Cline roots so a task that exists in more than one root (a migration, or the same extension storage seen by two VS Code variants) is not scanned twice. If the same task id exists in multiple roots, the one with the newest `ui_messages.json` wins. Parsing still uses the shared per-call key: `<providerName>:<taskId>:<index>`.
## Quirks
- This provider is intentionally a thin wrapper over the shared Cline-family parser.
- Cline can keep data in both VS Code globalStorage and `~/.cline/data`, depending on version and workflow.
- A user can run Cline in VS Code stable, Insiders, and VSCodium at the same time; each variant has its own globalStorage tree, so all of them must be scanned.
- If Cline changes the JSON shape, fix `vscode-cline-parser.ts` only if Roo Code and KiloCode still pass. Branch provider-specific parsing rather than duplicating the whole parser.
## When fixing a bug here

View file

@ -104,5 +104,5 @@ parser key is `codewhale:<session-id>`.
1. Reproduce with a minimal real-shape saved-session JSON fixture.
2. Verify aggregate tokens and parent-plus-subagent cost before checking UI
totals; do not infer an input/output split CodeWhale does not store.
3. Run `npm test -- tests/providers/codewhale.test.ts --run` and
`npm test -- tests/provider-registry.test.ts tests/session-cache.test.ts --run`.
3. Run `npx vitest run tests/providers/codewhale.test.ts` and
`npx vitest run tests/provider-registry.test.ts tests/session-cache.test.ts`.

View file

@ -4,7 +4,7 @@ OpenAI Codex CLI.
- **Source:** `src/providers/codex.ts`
- **Loading:** eager (`src/providers/index.ts:2`)
- **Test:** `tests/providers/codex.test.ts` (374 lines)
- **Test:** `tests/providers/codex.test.ts` (1075 lines)
## Where it reads from
@ -24,7 +24,11 @@ The active-session discovery walk uses strict regex (`^\d{4}$`, `^\d{2}$`) on ea
## Storage format
JSONL. The first line must be a `session_meta` entry with `payload.originator` starting with `codex` (case-insensitive). Files that fail this check are silently skipped.
JSONL. Validation of the first line is **structural**: it must parse as JSON, have `type === "session_meta"`, and carry a `payload` that is a plain object (not missing, not a scalar, not an array). Files that fail this check are silently skipped.
`payload.originator` is deliberately **not** part of the check. It is a free-form client identity string, not a format marker: Codex CLI writes `codex-tui` / `codex_exec` / `codex_cli_rs`, Codex Desktop writes `Codex Desktop`, and third-party frontends driving `codex app-server` write their own values (`t3code_desktop`, `JetBrains.IntelliJ IDEA`, ...) into structurally identical rollouts. Gating discovery on the spelling silently dropped those sessions from every report and required a new allowlist entry per client (issues #626, #873). Directory ownership decides the provider instead: `codex.ts` is the only provider that reads `~/.codex`, and the walk only visits `rollout-*.jsonl` under the strict `YYYY/MM/DD` path or `archived_sessions/`. `originator` is still parsed into the session meta entry, but nothing downstream reads it.
Because admission no longer implies a known client, every payload field is treated as untrusted JSON. `payload.cwd` in particular is type-guarded before it reaches `sanitizeProject` (discovery) or `projectPath`/`workingDirectory` (parse): a non-string `cwd` falls back to the `unknown` project instead of throwing out of `discoverSessions`, which `safeDiscoverSessions` would have turned into an empty session list for the *entire* provider.
The first line read is capped at 1 MB (`FIRST_LINE_READ_CAP`). Codex CLI 0.128+ embeds the full system prompt in `session_meta`, which can run 20-27 KB; the cap leaves headroom while bounding memory if a corrupt file has no newline.
@ -48,6 +52,110 @@ A session that yielded zero parseable lines does **not** write to the cache (`co
- `prev*` token counters are advanced on **every** event, including ones that used `last_token_usage`. Earlier code only updated them on the fallback branch, which double-counted any session that mixed modes.
- OpenAI counts cached tokens **inside** `input_tokens`. The parser subtracts them so the rest of the codebase can assume Anthropic semantics (cached are separate).
## Live quota (ChatGPT subscription)
Separate from the log parser above: the desktop app and the macOS menubar read
live quota from `GET https://chatgpt.com/backend-api/wham/usage` using the Codex
OAuth token. Two independent implementations of the same decoder, which must be
kept in sync:
- `app/electron/quota/codex.ts`: `decodeCodexUsage()` is the pure, exported decoder.
- `mac/Sources/CodeBurnMenubar/Data/CodexSubscriptionService.swift`: `decodeUsage()`.
### Seat-based plans (Plus, Pro, Team)
`rate_limit.primary_window` / `secondary_window` carry `used_percent`,
`reset_at` and `limit_window_seconds`. The window *label* is inferred from the
duration (5-hour, Weekly, …), never from the plan, because window size is dynamic per
account. `additional_rate_limits[]` holds per-model limits (Codex Spark, etc.)
and is only surfaced when utilization is non-zero.
### Credit-metered plans (Business, Edu, Enterprise on flexible pricing)
These workspaces have **no rate-limit windows**: `rate_limit` comes back
`null`. Usage scales with credits, and an admin sets a monthly per-user credit
allowance. That allowance is the account's only limit and lives in
`spend_control`:
```jsonc
"spend_control": {
"reached": false,
"individual_limit": {
"source": "workspace_spend_controls",
"limit": "10000", // string
"used": "3028.9909675121307", // string
"used_percent": 30, // number
"remaining_percent": 70,
"reset_after_seconds": 441896, // time *remaining*, not window length
"reset_at": 1785542400
}
}
```
Notes that have bitten us:
- **Number encodings are mixed within the same object**: `limit` and `used`
arrive as strings while `used_percent` arrives as a number. Every numeric
field is decoded flexibly (number | string) on both sides.
- **`reset_after_seconds` is not the window length.** Pace projection needs the
whole-window duration, so it is derived as the calendar month preceding
`reset_at`, resolved in **UTC**: `reset_at` is a UTC boundary, and a local
calendar would make the month length depend on the viewer's timezone (a
2026-03-01Z reset spans 28 days in UTC but 31 in Toronto).
- Two other positions for this object have been observed in other clients
(top-level `individual_limit`, and nested under `rate_limit`), in both
snake_case and camelCase. All are accepted; `spend_control` wins.
- `credits.has_credits` means the account settles in **credits, not dollars**, so
`credits.balance` must not be rendered with a currency symbol in that case.
`credits.unlimited` means credit-metered but deliberately uncapped.
- **`has_credits` is not "is credit-metered".** The live Enterprise workspace
above is credit-metered (it has a `spend_control` allowance) yet reports
`has_credits: false` with a `null` balance, so the flag tracks whether the
account holds a *credit balance*, which is orthogonal to the allowance. Do not
derive one from the other. The `has_credits: true` rendering path has not been
observed against a real account; if a seat-based account ever reports it
alongside a dollar balance, the footer would drop the `$` and round to whole
units.
### `plan_type` cannot distinguish Business from Enterprise
A live ChatGPT **Enterprise** workspace reports `plan_type: "business"` on this
endpoint, and the `id_token`'s `https://api.openai.com/auth → chatgpt_plan_type`
claim says `"business"` too, even though ChatGPT's own workspace switcher
displays "Enterprise". Neither source carries the distinction, so the label
CodeBurn shows is faithfully what OpenAI returns. Do not try to infer a tier
from the presence of a spend control.
The switcher renders from the accounts endpoints, and **those are not reachable
with a Codex token**, verified against a live Enterprise workspace:
| Endpoint | Result |
| --- | --- |
| `/backend-api/accounts/check/v4-2023-04-27` | 403 |
| `/backend-api/accounts/check` | 403 |
| `/backend-api/me` | 403 |
| `/backend-api/settings/account_user_setting` | 403 |
Not an expiry or a missing-header problem: the same token returns 200 on
`/wham/usage` (and on `/backend-api/gizmo_creator_profile`) in the same run. The
Codex OAuth access token carries scopes `openid profile email offline_access
api.connectors.read api.connectors.invoke` with audience
`https://api.openai.com/v1`, with no ChatGPT web-app account scope, so the accounts
surfaces reject it by design. Adding a `ChatGPT-Account-Id` header does not
change this. **Business is therefore the correct label to display**; closing
this gap would need a different credential, not a different endpoint.
Composite tiers (`enterprise_cbp_usage_based`, `self_serve_business_usage_based`)
*are* normalized down to their base tier before lookup.
### Reset credits
`rate_limit_reset_credits` is carried inline on the usage payload
(`available_count`). The dedicated `GET /wham/rate-limit-reset-credits`
endpoint is only called when the inline block is absent. It is the sole source
of per-credit `expires_at` values, so the "next expires" caption is omitted on
the inline path.
## When fixing a bug here
1. Reproduce against a real `rollout-*.jsonl` if you can. Drop a redacted copy under `tests/fixtures/codex/` and reference it from `tests/providers/codex.test.ts`.

View file

@ -62,6 +62,6 @@ The shared session cache fingerprints Hermes state DB files. `HERMES_HOME` is in
## When fixing a bug here
1. Reproduce against a real Hermes `state.db` or a minimal SQLite fixture.
2. Run `npm test -- tests/providers/hermes.test.ts --run`.
2. Run `npx vitest run tests/providers/hermes.test.ts`.
3. For local smoke testing, use an isolated cache directory, for example:
`CODEBURN_CACHE_DIR=/tmp/codeburn-hermes-cache node --import tsx -e "import { parseAllSessions } from './src/parser.ts'; console.log(await parseAllSessions(undefined, 'hermes'))"`.

View file

@ -8,14 +8,23 @@ MoonshotAI Kimi Code local session usage and tool activity.
## Where it reads from
The provider reads `~/.kimi-code` by default and honors the Kimi Code CLI's `KIMI_CODE_HOME` environment variable. It scans:
By default the provider scans every known Kimi Code runtime store:
```text
$KIMI_CODE_HOME/sessions/wd_*/session_*/
~/.kimi-code
~/Library/Application Support/kimi-desktop/daimon-share/daimon/runtime/kimi-code/home (Kimi desktop / IDE embedded runtime)
```
Setting `KIMI_CODE_HOME` (or passing a home override) narrows the scan to that single home. Inside each home it scans:
```text
$HOME/sessions/wd_*/<session-dir>/
├── state.json
└── agents/<agent-id>/wire.jsonl
```
Session directory naming depends on the host product: the CLI uses `session_*`, embedded runtimes use `conv-*` / `ctitle-*`. Any directory is accepted; the `agents/*/wire.jsonl` probe gates real sessions.
Every agent wire is a cache source. Main-agent and subagent calls share the session ID from the `session_*` directory. `state.json.workDir` supplies the project name and path. `probeRoots()` reports the resolved Kimi Code home for `codeburn doctor` even when there are no sessions.
## Storage format

View file

@ -85,4 +85,4 @@ The ledger is append-only, so line number is stable for normal operation.
1. Prefer a minimal redacted `token_ledger.jsonl` fixture over full `chat_history.jsonl`.
2. Check whether a daemon entry is already mirrored into the parent ledger before adding new discovery paths.
3. Run `npm test -- tests/providers/lingtai-tui.test.ts --run`.
3. Run `npx vitest run tests/providers/lingtai-tui.test.ts`.

View file

@ -0,0 +1,33 @@
# OpenClaude
OpenClaude (npm `@gitlawb/openclaude`) is a Claude Code fork that runs the same
agent loop against any LLM backend (DeepSeek, OpenAI-compatible endpoints,
Gemini, Ollama, ...). Because it is a fork, its transcripts are Claude-Code
schema and its tool names are already codeburn-canonical.
## Storage layout
```
~/.openclaude/projects/<project-slug>/<uuid>.jsonl transcript
~/.openclaude/projects/<project-slug>/<uuid>.replay.json replay state (skipped)
```
`CODEBURN_OPENCLAUDE_DIR` overrides the root (projects live under
`<root>/projects`).
## Quirks
- Only `assistant` lines carrying `message.usage` become calls; the
`queue-operation` / `last-prompt` bookkeeping lines and user lines are
skipped.
- Usage is Anthropic-shaped; there is NO cost field, so every call is priced
through the shared tables and always carries `costIsEstimated: true`.
- `isSidechain: true` lines are subagent traffic inside the same transcript
and are counted: their usage is real spend.
- The model id is whatever the routed backend reports (e.g. `deepseek-chat`),
so pricing accuracy tracks the shared litellm tables.
- Tool attribution is partial by construction: streamed responses can split
tool_use blocks across assistant events, and only usage-bearing events are
parsed. Cost and token accounting are exact; tool breakdowns are a floor.
- The project name prefers the basename of the first `cwd` seen in the
transcript; the project-slug directory is the fallback.

View file

@ -57,6 +57,6 @@ Per `<sessionId>:<messageId>`.
## When fixing a bug here
1. The 558-line test suite catches a lot. Run `npm test -- tests/providers/opencode.test.ts` before and after any change.
1. The 558-line test suite catches a lot. Run `npx vitest run tests/providers/opencode.test.ts` before and after any change.
2. If the bug is "missing table" warning, do not catch and silence it. Either upgrade the version expectation in the parser or document the breaking schema change.
3. If the bug is "reasoning tokens off by one", check the parts index ordering.

View file

@ -48,6 +48,9 @@ codeburn sync push --since 30d
# Preview what would be sent
codeburn sync push --dry-run
# Also push git attribution (opt-in — see "Git attribution" below)
codeburn sync push --attribution
```
### `codeburn sync status`
@ -95,6 +98,36 @@ Each AI interaction becomes one OTLP span with these attributes:
A pseudonymous `device_id` distinguishes your machines without revealing hostnames.
### Git attribution (opt-in: `--attribution`)
`codeburn sync push --attribution` additionally sends the session→commit correlation that `codeburn yield` computes locally, so the backend can join AI usage to git activity without git hooks. Two extra span types are emitted:
**`codeburn.session.attribution`** — one per session with joinable evidence:
| Field | Example | Description |
|---|---|---|
| `ai.session_id` | `abc123…` | Session (shares the usage spans' traceId) |
| `ai.project` | `my-app` | Project name |
| `git.repo` | `github.com/acme/widget` | Normalized `origin` remote (credentials and ports stripped) |
| `git.pr_links` | `["…/pull/12"]` | PR URLs captured for the session |
| `git.commit_count` | `2` | Number of attributed commits |
**`codeburn.commit`** — one per commit attributed to a session:
| Field | Example | Description |
|---|---|---|
| `git.sha` | `4f2a…` | Commit SHA |
| `git.in_main` | `true` | Whether the commit landed in the main branch |
| `git.was_reverted` | `false` | Whether a later commit reverted it |
Attribution is **inferred** (timestamp-window correlation, the same heuristic as `codeburn yield`); the resource attribute `codeburn.attribution_methodology: timestamp-window` marks it as such. State transitions (a commit merging to main, or being reverted) are re-sent automatically on later pushes — receivers should upsert commits by `(git.repo, git.sha)` and session spans by `ai.session_id` (latest state wins). When a commit migrates to a later-parsed session with a tighter window, the losing session re-emits with `git.commit_count: 0` (a retraction), so summing `git.commit_count` across upserted session rows never double-counts. Retractions fire only when the commit was won by another session — commits that merely age out of the `--since` window are not retracted, so a previously-synced count stays correct. Session spans also re-emit when an ongoing session's window grows, keeping the span end time current.
With `--attribution`, normalized repo remote URLs, commit SHAs, commit timestamps (span start times), PR URLs, and the merged/reverted booleans leave your machine — plus the same pseudonymous `codeburn.device_id` resource attribute the usage spans carry. PR links are rebuilt client-side from scheme + host + path only (userinfo, query strings, and fragments are dropped; https, `/org/repo/pull/N` path, bounded length, max 20 per session), and the repo identity itself passes a strict hostname/path allow-list before sending — malformed or transport-helper remotes (`ext::…`, `codecommit::…`) are rejected outright rather than parsed. Precisely what is and is not sent:
- **Commits**: only from repos with a network `origin` remote, and only for sessions whose own project path resolved to that repo. Local-only repos, `file://` remotes, and Windows filesystem paths are never emitted as repo identities. A session whose project path no longer resolves never inherits the repo of the directory you happen to push from.
- **PR links**: sent whenever a session captured them, even when the session's repo could not be identified — the PR URL itself names the repo, so this adds no information beyond the link the session already recorded.
- Without the flag, none of this is sent.
### What is NOT sent
- **Prompts** — your actual messages to AI are never included
@ -102,7 +135,7 @@ A pseudonymous `device_id` distinguishes your machines without revealing hostnam
- **Bash commands** — may contain secrets, never sent
- **Your name/email** — identity is derived server-side from your login token
There is no flag to override this. Privacy is structural, not configurable.
There is no flag to override this. Privacy is structural, not configurable. The only additive opt-in is `--attribution` (repo remotes, commit SHAs, and PR URLs — never code or prompts), described above.
## Authentication

View file

@ -138,11 +138,19 @@ final class AppStore {
var codexError: String?
var codexLoadState: SubscriptionLoadState = CodexCredentialStore.isBootstrapCompleted ? .dormant : .notBootstrapped
var kimiUsage: KimiUsage?
var kimiError: String?
// No keychain dance for Kimi "connected" just means the CLI's
// credential file exists, so we start dormant and auto-activate on the
// first refresh tick.
var kimiLoadState: SubscriptionLoadState = KimiSubscriptionService.hasCredential ? .dormant : .notBootstrapped
/// Generation tokens for the in-flight refresh tasks. Incremented on every
/// disconnect / reset so a fetch that started before the disconnect cannot
/// resume after the await and re-populate the freshly-cleared state.
private var claudeRefreshGen: Int = 0
private var codexRefreshGen: Int = 0
private var kimiRefreshGen: Int = 0
private var cache: [PayloadCacheKey: CachedPayload] = [:]
private var cacheDate: String = ""
@ -263,6 +271,49 @@ final class AppStore {
cache[menubarStatusKey]?.payload
}
private var menubarCombinedKey: PayloadCacheKey {
PayloadCacheKey(scope: .combined, period: menubarPeriod, provider: .all, day: nil, claudeConfigSourceId: selectedClaudeConfigSourceId)
}
/// Cross-device totals for the menubar badge's period, used so the badge
/// figure matches the popover hero under combined scope. `nil` under local
/// scope, or when no combined payload for the badge period is cached yet
/// (cold start, or the peer is unreachable) the badge then falls back to
/// the local figure, exactly like the popover.
var menubarBadgeCombined: CombinedUsageTotals? {
guard effectiveSelectedScope == .combined else { return nil }
return cache[menubarCombinedKey]?.payload.combined?.combined
}
/// `(reachable, total)` only when combined scope is active and fewer paired
/// devices reported than are paired i.e. the badge total is degraded to
/// the reachable subset (a peer is asleep/off-network this cycle). The badge
/// shows this so a momentary drop to the local figure reads as "peer
/// unreachable", not a glitch. `nil` when every paired device reported (or
/// there is only one), and under local scope.
var menubarBadgeDeviceShortfall: (reachable: Int, total: Int)? {
guard let totals = menubarBadgeCombined, totals.reachableCount < totals.deviceCount else { return nil }
return (totals.reachableCount, totals.deviceCount)
}
/// Refresh the payloads the badge renders for `period`: always the local
/// figure, plus the combined cross-device total when combined scope is
/// active. Combined is best-effort a slow or unreachable peer degrades to
/// the local figure so the local fetch alone determines success.
@discardableResult
func refreshMenubarBadge(period: Period, force: Bool = false, qualityOfService: QualityOfService = .userInitiated) async -> Bool {
async let local = refreshQuietly(period: period, force: force, qualityOfService: qualityOfService)
guard effectiveSelectedScope == .combined else { return await local }
async let combined = refreshQuietly(
key: PayloadCacheKey(scope: .combined, period: period, provider: .all, day: nil, claudeConfigSourceId: selectedClaudeConfigSourceId),
includeOptimize: false,
force: force,
qualityOfService: qualityOfService
)
let (localSucceeded, _) = await (local, combined)
return localSucceeded
}
/// All-provider payload for the selected period. Used by the tab strip to show
/// per-provider costs that match the active period, not just today.
var periodAllPayload: MenubarPayload? {
@ -1104,6 +1155,90 @@ final class AppStore {
}
}
// MARK: - Kimi Code
/// Unlike Claude/Codex there is no keychain bootstrap: reading the CLI's
/// credential file is prompt-free, so the first refresh tick activates
/// the dormant state automatically.
func bootstrapKimi() async {
// Capture the generation before the await so a disconnect that lands
// mid-fetch cannot be resurrected into .loaded when the fetch returns.
let gen = kimiRefreshGen
kimiLoadState = .bootstrapping
do {
let usage = try await KimiSubscriptionService.refresh()
guard gen == kimiRefreshGen else { return }
kimiUsage = usage
kimiError = nil
kimiLoadState = .loaded
} catch let err as KimiSubscriptionService.FetchError {
guard gen == kimiRefreshGen else { return }
applyKimiFetchError(err)
} catch {
guard gen == kimiRefreshGen else { return }
kimiError = sanitizeForUI(String(describing: error))
kimiLoadState = .failed
}
}
func refreshKimi() async {
_ = await refreshKimiReportingSuccess()
}
@discardableResult
func refreshKimiReportingSuccess() async -> Bool {
if case .dormant = kimiLoadState {
await bootstrapKimi()
return kimiLoadState == .loaded
}
guard KimiSubscriptionService.hasCredential else {
if kimiLoadState != .notBootstrapped { kimiLoadState = .notBootstrapped }
return false
}
let gen = kimiRefreshGen
if kimiUsage == nil { kimiLoadState = .loading }
do {
let usage = try await KimiSubscriptionService.refresh()
guard gen == kimiRefreshGen else { return false }
kimiUsage = usage
kimiError = nil
kimiLoadState = .loaded
return true
} catch let err as KimiSubscriptionService.FetchError {
guard gen == kimiRefreshGen else { return false }
applyKimiFetchError(err)
return false
} catch {
guard gen == kimiRefreshGen else { return false }
kimiError = sanitizeForUI(String(describing: error))
kimiLoadState = .failed
return false
}
}
func disconnectKimi() {
KimiSubscriptionService.disconnect()
kimiRefreshGen &+= 1
kimiUsage = nil
kimiError = nil
kimiLoadState = .notBootstrapped
NotificationCenter.default.post(name: .codeBurnSubscriptionDisconnected, object: nil)
}
private func applyKimiFetchError(_ err: KimiSubscriptionService.FetchError) {
let sanitized = sanitizeForUI(err.errorDescription)
kimiError = sanitized
if case .noCredentials = err {
kimiLoadState = .noCredentials
} else if err.isTerminal {
kimiLoadState = .terminalFailure(reason: sanitized)
} else if let retryAt = err.rateLimitRetryAt {
kimiLoadState = .transientFailure(retryAt: retryAt)
} else {
kimiLoadState = .failed
}
}
private func applyFetchError(_ err: ClaudeSubscriptionService.FetchError) {
let sanitized = sanitizeForUI(err.errorDescription)
subscriptionError = sanitized
@ -1172,6 +1307,10 @@ final class AppStore {
let worst = max(usage.primary?.usedPercent ?? 0, usage.secondary?.usedPercent ?? 0)
if worst > 0 { providers.append(("Codex", worst)) }
}
if let usage = kimiUsage, shouldIncludeCachedQuota(loadState: kimiLoadState) {
let worst = max(usage.primary?.usedPercent ?? 0, usage.details.map(\.usedPercent).max() ?? 0)
if worst > 0 { providers.append(("Kimi Code", worst)) }
}
let worst = providers.map(\.percent).max() ?? 0
let severity = QuotaSummary.severity(for: worst / 100)
let sorted = providers.sorted { $0.percent > $1.percent }
@ -1192,6 +1331,7 @@ final class AppStore {
switch filter {
case .claude: return claudeQuotaSummary(filter: filter)
case .codex: return codexQuotaSummary(filter: filter)
case .kimiCode: return kimiQuotaSummary(filter: filter)
default: return nil
}
}
@ -1281,22 +1421,78 @@ final class AppStore {
details.append(.init(label: "\(extra.name) · \(s.windowLabel)", percent: s.usedPercent / 100, resetsAt: s.resetsAt))
}
}
// No rate windows here, so the allowance feeds the bar and badge.
if let credits = usage.creditLimit {
let row = QuotaSummary.Window(
label: credits.shortLabel,
percent: credits.usedPercent / 100,
resetsAt: credits.resetsAt
)
if primary == nil { primary = row }
details.append(row)
}
}
let plan = codexUsage?.plan.displayName
var footerLines: [String] = []
if let balance = codexUsage?.creditsBalance, balance > 0 {
// Format as plain dollars; ChatGPT settles in USD regardless of
// the user's display-currency preference.
// Credit-settled accounts denominate in credits, so no symbol.
let inCredits = codexUsage?.hasCredits == true
let formatter = NumberFormatter()
formatter.numberStyle = .currency
formatter.currencyCode = "USD"
formatter.maximumFractionDigits = 2
let formatted = formatter.string(from: NSNumber(value: balance)) ?? "$\(balance)"
formatter.numberStyle = inCredits ? .decimal : .currency
formatter.maximumFractionDigits = inCredits ? 0 : 2
// Half-up matches the desktop decoder's Math.round; the default is
// half-even, which disagrees on exact-half balances.
formatter.roundingMode = .halfUp
// `en_US`, not `en_US_POSIX`: the latter drops grouping entirely.
formatter.locale = Locale(identifier: "en_US")
if !inCredits { formatter.currencyCode = "USD" }
let fallback = inCredits ? "\(Int(balance.rounded()))" : "$\(balance)"
let formatted = formatter.string(from: NSNumber(value: balance)) ?? fallback
footerLines.append("Credits remaining · \(formatted)")
}
if codexUsage?.creditLimit == nil, codexUsage?.creditsUnlimited == true {
footerLines.append("Credits · Unlimited")
}
return QuotaSummary(providerFilter: filter, connection: connection, primary: primary, details: details, planLabel: plan, footerLines: footerLines)
}
private func kimiQuotaSummary(filter: ProviderFilter) -> QuotaSummary? {
if case .notBootstrapped = kimiLoadState { return nil }
if case .bootstrapping = kimiLoadState { return nil }
if case .noCredentials = kimiLoadState { return nil }
let connection: QuotaSummary.Connection = {
switch kimiLoadState {
case .notBootstrapped, .dormant, .bootstrapping, .noCredentials: return .disconnected
case .loading: return kimiUsage == nil ? .loading : .stale
case .loaded: return .connected
case .failed: return kimiUsage == nil ? .loading : .stale
// Kimi tokens expire ~every 15 min and only the CLI renews them, so
// terminal is the steady state between CLI uses. Keep the last-known
// bars (marked stale) instead of flapping the chip to a reconnect
// card; the reconnect card is reserved for the genuinely-no-data case.
case let .terminalFailure(reason): return kimiUsage == nil ? .terminalFailure(reason: reason) : .stale
case .transientFailure: return .transientFailure
}
}()
var primary: QuotaSummary.Window?
var details: [QuotaSummary.Window] = []
if let usage = kimiUsage {
if let w = usage.primary {
let row = QuotaSummary.Window(label: w.label, percent: w.usedPercent / 100, resetsAt: w.resetsAt)
primary = row
details.append(row)
}
for w in usage.details {
let row = QuotaSummary.Window(label: w.label, percent: w.usedPercent / 100, resetsAt: w.resetsAt)
if primary == nil { primary = row }
details.append(row)
}
}
return QuotaSummary(providerFilter: filter, connection: connection, primary: primary, details: details, planLabel: kimiUsage?.plan ?? "Kimi Code", footerLines: [])
}
/// 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,
@ -1400,9 +1596,11 @@ enum ProviderFilter: String, CaseIterable, Identifiable {
case ibmBob = "IBM Bob"
case kiro = "Kiro"
case kimi = "Kimi"
case kimiCode = "Kimi Code"
case lingtaiTui = "LingTai TUI"
case kiloCode = "KiloCode"
case openclaw = "OpenClaw"
case openclaude = "OpenClaude"
case opencode = "OpenCode"
case pi = "Pi"
case qwen = "Qwen"
@ -1432,6 +1630,7 @@ enum ProviderFilter: String, CaseIterable, Identifiable {
case .grok: ["grok", "grok build"]
case .hermes: ["hermes", "hermes agent"]
case .lingtaiTui: ["lingtai-tui", "lingtai tui"]
case .kimiCode: ["kimicode", "kimi code"]
default: [rawValue.lowercased()]
}
}
@ -1453,8 +1652,10 @@ enum ProviderFilter: String, CaseIterable, Identifiable {
case .kiloCode: "kilo-code"
case .kiro: "kiro"
case .kimi: "kimi"
case .kimiCode: "kimicode"
case .lingtaiTui: "lingtai-tui"
case .openclaw: "openclaw"
case .openclaude: "openclaude"
case .opencode: "opencode"
case .pi: "pi"
case .qwen: "qwen"

View file

@ -49,11 +49,14 @@ struct CodeBurnApp: App {
}
@MainActor
final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSMenuDelegate {
private var statusItem: NSStatusItem!
private var popover: NSPopover!
private var rightClickMonitor: Any?
private var lastContextMenuPresentedAt: Date = .distantPast
/// Held only while the right-click menu is open. Cleared in menuDidClose so
/// left-click goes back to the popover action instead of re-showing the menu.
private var contextMenu: NSMenu?
fileprivate let store = AppStore()
let updateChecker = UpdateChecker()
/// True while the displays are asleep. Refresh ticks skip spawning
@ -464,7 +467,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
// is refreshed by refreshPayloadForPopoverOpen the moment it opens,
// so a closed-popover tick never pays for it (#647).
if !(popover?.isShown ?? false) {
async let menubar = store.refreshQuietly(
async let menubar = store.refreshMenubarBadge(
period: menubarPeriod,
force: force,
qualityOfService: qualityOfService
@ -486,7 +489,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
qualityOfService: qualityOfService
)
async let menubar = needsMenubarPayload
? store.refreshQuietly(period: menubarPeriod, force: force, qualityOfService: qualityOfService)
? store.refreshMenubarBadge(period: menubarPeriod, force: force, qualityOfService: qualityOfService)
: true
async let today = needsTodayPayload
? store.refreshQuietly(period: .today, force: force, qualityOfService: qualityOfService)
@ -519,6 +522,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
fileprivate var lastSubscriptionRefreshAt: Date?
fileprivate var lastCodexRefreshAt: Date?
fileprivate var lastKimiRefreshAt: Date?
private var claudeQuotaFailureCount = 0
private var nextClaudeQuotaRefreshAt: Date?
@ -614,8 +618,24 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
if let task = codexQuotaRefreshTask {
return await task.value
}
let task = Task { [store] in
await store.refreshCodexReportingSuccess()
// Kimi Code rides the same tick, but with its own cadence anchor:
// when Codex is not connected its refresh returns false immediately,
// so lastCodexRefreshAt never advances anchoring Kimi on it would
// poll api.kimi.com on every payload tick instead of the configured
// quota cadence. Anchor on attempt (not success) so a failing Kimi
// endpoint also respects the cadence.
let kimiDue: Bool = {
let cadence = SubscriptionRefreshCadence.current
guard cadence != .manual else { return false }
return Date().timeIntervalSince(lastKimiRefreshAt ?? .distantPast) >= TimeInterval(cadence.rawValue)
}()
if kimiDue { lastKimiRefreshAt = Date() }
let task = Task { [store, kimiDue] in
async let codex = store.refreshCodexReportingSuccess()
if kimiDue {
_ = await store.refreshKimiReportingSuccess()
}
return await codex
}
codexQuotaRefreshTask = task
let result = await task.value
@ -833,6 +853,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
func resetSubscriptionCadenceAnchor() {
lastSubscriptionRefreshAt = nil
lastCodexRefreshAt = nil
lastKimiRefreshAt = nil
claudeQuotaFailureCount = 0
nextClaudeQuotaRefreshAt = nil
}
@ -849,6 +870,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
_ = self.store.payload
_ = self.store.menubarPeriod
_ = self.store.menubarPayload
// Combined-scope badge total: re-render the badge when the cross-device
// aggregate for the menubar period lands (or a peer goes reachable).
_ = self.store.menubarBadgeCombined
// Track currency so the menubar title catches up immediately on
// currency switch instead of waiting for the next 30s payload tick.
_ = self.store.currency
@ -909,15 +933,25 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
button.sendAction(on: [.leftMouseUp, .rightMouseUp])
// macOS 27 no longer routes any right-mouse event to the status-item
// button's target/action. A global monitor still observes right-mouse-down;
// button's target/action. A global monitor still observes right-mouse-up;
// we hit-test it against our own status-item window and present the menu
// ourselves. Harmless and stable on 15/26 too (the debounce in
// showContextMenu prevents a double-present if the legacy path also fires).
rightClickMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.rightMouseDown]) { [weak self] _ in
// ourselves.
//
// Must be mouse-*up*, not mouse-down: presenting on down starts menu
// tracking while the button is still held, so the matching rightMouseUp
// is treated as an outside click and the menu flashes then dismisses.
// Presenting on up (after the click completes) keeps it open. Harmless
// on 15/26 too (the debounce in showContextMenu prevents a double-present
// if the legacy path also fires).
rightClickMonitor = NSEvent.addGlobalMonitorForEvents(matching: StatusItemContextMenuPolicy.presentEventMask) { [weak self] _ in
guard let self,
let button = self.statusItem.button,
let window = button.window,
window.frame.contains(NSEvent.mouseLocation) else { return }
// Defer one turn so menu presentation is not nested inside the
// monitor callback. Safe on mouse-*up* (the click is already
// complete); on mouse-down a deferred present was killed by the
// matching up event and the menu only flashed.
DispatchQueue.main.async { self.showContextMenu(from: button) }
}
@ -955,7 +989,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
button.image = nil
button.imagePosition = .noImage
let font = NSFont.monospacedDigitSystemFont(ofSize: menubarTitleFontSize, weight: .medium)
let font = NSFont.monospacedDigitSystemFont(ofSize: menubarTitleFontSize, weight: .regular)
let baseConfig = NSImage.SymbolConfiguration(pointSize: menubarTitleFontSize, weight: .medium)
// Tint the flame based on the worst-affected connected provider's quota.
// Normal (<70%) keeps the template (auto white-on-dark / black-on-light);
@ -992,23 +1026,31 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
if store.displayMetric != .iconOnly {
let suffix = menubarPeriod.menubarSuffix(compact: compact)
// Under combined scope the badge shows the cross-device aggregate, so
// it matches the popover hero instead of trailing it with the local
// figure. Falls back to local when no combined payload is available
// (local scope, cold cache, or an unreachable peer). Credits have no
// combined total, so that metric always reflects the local device.
let badgeCombined = store.menubarBadgeCombined
let cost: Double? = badgeCombined?.cost ?? menubarPayload?.current.cost
let outputTokens: Int? = badgeCombined?.outputTokens ?? menubarPayload?.current.outputTokens
let inputTokens: Int? = badgeCombined?.inputTokens ?? menubarPayload?.current.inputTokens
let valueText: String
if store.displayMetric == .tokens, let p = menubarPayload?.current {
let out = formatTokensMenubar(Double(p.outputTokens))
let inp = formatTokensMenubar(Double(p.inputTokens))
valueText = compact ? "\(out)\(inp)\(suffix)" : "\(out)\(inp)\(suffix)"
} else if store.displayMetric == .totalTokens, let p = menubarPayload?.current {
let total = formatTokensMenubar(Double(p.inputTokens + p.outputTokens))
if store.displayMetric == .tokens, let out = outputTokens, let inp = inputTokens {
let outText = formatTokensMenubar(Double(out))
let inpText = formatTokensMenubar(Double(inp))
valueText = compact ? "\(outText)\(inpText)\(suffix)" : "\(outText)\(inpText)\(suffix)"
} else if store.displayMetric == .totalTokens, let out = outputTokens, let inp = inputTokens {
let total = formatTokensMenubar(Double(inp + out))
valueText = compact ? "\(total)\(suffix)" : " \(total)\(suffix)"
} else if store.displayMetric == .credits, let p = menubarPayload?.current {
let credits = formatTokensMenubar((p.codexCredits ?? 0).rounded())
valueText = compact ? "\(credits)cr\(suffix)" : " \(credits) credits\(suffix)"
} else {
let fallback = compact ? "$-" : "$—"
let formatted = menubarPayload?.current.cost
valueText = compact
? (formatted?.asCompactCurrencyWhole() ?? fallback) + suffix
: " " + (formatted?.asCompactCurrency() ?? fallback) + suffix
? (cost?.asCompactCurrencyWhole() ?? fallback) + suffix
: " " + (cost?.asCompactCurrency() ?? fallback) + suffix
}
var textAttrs: [NSAttributedString.Key: Any] = [.font: font, .baselineOffset: -1.0]
@ -1016,10 +1058,27 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
textAttrs[.foregroundColor] = NSColor.secondaryLabelColor
}
composed.append(NSAttributedString(string: valueText, attributes: textAttrs))
// Combined scope, but a paired device didn't report this cycle: append
// a dimmed "reachable/total" so the reduced total reads as "peer
// unreachable" rather than a glitch (mirrors the popover's device list).
if let shortfall = store.menubarBadgeDeviceShortfall {
let marker = " · \(shortfall.reachable)/\(shortfall.total)"
let markerAttrs: [NSAttributedString.Key: Any] = [
.font: font,
.baselineOffset: -1.0,
.foregroundColor: NSColor.secondaryLabelColor,
]
composed.append(NSAttributedString(string: marker, attributes: markerAttrs))
}
}
button.attributedTitle = composed
button.toolTip = "CodeBurn \(menubarPeriod.menubarMetricLabel)"
if let shortfall = store.menubarBadgeDeviceShortfall {
button.toolTip = "CodeBurn \(menubarPeriod.menubarMetricLabel) · \(shortfall.reachable) of \(shortfall.total) devices reporting"
} else {
button.toolTip = "CodeBurn \(menubarPeriod.menubarMetricLabel)"
}
persistBadgeStatusFile()
}
@ -1126,9 +1185,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
private func showContextMenu(from button: NSStatusBarButton) {
// Debounce: on macOS <= 26 both the legacy action path and the global
// monitor can fire for a single right-click. Present at most once per click.
let now = Date()
guard now.timeIntervalSince(lastContextMenuPresentedAt) > 0.3 else { return }
lastContextMenuPresentedAt = now
// Policy lives in StatusItemContextMenuPolicy so the gate is unit-tested (#802).
guard StatusItemContextMenuPolicy.acceptPresent(
now: Date(),
lastPresentedAt: &lastContextMenuPresentedAt
) else { return }
// Don't let an open popover steal the click / sit under the menu.
if popover?.isShown == true {
popover.performClose(nil)
}
let menu = NSMenu()
@ -1160,12 +1226,39 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
quitItem.target = self
menu.addItem(quitItem)
// Present directly. The previous `statusItem.menu = menu; button.performClick`
// trick relies on the click -> action path that macOS 27 changed; popUp is
// version-stable. Open a few px below the status item so the menu clears the
// menu bar: anchoring flush clips the top edge and makes macOS engage menu
// scrolling (a scroll chevron appears and the first row slides up on hover).
menu.popUp(positioning: nil, at: NSPoint(x: 0, y: button.bounds.height + 6), in: button)
// Present via the status item's own menu slot. AppKit positions and tracks
// that menu correctly under the status item (no scroll chevron / first-row
// jump). Manual NSMenu.popUp(at:in:) is what caused the jump: the menu was
// tracked against a point while the cursor still sat on the status item
// above it, so the first mouse move engaged scroll mode.
//
// #472 dropped this pattern because assigning `statusItem.menu` from the
// right-mouse *action* never ran on macOS 27 (right-clicks no longer reach
// the button action). We still open from our global rightMouseUp monitor
// (or the legacy action on 26); once we set `statusItem.menu` ourselves,
// performClick is just "open the attached menu" and works on 27 too.
//
// menuDidClose clears `statusItem.menu` so the next left-click hits our
// action (popover) instead of re-opening this menu.
menu.delegate = self
contextMenu = menu
statusItem.menu = menu
button.performClick(nil)
}
// MARK: - NSMenuDelegate
// AppKit invokes menu callbacks on the main thread. Clear the status-item
// menu slot so the next left-click hits our action (popover) again.
nonisolated func menuDidClose(_ menu: NSMenu) {
// Hop explicitly don't assumeIsolated across the NSMenuDelegate boundary
// under Swift 6 strict concurrency (NSMenu isn't Sendable).
DispatchQueue.main.async { [weak self] in
guard let self else { return }
// Always clear: we only ever attach our own context menu to the item.
self.statusItem.menu = nil
self.contextMenu = nil
}
}
/// One-line "today" summary for the context menu's usage row.

View file

@ -111,10 +111,12 @@ enum CodexSubscriptionService {
switch http.statusCode {
case 200:
clearUsageBlock()
// Companion fetch, strictly best-effort: any failure yields nil and
// the Plan view simply omits the row. This endpoint must never be
// able to break the quota display.
let resetCredits = await fetchResetCredits(token: token)
// Skip the companion request only when the inline block says zero.
// Best-effort either way: nil just omits the row.
var resetCredits = inlineResetCreditsShortcut(data: data)
if resetCredits == nil {
resetCredits = await fetchResetCredits(token: token)
}
do {
return try decodeUsage(data: data, resetCredits: resetCredits)
} catch {
@ -144,15 +146,83 @@ enum CodexSubscriptionService {
}
}
/// chatgpt.com mixes encodings inside one payload: `"limit": "10000"` next
/// to `"used_percent": 30`. Every numeric field decodes through here.
private enum Flexible {
// `decode`, not `decodeIfPresent`: missing, null and wrong-typed all
// mean "not available", without the double-optional footgun.
// Int first keeps precision above 2^53. Infinity and NaN survive
// `Double(_ text:)`, so reject them here.
static func double<K: CodingKey>(_ c: KeyedDecodingContainer<K>, _ key: K) -> Double? {
if let v = try? c.decode(Int.self, forKey: key) { return Double(v) }
if let v = try? c.decode(Double.self, forKey: key) { return v.isFinite ? v : nil }
if let v = try? c.decode(String.self, forKey: key),
let d = Double(v.trimmingCharacters(in: .whitespacesAndNewlines)) {
return d.isFinite ? d : nil
}
return nil
}
// `Int(exactly:)`, never `Int(_:)`: the plain initializer traps on an
// out-of-range Double, and a trap is not a catchable DecodingError.
static func int<K: CodingKey>(_ c: KeyedDecodingContainer<K>, _ key: K) -> Int? {
double(c, key).flatMap { Int(exactly: $0.rounded()) }
}
static func bool<K: CodingKey>(_ c: KeyedDecodingContainer<K>, _ key: K) -> Bool {
(try? c.decode(Bool.self, forKey: key)) ?? false
}
}
/// Decoding `[T]` is atomic, so one bad entry would discard every sibling.
private struct Lossy<T: Decodable>: Decodable {
let value: T?
init(from decoder: Decoder) throws { value = try? T(from: decoder) }
}
private struct UsageDTO: Decodable {
let plan_type: String?
let rate_limit: RateLimit?
let additional_rate_limits: [AdditionalLimitDTO]?
let credits: Credits?
let spend_control: SpendControl?
/// Forward-compat: some variants hoist this to the top level.
let individual_limit: IndividualLimit?
enum CodingKeys: String, CodingKey {
case plan_type, rate_limit, additional_rate_limits, credits, spend_control
case individual_limit
case individualLimit
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
plan_type = try? c.decode(String.self, forKey: .plan_type)
rate_limit = try? c.decode(RateLimit.self, forKey: .rate_limit)
additional_rate_limits = (try? c.decode([Lossy<AdditionalLimitDTO>].self, forKey: .additional_rate_limits))?
.compactMap(\.value)
credits = try? c.decode(Credits.self, forKey: .credits)
spend_control = try? c.decode(SpendControl.self, forKey: .spend_control)
individual_limit = (try? c.decode(IndividualLimit.self, forKey: .individual_limit))
?? (try? c.decode(IndividualLimit.self, forKey: .individualLimit))
}
struct RateLimit: Decodable {
let primary_window: WindowDTO?
let secondary_window: WindowDTO?
/// Forward-compat: another observed position for the spend control.
let individual_limit: IndividualLimit?
enum CodingKeys: String, CodingKey {
case primary_window, secondary_window, individual_limit
case individualLimit
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
primary_window = try? c.decode(WindowDTO.self, forKey: .primary_window)
secondary_window = try? c.decode(WindowDTO.self, forKey: .secondary_window)
individual_limit = (try? c.decode(IndividualLimit.self, forKey: .individual_limit))
?? (try? c.decode(IndividualLimit.self, forKey: .individualLimit))
}
}
struct AdditionalLimitDTO: Decodable {
let limit_name: String?
@ -163,22 +233,72 @@ enum CodexSubscriptionService {
let reset_at: Int?
let limit_window_seconds: Int?
}
/// Credit-metered workspaces report `rate_limit: null` and carry their
/// real limit here: the monthly allowance an admin sets.
struct SpendControl: Decodable {
let reached: Bool
let individualLimit: IndividualLimit?
enum CodingKeys: String, CodingKey {
case reached
case individual_limit
case individualLimit
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
reached = Flexible.bool(c, .reached)
individualLimit = (try? c.decode(IndividualLimit.self, forKey: .individual_limit))
?? (try? c.decode(IndividualLimit.self, forKey: .individualLimit))
}
}
struct IndividualLimit: Decodable {
let limit: Double?
let used: Double?
let usedPercent: Double?
let remainingPercent: Double?
let resetAt: Int?
enum CodingKeys: String, CodingKey {
case limit, used
case used_percent, usedPercent
case remaining_percent, remainingPercent
case reset_at, resets_at, resetsAt
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
limit = Flexible.double(c, .limit)
used = Flexible.double(c, .used)
usedPercent = Flexible.double(c, .used_percent) ?? Flexible.double(c, .usedPercent)
remainingPercent = Flexible.double(c, .remaining_percent)
?? Flexible.double(c, .remainingPercent)
resetAt = Flexible.int(c, .reset_at)
?? Flexible.int(c, .resets_at)
?? Flexible.int(c, .resetsAt)
}
}
// chatgpt.com sometimes serializes balance as a Double ("balance": 0.0)
// and other times as a String ("balance": "0.00"). Mirror CodexBar's
// resilient decode so a schema drift on either shape doesn't blow up
// the whole quota fetch.
struct Credits: Decodable {
let balance: Double?
enum CodingKeys: String, CodingKey { case balance }
/// Settles in credits, not dollars, which relabels `balance`.
let hasCredits: Bool
let unlimited: Bool
enum CodingKeys: String, CodingKey {
case balance
case has_credits
case unlimited
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
if let n = try? c.decode(Double.self, forKey: .balance) {
balance = n
} else if let s = try? c.decode(String.self, forKey: .balance), let n = Double(s) {
balance = n
} else {
balance = nil
}
balance = Flexible.double(c, .balance)
hasCredits = Flexible.bool(c, .has_credits)
unlimited = Flexible.bool(c, .unlimited)
}
}
}
@ -204,6 +324,28 @@ enum CodexSubscriptionService {
return parseResetCredits(data: data)
}
/// The inline block carries no per-credit expiry list, so it is only a safe
/// shortcut at zero, where there is no expiry to report. A non-zero count
/// still pays for the companion request rather than dropping the
/// "next expires" caption the popover would otherwise show.
static func inlineResetCreditsShortcut(data: Data) -> CodexUsage.ResetCredits? {
guard let inline = inlineResetCredits(data: data), inline.availableCount == 0 else { return nil }
return inline
}
/// Reset-credit inventory carried inline on the usage payload. Nil means
/// absent.
static func inlineResetCredits(data: Data) -> CodexUsage.ResetCredits? {
struct InlineDTO: Decodable {
struct Block: Decodable { let available_count: Int? }
let rate_limit_reset_credits: Block?
}
guard let count = (try? JSONDecoder().decode(InlineDTO.self, from: data))?
.rate_limit_reset_credits?.available_count, count >= 0
else { return nil }
return CodexUsage.ResetCredits(availableCount: count, nextExpiresAt: nil)
}
/// Internal (not private) so tests can drive it with fixture payloads.
/// Returns nil on any unexpected shape the caller treats nil as
/// "feature unavailable", never as an error.
@ -239,7 +381,8 @@ enum CodexSubscriptionService {
return plain.date(from: raw)
}
private static func decodeUsage(data: Data, resetCredits: CodexUsage.ResetCredits? = nil) throws -> CodexUsage {
/// Internal (not private) so tests can drive it with fixture payloads.
static func decodeUsage(data: Data, resetCredits: CodexUsage.ResetCredits? = nil) throws -> CodexUsage {
let root = try JSONDecoder().decode(UsageDTO.self, from: data)
let additional: [CodexUsage.AdditionalLimit] = (root.additional_rate_limits ?? []).compactMap { dto in
guard let name = dto.limit_name, !name.isEmpty else { return nil }
@ -249,17 +392,61 @@ enum CodexSubscriptionService {
secondary: makeWindow(dto.rate_limit?.secondary_window)
)
}
let limitDTO = root.spend_control?.individualLimit
?? root.individual_limit
?? root.rate_limit?.individual_limit
return CodexUsage(
plan: CodexUsage.planType(from: root.plan_type),
primary: makeWindow(root.rate_limit?.primary_window),
secondary: makeWindow(root.rate_limit?.secondary_window),
additionalLimits: additional,
creditsBalance: root.credits?.balance,
hasCredits: root.credits?.hasCredits ?? false,
creditsUnlimited: root.credits?.unlimited ?? false,
creditLimit: makeCreditLimit(limitDTO, reached: root.spend_control?.reached ?? false),
resetCredits: resetCredits,
fetchedAt: Date()
)
}
private static func makeCreditLimit(
_ dto: UsageDTO.IndividualLimit?,
reached: Bool
) -> CodexUsage.CreditLimit? {
guard let dto, let limit = dto.limit, limit > 0 else { return nil }
// Server percentage, then remaining_percent, then the raw ratio. No
// signal at all means the draw is unknown; a 0% bar would claim otherwise.
guard let raw = dto.usedPercent
?? dto.remainingPercent.map({ 100 - $0 })
?? dto.used.map({ $0 / limit * 100 })
else { return nil }
let percent = min(max(raw, 0), 100)
let resetsAt = dto.resetAt.flatMap { $0 > 0 ? Date(timeIntervalSince1970: TimeInterval($0)) : nil }
return CodexUsage.CreditLimit(
// Unclamped percent, so a 120% draw still reports 12,000 of 10,000.
used: dto.used ?? limit * max(raw, 0) / 100,
limit: limit,
usedPercent: percent,
resetsAt: resetsAt,
windowSeconds: monthlyWindowSeconds(endingAt: resetsAt),
reached: reached
)
}
/// Spend controls reset on a calendar-month boundary, so the window is the
/// month preceding the reset. Not `reset_after_seconds`, which is remaining.
/// UTC, not `Calendar.current`: a 2026-03-01Z reset spans 28 days in UTC
/// but 31 in Toronto, so a local calendar makes pace timezone-dependent.
private static func monthlyWindowSeconds(endingAt resetsAt: Date?) -> Int? {
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = TimeZone(secondsFromGMT: 0) ?? .gmt
guard let resetsAt,
let start = calendar.date(byAdding: .month, value: -1, to: resetsAt)
else { return nil }
let seconds = Int(resetsAt.timeIntervalSince(start))
return seconds > 0 ? seconds : nil
}
private static func makeWindow(_ dto: UsageDTO.WindowDTO?) -> CodexUsage.Window? {
guard let dto, let used = dto.used_percent, let windowSeconds = dto.limit_window_seconds else {
return nil

View file

@ -31,7 +31,12 @@ struct CodexUsage: Sendable, Equatable {
case .k12: "K-12"
case .enterprise: "Enterprise"
case .edu: "Edu"
case let .unknown(raw): raw.isEmpty ? "Subscription" : raw.capitalized
case let .unknown(raw):
raw.isEmpty
? "Subscription"
: raw.replacingOccurrences(of: "_", with: " ")
.replacingOccurrences(of: "-", with: " ")
.capitalized
}
}
}
@ -76,16 +81,55 @@ struct CodexUsage: Sendable, Equatable {
let nextExpiresAt: Date?
}
/// The monthly allowance an admin sets. Credit-metered workspaces report
/// `rate_limit: null`, so this is their only limit.
struct CreditLimit: Sendable, Equatable {
let used: Double
let limit: Double
let usedPercent: Double // 0.0 ... 100.0
let resetsAt: Date?
/// Calendar month the allowance resets on, for pace projection. Not the
/// payload's `reset_after_seconds`, which is the time remaining.
let windowSeconds: Int?
/// Allowance already spent: a hard stop, not a near-limit warning.
let reached: Bool
/// `.halfUp` matches the desktop decoder's `Math.round`.
var displayLabel: String {
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
formatter.maximumFractionDigits = 0
formatter.roundingMode = .halfUp
// `en_US`, not `en_US_POSIX`: the latter drops grouping entirely.
formatter.locale = Locale(identifier: "en_US")
func text(_ value: Double) -> String {
formatter.string(from: NSNumber(value: value)) ?? "\(Int(value.rounded()))"
}
let base = "Monthly usage limit · \(text(used)) / \(text(limit)) credits"
return reached ? "\(base) · limit reached" : base
}
var shortLabel: String {
reached ? "Monthly usage limit · limit reached" : "Monthly usage limit"
}
}
let plan: PlanType
let primary: Window?
let secondary: Window?
let additionalLimits: [AdditionalLimit]
let creditsBalance: Double?
/// Account settles in credits, not dollars, which changes `creditsBalance`.
let hasCredits: Bool
/// Uncapped on purpose, as distinct from a limit we failed to read.
let creditsUnlimited: Bool
let creditLimit: CreditLimit?
let resetCredits: ResetCredits?
let fetchedAt: Date
static func planType(from raw: String?) -> PlanType {
guard let raw = raw?.lowercased() else { return .unknown("") }
guard let original = raw?.lowercased() else { return .unknown("") }
let raw = normalizePlanType(original)
switch raw {
case "guest": return .guest
case "free": return .free
@ -101,7 +145,28 @@ struct CodexUsage: Sendable, Equatable {
case "k12": return .k12
case "enterprise": return .enterprise
case "edu": return .edu
// Normalized, so an unknown composite reads "Some Future Tier".
default: return .unknown(raw)
}
}
/// Credit-based-pricing tiers arrive composite (`enterprise_cbp_usage_based`).
private static func normalizePlanType(_ raw: String) -> String {
var value = raw.trimmingCharacters(in: .whitespacesAndNewlines)
for suffix in ["_usage_based", "-usage-based", "_usage-based", "-usage_based"]
where value.hasSuffix(suffix) {
value.removeLast(suffix.count)
}
for prefix in ["self_serve_", "self-serve-", "self_serve-", "self-serve_"]
where value.hasPrefix(prefix) {
value.removeFirst(prefix.count)
}
for suffix in ["_cbp", "-cbp"] where value.hasSuffix(suffix) {
value.removeLast(suffix.count)
}
for infix in ["_cbp_", "-cbp-", "_cbp-", "-cbp_"] {
value = value.replacingOccurrences(of: infix, with: "_")
}
return value
}
}

View file

@ -0,0 +1,51 @@
import Foundation
/// Pure display-decision helpers for the Kimi Code quota surfaces.
///
/// Kimi Code tokens live ~15 min and only the Kimi CLI renews them, so the
/// load state sits in `.terminalFailure` as its dominant steady state between
/// CLI uses. The always-visible surfaces (Plan tab, tab-strip chip) must keep
/// showing the last good snapshot with a quiet caption instead of flapping to
/// a reconnect screen every cycle. The reconnect screen is reserved for the
/// no-data case, where there is genuinely nothing to show.
enum KimiQuotaPresentation {
/// Which Plan-tab subview to render, given the load state and whether a
/// last-known snapshot exists.
enum PlanContent: Equatable {
case noCredentials
case loading
case failed
case transientFailed
case reconnect(reason: String?)
/// Render the loaded usage bars. `idle` is true when the login has
/// gone terminal but a snapshot is still on hand the caller stamps a
/// quiet "run the CLI" caption instead of hiding the data.
case usage(idle: Bool)
}
static func planContent(loadState: SubscriptionLoadState, hasUsage: Bool) -> PlanContent {
switch loadState {
case .notBootstrapped, .noCredentials:
return .noCredentials
case .dormant, .bootstrapping:
return .loading
case .loading, .loaded:
return hasUsage ? .usage(idle: false) : .loading
case .failed:
return .failed
case .transientFailure:
return hasUsage ? .usage(idle: false) : .transientFailed
case .terminalFailure(let reason):
return hasUsage ? .usage(idle: true) : .reconnect(reason: reason)
}
}
/// Snapshot age past which a loaded view stamps an "as of <time>" caption,
/// so a bar can never silently masquerade as current. Kimi's ~15-min token
/// life makes 10 minutes the point where a snapshot is likely a cycle old.
static let stalenessThreshold: TimeInterval = 10 * 60
static func isStale(fetchedAt: Date, now: Date = Date()) -> Bool {
now.timeIntervalSince(fetchedAt) > stalenessThreshold
}
}

View file

@ -0,0 +1,372 @@
import Foundation
/// Live quota snapshot for a Kimi Code subscription, returned by
/// GET https://api.kimi.com/coding/v1/usages. Shape mirrors what
/// steipete/CodexBar consumes: a top-level `usage` envelope plus a
/// `limits` array of additional rate-limit windows. Numeric fields are
/// decoded leniently (String or Int/Double) because the API has shipped
/// both.
struct KimiUsage: Sendable, Equatable {
struct Window: Sendable, Equatable {
let label: String
let limit: Double
let used: Double
let remaining: Double?
let resetsAt: Date?
var usedPercent: Double { // 0.0 ... 100.0
guard limit > 0 else { return 0 }
return min(100, max(0, used / limit * 100))
}
}
/// The top-level `usage` envelope treated as the primary window.
let primary: Window?
/// Additional windows from the `limits` array (e.g. 5-hour rate limit).
let details: [Window]
/// Membership tier from user.membership.level (e.g. "Intermediate").
let plan: String?
/// Max parallel sessions from parallel.limit, when reported.
let parallelLimit: Int?
let fetchedAt: Date
}
/// Mirror of CodexSubscriptionService for Kimi Code. Reads the CLI's
/// credential file directly (~/.kimi-code/credentials/kimi-code.json)
/// no keychain bootstrap, no OAuth refresh. Tokens are short-lived
/// (~15 min) and only the Kimi CLI refreshes them, so an expired token is
/// a terminal state: the UI tells the user to run the CLI once.
enum KimiSubscriptionService {
private static let usageURL = URL(string: "https://api.kimi.com/coding/v1/usages")!
private static let usageBlockedUntilKey = "codeburn.kimi.usage.blockedUntil"
enum FetchError: Error, LocalizedError {
case noCredentials
case tokenExpired
case rateLimited(retryAt: Date)
case usageHTTPError(Int, String?)
case usageDecodeFailed
case network(Error)
var errorDescription: String? {
switch self {
case .noCredentials:
return "No Kimi Code credentials found. Sign in with the Kimi CLI first."
case .tokenExpired:
return "Kimi Code login expired. Run the Kimi CLI once to refresh, then try again."
case let .rateLimited(retryAt):
let f = RelativeDateTimeFormatter()
f.unitsStyle = .short
return "Kimi rate-limited the quota endpoint. Retrying \(f.localizedString(for: retryAt, relativeTo: Date()))."
case let .usageHTTPError(code, body):
return "Kimi quota fetch failed (HTTP \(code))\(body.map { ": \($0)" } ?? "")"
case .usageDecodeFailed: return "Kimi quota response was malformed."
case let .network(err): return "Network error: \(err.localizedDescription)"
}
}
var isTerminal: Bool {
if case .tokenExpired = self { return true }
if case .noCredentials = self { return true }
return false
}
var rateLimitRetryAt: Date? {
if case let .rateLimited(retryAt) = self { return retryAt }
return nil
}
}
// MARK: - Credential file
private struct CredentialFile: Decodable {
let accessToken: String
let expiresAt: Double
enum CodingKeys: String, CodingKey {
case accessToken = "access_token"
case expiresAt = "expires_at"
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
accessToken = try c.decode(String.self, forKey: .accessToken)
// Tolerate Int / Double / String epoch-seconds.
if let n = try? c.decode(Double.self, forKey: .expiresAt) {
expiresAt = n
} else if let s = try? c.decode(String.self, forKey: .expiresAt), let n = Double(s) {
expiresAt = n
} else {
throw DecodingError.dataCorruptedError(forKey: .expiresAt, in: c, debugDescription: "expires_at missing or not numeric")
}
}
}
private static var credentialsURL: URL {
let home = ProcessInfo.processInfo.environment["KIMI_CODE_HOME"]
?? NSHomeDirectory() + "/.kimi-code"
return URL(fileURLWithPath: home + "/credentials/kimi-code.json")
}
static var hasCredential: Bool {
FileManager.default.fileExists(atPath: credentialsURL.path)
}
/// Returns the access token only when it is still fresh (60s skew).
/// Throws noCredentials / tokenExpired otherwise.
private static func freshToken() throws -> String {
guard let data = FileManager.default.contents(atPath: credentialsURL.path),
let cred = try? JSONDecoder().decode(CredentialFile.self, from: data),
!cred.accessToken.isEmpty else {
throw FetchError.noCredentials
}
guard cred.expiresAt > Date().timeIntervalSince1970 + 60 else {
throw FetchError.tokenExpired
}
return cred.accessToken
}
private static func deviceId() -> String? {
let home = ProcessInfo.processInfo.environment["KIMI_CODE_HOME"]
?? NSHomeDirectory() + "/.kimi-code"
guard let data = FileManager.default.contents(atPath: home + "/device_id"),
let id = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines),
!id.isEmpty else { return nil }
return id
}
// MARK: - Fetch
static func refresh() async throws -> KimiUsage {
if let until = usageBlockedUntil(), until > Date() {
throw FetchError.rateLimited(retryAt: until)
}
let token = try freshToken()
var request = URLRequest(url: usageURL)
request.httpMethod = "GET"
request.timeoutInterval = 30
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue("CodeBurn", forHTTPHeaderField: "User-Agent")
// Kimi server expects these platform headers (same as CodexBar sends).
request.setValue("kimi_code_cli", forHTTPHeaderField: "X-Msh-Platform")
if let deviceId = deviceId() {
request.setValue(deviceId, forHTTPHeaderField: "X-Msh-Device-Id")
}
let data: Data
let response: URLResponse
do {
(data, response) = try await URLSession.shared.data(for: request)
} catch {
throw FetchError.network(error)
}
guard let http = response as? HTTPURLResponse else {
throw FetchError.usageHTTPError(-1, nil)
}
switch http.statusCode {
case 200:
clearUsageBlock()
do {
return try parseUsage(data: data)
} catch {
// Never log the body account data readable via `log stream`.
NSLog("CodeBurn: kimi usage decode failed: %@", String(describing: error))
throw FetchError.usageDecodeFailed
}
case 401, 403:
// We don't self-refresh; surface as terminal so the UI prompts
// the user to run the CLI.
throw FetchError.tokenExpired
case 429:
let retryAfter = parseRetryAfterHeader(http.value(forHTTPHeaderField: "Retry-After"))
let until = recordUsageRateLimit(retryAfterSeconds: retryAfter)
throw FetchError.rateLimited(retryAt: until)
default:
throw FetchError.usageHTTPError(http.statusCode, String(data: data, encoding: .utf8))
}
}
// MARK: - Decode (internal so tests can drive fixtures)
private struct LenientDouble: Decodable {
let value: Double?
init(from decoder: Decoder) throws {
let c = try decoder.singleValueContainer()
if let n = try? c.decode(Double.self) { value = n }
else if let s = try? c.decode(String.self) { value = Double(s) }
else { value = nil }
}
}
private struct UsageEnvelopeDTO: Decodable {
let limit: LenientDouble?
let used: LenientDouble?
let remaining: LenientDouble?
let resetTime: LenientString?
let resetAt: LenientString?
let reset_time: LenientString?
let reset_at: LenientString?
var resetsAtRaw: String? {
resetTime?.value ?? resetAt?.value ?? reset_time?.value ?? reset_at?.value
}
}
/// Reset timestamps are normally ISO-8601 strings, but decode numbers
/// tolerantly too (epoch seconds) so a schema drift can't fail the whole
/// response. A plain String? would throw on a JSON number.
private struct LenientString: Decodable {
let value: String?
init(from decoder: Decoder) throws {
let c = try decoder.singleValueContainer()
if let s = try? c.decode(String.self) { value = s }
else if let n = try? c.decode(Double.self) { value = String(n) }
else { value = nil }
}
}
private struct LimitDTO: Decodable {
let window: WindowDTO?
let detail: UsageEnvelopeDTO?
struct WindowDTO: Decodable {
let duration: LenientDouble?
let timeUnit: String?
}
}
private struct ResponseDTO: Decodable {
let usage: UsageEnvelopeDTO?
let limits: [LimitDTO]?
let user: UserDTO?
let parallel: ParallelDTO?
struct UserDTO: Decodable {
let membership: MembershipDTO?
struct MembershipDTO: Decodable {
let level: String?
}
}
struct ParallelDTO: Decodable {
let limit: LenientDouble?
}
}
static func parseUsage(data: Data, now: Date = Date()) throws -> KimiUsage {
let root = try JSONDecoder().decode(ResponseDTO.self, from: data)
// The top-level usage envelope is the account's weekly quota (its
// reset lands ~7 days out), so label it like Claude's weekly window.
let primary = makeWindow(label: "Weekly", dto: root.usage)
let details: [KimiUsage.Window] = (root.limits ?? []).compactMap { limit in
guard let dto = limit.detail else { return nil }
let label = windowLabel(duration: limit.window?.duration?.value, timeUnit: limit.window?.timeUnit)
return makeWindow(label: label, dto: dto)
}
guard primary != nil || !details.isEmpty else {
throw FetchError.usageDecodeFailed
}
return KimiUsage(
primary: primary,
details: details,
plan: planName(from: root.user?.membership?.level),
parallelLimit: root.parallel?.limit?.value.map { Int($0) },
fetchedAt: now
)
}
/// "LEVEL_INTERMEDIATE" "Intermediate". Unknown / missing nil so the
/// UI falls back to the plain "Kimi Code" label.
private static func planName(from level: String?) -> String? {
guard var raw = level, !raw.isEmpty else { return nil }
if raw.hasPrefix("LEVEL_") { raw = String(raw.dropFirst("LEVEL_".count)) }
return raw.replacingOccurrences(of: "_", with: " ").capitalized
}
private static func makeWindow(label: String, dto: UsageEnvelopeDTO?) -> KimiUsage.Window? {
guard let dto, let limit = dto.limit?.value, limit > 0 else { return nil }
// Rate-limit windows report only limit + remaining derive used.
let used = dto.used?.value ?? max(0, limit - (dto.remaining?.value ?? limit))
return KimiUsage.Window(
label: label,
limit: limit,
used: used,
remaining: dto.remaining?.value,
resetsAt: dto.resetsAtRaw.flatMap(parseResetTime)
)
}
/// Window size human label. The API sends enum-style units
/// ("TIME_UNIT_MINUTE", duration 300) as well as plain ones ("hour"),
/// so normalize first; sub-hour durations roll up to hours when exact.
private static func windowLabel(duration: Double?, timeUnit: String?) -> String {
guard let duration, let rawUnit = timeUnit else { return "Rate Limit" }
var unit = rawUnit.lowercased()
if unit.hasPrefix("time_unit_") { unit = String(unit.dropFirst("time_unit_".count)) }
var d = duration
// Roll up exact sub-day durations: 300 minutes 5 hours.
if unit == "minute" || unit == "minutes", d.truncatingRemainder(dividingBy: 60) == 0, d >= 60 {
d /= 60; unit = "hour"
}
let i = Int(d)
switch unit {
case "minute", "minutes": return i == 1 ? "Minutely" : "\(i)-min"
case "hour", "hours": return i == 1 ? "Hourly" : "\(i)-hour"
case "day", "days":
if i == 1 { return "Daily" }
if i == 7 { return "Weekly" }
return "\(i)-day"
case "week", "weeks": return i == 1 ? "Weekly" : "\(i)-week"
case "month", "months": return i == 1 ? "Monthly" : "\(i)-month"
default: return "\(i) \(unit)"
}
}
/// resetTime arrives as ISO-8601 (fractional seconds optional) or epoch seconds.
private static func parseResetTime(_ raw: String) -> Date? {
let fractional = ISO8601DateFormatter()
fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let date = fractional.date(from: raw) { return date }
let plain = ISO8601DateFormatter()
plain.formatOptions = [.withInternetDateTime]
if let date = plain.date(from: raw) { return date }
if let epoch = Double(raw) { return Date(timeIntervalSince1970: epoch) }
return nil
}
// MARK: - 429 backoff
private static func usageBlockedUntil() -> Date? {
UserDefaults.standard.object(forKey: usageBlockedUntilKey) as? Date
}
private static func clearUsageBlock() {
UserDefaults.standard.removeObject(forKey: usageBlockedUntilKey)
}
private static func parseRetryAfterHeader(_ value: String?) -> Int? {
guard let value = value?.trimmingCharacters(in: .whitespaces), !value.isEmpty else { return nil }
if let seconds = Int(value), seconds >= 0 { return seconds }
let f = DateFormatter()
f.locale = Locale(identifier: "en_US_POSIX")
f.timeZone = TimeZone(secondsFromGMT: 0)
f.dateFormat = "EEE, dd MMM yyyy HH:mm:ss zzz"
if let date = f.date(from: value) {
return max(0, Int(date.timeIntervalSinceNow))
}
return nil
}
private static func recordUsageRateLimit(retryAfterSeconds: Int?) -> Date {
let seconds = max(retryAfterSeconds ?? 300, 60)
let until = Date().addingTimeInterval(TimeInterval(seconds))
UserDefaults.standard.set(until, forKey: usageBlockedUntilKey)
return until
}
static func disconnect() {
clearUsageBlock()
}
}

View file

@ -178,6 +178,24 @@ struct RoutingWaste: Codable, Sendable {
let byModel: [RoutingWasteModelEntry]
}
/// Workflow-intelligence rollup for the period. `correctionRate` and
/// `medianTimeToFirstEditMs` are null when not computable (no user turns / no
/// session ever edited), so both are optional.
struct WorkflowBlock: Codable, Sendable {
let corrections: Int
let correctionRate: Double?
let medianTimeToFirstEditMs: Double?
}
/// One entry of `topReworkedFiles`. `path` is basename-only (the CLI trims it
/// for privacy before the payload can leave the machine); `sessions` is the
/// distinct-session count and `edits` the total edit-family calls.
struct ReworkedFileEntry: Codable, Sendable {
let path: String
let sessions: Int
let edits: Int
}
struct CurrentBlock: Codable, Sendable {
let label: String
let cost: Double
@ -202,6 +220,13 @@ struct CurrentBlock: Codable, Sendable {
let skills: [SkillEntry]
let subagents: [SubagentEntry]
let mcpServers: [McpServerEntry]
/// Workflow-intelligence rollup. Optional so payloads from older CLIs
/// (which never emit it) still decode; absent -> the Workflow strip hides.
/// Declared last with a default so the memberwise initializer stays
/// backward-compatible for existing construction sites.
var workflow: WorkflowBlock? = nil
/// Files most reworked by edit-family calls. Empty on older CLIs.
var topReworkedFiles: [ReworkedFileEntry] = []
}
extension CurrentBlock {
@ -209,7 +234,8 @@ extension CurrentBlock {
case label, cost, calls, sessions, oneShotRate, inputTokens, outputTokens,
cacheHitPercent, codexCredits, topActivities, topModels, localModelSavings, providers, topProjects,
modelEfficiency, topSessions, retryTax, routingWaste,
tools, skills, subagents, mcpServers
tools, skills, subagents, mcpServers,
workflow, topReworkedFiles
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
@ -235,6 +261,8 @@ extension CurrentBlock {
skills = try c.decodeIfPresent([SkillEntry].self, forKey: .skills) ?? []
subagents = try c.decodeIfPresent([SubagentEntry].self, forKey: .subagents) ?? []
mcpServers = try c.decodeIfPresent([McpServerEntry].self, forKey: .mcpServers) ?? []
workflow = try c.decodeIfPresent(WorkflowBlock.self, forKey: .workflow)
topReworkedFiles = try c.decodeIfPresent([ReworkedFileEntry].self, forKey: .topReworkedFiles) ?? []
}
}

View file

@ -14,6 +14,35 @@ private let maxUpdateStderrBytes = 64 * 1024
// `menubar --force`, so we refuse to run them and ask the user to upgrade the CLI first.
private let minCliVersionForUpdate = "0.9.9"
enum UpdateFailureStage: Equatable {
case check
case cliUpdate
case menubarUpdate
var badgeLabel: String {
switch self {
case .check: "Update Check Failed"
case .cliUpdate: "CLI Update Failed"
case .menubarUpdate: "Menubar Update Failed"
}
}
var summary: String {
switch self {
case .check: "CodeBurn could not check GitHub for updates."
case .cliUpdate: "CodeBurn could not update the CLI."
case .menubarUpdate: "CodeBurn could not update the menubar app."
}
}
var retryHelp: String {
switch self {
case .check: "Click to retry the update check."
case .cliUpdate, .menubarUpdate: "Click to retry the update."
}
}
}
private final class LockedDataBuffer: @unchecked Sendable {
private let lock = NSLock()
private var data = Data()
@ -38,6 +67,19 @@ final class UpdateChecker {
var installedCliVersion: String?
var isUpdating = false
var updateError: String?
var updateFailureStage: UpdateFailureStage?
var updateBadgeLabel: String {
if isUpdating { return "Updating..." }
return updateFailureStage?.badgeLabel ?? "Update"
}
var updateHelpText: String {
guard let error = updateError, let stage = updateFailureStage else {
return "Update the CLI and menubar to the latest release"
}
return "\(stage.summary)\n\n\(error)\n\n\(stage.retryHelp)"
}
var updateAvailable: Bool {
guard let latest = latestVersion else { return false }
@ -88,6 +130,7 @@ final class UpdateChecker {
func check() async {
updateError = nil
updateFailureStage = nil
installedCliVersion = Self.queryInstalledCliVersion()
guard let url = URL(string: releasesAPI) else { return }
var request = URLRequest(url: url)
@ -118,7 +161,8 @@ final class UpdateChecker {
UserDefaults.standard.set(version, forKey: cachedVersionKey)
if let cliVersion { UserDefaults.standard.set(cliVersion, forKey: cachedCliVersionKey) }
} catch {
updateError = "Update check failed: \(error.localizedDescription)"
updateFailureStage = .check
updateError = error.localizedDescription
NSLog("CodeBurn: update check failed: \(error)")
}
}
@ -195,9 +239,11 @@ final class UpdateChecker {
if cliUpdateAvailable || cliTooOldForUpdate {
isUpdating = true
updateError = nil
updateFailureStage = nil
let cliPath = CodeburnCLI.baseArgv().first ?? ""
guard let argv = Self.cliUpdateInvocation(cliPath: cliPath), let bin = argv.first else {
isUpdating = false
updateFailureStage = .cliUpdate
updateError = "Could not find the package manager for \(cliPath.isEmpty ? "the CLI" : cliPath). Run \u{201C}\(cliUpdateCommand)\u{201D} manually, then try again."
return
}
@ -209,6 +255,7 @@ final class UpdateChecker {
guard let self else { return }
if status != 0 {
self.isUpdating = false
self.updateFailureStage = .cliUpdate
self.updateError = stderr.isEmpty ? "CLI update failed (exit \(status))" : stderr
NSLog("CodeBurn: CLI update failed (exit \(status)): \(stderr)")
return
@ -263,11 +310,13 @@ final class UpdateChecker {
func performUpdate() {
installedCliVersion = Self.queryInstalledCliVersion()
if cliTooOldForUpdate {
updateFailureStage = .menubarUpdate
updateError = "Your codeburn CLI (\(AppVersion.display(installedCliVersion ?? ""))) is too old to update the menubar. Run “\(cliUpdateCommand)” first, then try again."
return
}
isUpdating = true
updateError = nil
updateFailureStage = nil
let process = CodeburnCLI.makeProcess(subcommand: ["menubar", "--force"])
let errPipe = Pipe()
@ -297,6 +346,7 @@ final class UpdateChecker {
guard let self else { return }
self.isUpdating = false
if proc.terminationStatus != 0 {
self.updateFailureStage = .menubarUpdate
self.updateError = stderr.isEmpty ? "Update failed (exit \(proc.terminationStatus))" : stderr
NSLog("CodeBurn: update failed (exit \(proc.terminationStatus)): \(stderr)")
} else {
@ -309,6 +359,7 @@ final class UpdateChecker {
try process.run()
} catch {
isUpdating = false
updateFailureStage = .menubarUpdate
updateError = error.localizedDescription
NSLog("CodeBurn: update spawn failed: \(error)")
}

View file

@ -99,6 +99,8 @@ enum UsageDataChangeGuard {
for name in [".openclaw", ".clawdbot", ".moltbot", ".moldbot"] {
add(path(homeDirectory, name, "agents"), scanFirstLevelDirectories: false)
}
let openClaudeRoot = expand(environment["CODEBURN_OPENCLAUDE_DIR"] ?? path(homeDirectory, ".openclaude"), homeDirectory: homeDirectory)
add(path(openClaudeRoot, "projects"), scanFirstLevelDirectories: true)
add(path(applicationSupport, "Open Design"), scanFirstLevelDirectories: false)
add(path(homeDirectory, ".pi", "agent", "sessions"), scanFirstLevelDirectories: false)
add(path(homeDirectory, ".omp", "agent", "sessions"), scanFirstLevelDirectories: false)

View file

@ -0,0 +1,127 @@
import Foundation
/// Pure, testable derivation of the menubar Workflow strip from the decoded
/// payload. The view renders exactly these values; all formatting and the
/// coaching-note selection live here so they can be unit-tested without SwiftUI.
///
/// Every field is nil unless it carries real signal: a stat is never shown as a
/// zero placeholder. `isEmpty` is true when none of the three stats fire, and
/// the section renders nothing in that case.
struct WorkflowStripModel: Equatable {
/// Correction rate as a percent with the raw count, e.g. "3% (5)".
let corrections: String?
/// Median time to the first edit, e.g. "6m".
let firstEdit: String?
/// Most reworked file, basename only, e.g. "sdk.py".
let reworkedName: String?
/// Distinct-session count for the most reworked file, e.g. 15.
let reworkedSessions: Int?
/// One coaching note, or nil. Mirrors the first-firing note in
/// src/workflow-insights.ts buildCoachingNotes over the payload's signals.
let note: String?
/// Nothing worth showing -> the section hides itself.
var isEmpty: Bool {
corrections == nil && firstEdit == nil && reworkedName == nil
}
/// The reworked stat as one string, e.g. "sdk.py ×15" (used for the exact
/// rendered value and by tests).
var reworked: String? {
guard let reworkedName, let reworkedSessions else { return nil }
return "\(reworkedName) ×\(reworkedSessions)"
}
}
extension WorkflowStripModel {
// Coaching-note thresholds, matching src/workflow-insights.ts exactly.
static let correctionHighRate = 0.15
static let correctionMinCount = 3
static let churnMinSessions = 3
static let ttfeSlowMs: Double = 5 * 60 * 1000
init(workflow: WorkflowBlock?, topReworkedFiles: [ReworkedFileEntry]) {
let topFile = topReworkedFiles.first
// Corrections: only when the displayed rate is a real, non-zero percent.
// The rate is the headline; a count of corrections that rounds to 0% (a
// handful over thousands of turns) carries no signal, so the whole stat
// hides rather than reading "0% (6)".
if let rate = workflow?.correctionRate, let count = workflow?.corrections,
count > 0, Int((rate * 100).rounded()) >= 1 {
corrections = "\(Self.percent(rate)) (\(count))"
} else {
corrections = nil
}
// First edit: only a real, positive median.
if let ms = workflow?.medianTimeToFirstEditMs, ms > 0 {
firstEdit = Self.formatDuration(ms: ms)
} else {
firstEdit = nil
}
// Top reworked file: basename ×sessions.
if let file = topFile, file.sessions > 0 {
reworkedName = Self.basename(file.path)
reworkedSessions = file.sessions
} else {
reworkedName = nil
reworkedSessions = nil
}
note = Self.coachingNote(
corrections: workflow?.corrections ?? 0,
correctionRate: workflow?.correctionRate,
topFile: topFile,
medianTimeToFirstEditMs: workflow?.medianTimeToFirstEditMs
)
}
/// Picks the single coaching note: the first threshold that fires, in the
/// same order as buildCoachingNotes (the worst-one-shot note is omitted
/// because that signal is not in the menubar payload). Copy is byte-for-byte
/// the CLI's, no em-dashes.
static func coachingNote(corrections: Int,
correctionRate: Double?,
topFile: ReworkedFileEntry?,
medianTimeToFirstEditMs: Double?) -> String? {
if let rate = correctionRate, rate >= correctionHighRate, corrections >= correctionMinCount {
return "You corrected the assistant on \(percent(rate)) of prompts (\(corrections) times). State the requirements in the first message to cut the back and forth."
}
if let file = topFile, file.sessions >= churnMinSessions {
return "\(basename(file.path)) was reworked across \(file.sessions) sessions (\(file.edits) edits). A focused pass on it may cost less than the repeated churn."
}
if let ms = medianTimeToFirstEditMs, ms >= ttfeSlowMs {
return "Median time to first edit is \(formatDurationShort(ms: ms)). Point the assistant at the target file to cut the exploration before it starts editing."
}
return nil
}
/// "First edit" stat duration: <60s -> "Ns", <60m -> "Nm", else "Nh Nm".
static func formatDuration(ms: Double) -> String {
if ms < 60_000 { return "\(Int((ms / 1000).rounded()))s" }
let totalMinutes = Int((ms / 60_000).rounded())
if totalMinutes < 60 { return "\(totalMinutes)m" }
return "\(totalMinutes / 60)h \(totalMinutes % 60)m"
}
/// Mirrors formatDurationShort in src/workflow-insights.ts: whole seconds
/// under a minute, whole minutes above. Used only inside the coaching note so
/// the copy matches the CLI exactly.
static func formatDurationShort(ms: Double) -> String {
if ms >= 60_000 { return "\(Int((ms / 60_000).rounded()))m" }
return "\(Int((ms / 1000).rounded()))s"
}
/// Rounded percent from a 0-1 rate, matching JS `Math.round(rate * 100)`.
static func percent(_ rate: Double) -> String {
"\(Int((rate * 100).rounded()))%"
}
/// Last path component. The payload is already basename-only, but an older
/// CLI could emit a fuller (forward-slash) path.
static func basename(_ path: String) -> String {
path.split(separator: "/").last.map(String.init) ?? path
}
}

View file

@ -0,0 +1,124 @@
import Foundation
/// Closed set of terminal emulators CodeBurn knows how to drive (#877).
///
/// SECURITY: this type exists specifically so that a *user preference* can never become a
/// free-form string inside an AppleScript. The preference is persisted as a raw value that is
/// parsed back through `init(rawValue:)`; anything unrecognised collapses to `.default`. Every
/// application name that reaches `osascript` is a hardcoded literal in `script(command:)` --
/// never a stored or interpolated string. The only interpolated value is the command, which
/// callers must have already validated token-by-token with `CodeburnCLI.isSafe`.
///
/// Only terminals with a real "run this in an interactive session and leave the window open"
/// scripting verb are listed. Terminal.app has `do script`; iTerm2 has
/// `write text` on a session. Ghostty, WezTerm, Warp, Alacritty and kitty expose no equivalent
/// AppleScript verb -- launching them with `-e <cmd>` tears the window down the moment the
/// command exits, which would make "Full Report" flash and vanish, so they intentionally stay
/// out of this enum rather than shipping broken.
enum PreferredTerminal: String, CaseIterable, Identifiable, Sendable {
case terminal
case iTerm2 = "iterm2"
var id: String { rawValue }
var label: String {
switch self {
case .terminal: return "Terminal (macOS default)"
case .iTerm2: return "iTerm2"
}
}
/// Bundle locations probed with a plain `fileExists` check, mirroring the pre-#877
/// behaviour.
///
/// This is a simplicity/determinism choice, NOT a security boundary. A fixed list needs no
/// LaunchServices database state, gives the same answer on every machine, and keeps the
/// Settings "(not installed)" hint honest without a framework round-trip.
///
/// It is worth being precise about what it does *not* buy, because an earlier version of
/// this comment overstated it. `NSWorkspace.urlForApplication(withBundleIdentifier:)` does
/// register and resolve bundles outside the standard folders -- an app dropped in
/// ~/Downloads is picked up within seconds -- but when a copy also exists in /Applications,
/// LaunchServices ranks /Applications first, and it still does so when the ~/Downloads copy
/// advertises a *higher* CFBundleShortVersionString. So on a normally installed machine the
/// two approaches resolve to the same bundle; the fixed list only differs by declining to
/// find an install the user put somewhere unusual, in which case we fall back to
/// Terminal.app rather than driving a bundle from a transient location such as a mounted
/// DMG. Neither approach checks a code signature or team ID, so neither authenticates the
/// app it drives.
var appPaths: [String] {
switch self {
case .terminal:
return [
"/System/Applications/Utilities/Terminal.app",
"/Applications/Utilities/Terminal.app",
]
case .iTerm2:
let home = FileManager.default.homeDirectoryForCurrentUser.path
return [
"/Applications/iTerm.app",
"\(home)/Applications/iTerm.app",
]
}
}
var isInstalled: Bool {
appPaths.contains(where: FileManager.default.fileExists(atPath:))
}
/// AppleScript that brings the terminal forward, opens a window and runs `command`.
///
/// `command` is the ONLY interpolated value; callers guarantee it is whitespace-joined argv
/// where every token passed `CodeburnCLI.isSafe` (no quotes, no `$`, no backticks, no `;`),
/// or a hardcoded literal. The `tell application` target is a compile-time literal per case.
func script(command: String) -> String {
switch self {
case .terminal:
return """
tell application "Terminal"
activate
do script "\(command)"
end tell
"""
case .iTerm2:
// iTerm2 has no `do script`. A window must be created from a profile first, then
// text is written into its session.
//
// The target MUST be "iTerm", not "iTerm2", even though the app calls itself iTerm2
// and its CFBundleName is "iTerm2". AppleScript resolves the name of a *not yet
// running* app through LaunchServices by bundle file name, and the bundle is
// `iTerm.app`. Measured on iTerm2 3.6.11: with the app quit,
// `tell application "iTerm2"` fails to even compile (-2741, "expected , but found
// class name" -- `text` binds to the built-in class because iTerm2's terminology
// never loads) while `tell application "iTerm"` compiles, cold-launches the app and
// runs the command. `"iTerm2"` only works while iTerm2 already happens to be
// running, which made the bug easy to miss when testing interactively.
return """
tell application "iTerm"
activate
set newWindow to (create window with default profile)
tell current session of newWindow
write text "\(command)"
end tell
end tell
"""
}
}
// MARK: - Persistence
static let defaultsKey = "CodeBurnPreferredTerminal"
/// Terminal.app, i.e. exactly the pre-#877 behaviour, so users who never open Settings
/// see no change.
static let `default`: PreferredTerminal = .terminal
static func saved(defaults: UserDefaults = .standard) -> PreferredTerminal {
guard let raw = defaults.string(forKey: defaultsKey) else { return .default }
return PreferredTerminal(rawValue: raw) ?? .default
}
func persist(defaults: UserDefaults = .standard) {
defaults.set(rawValue, forKey: Self.defaultsKey)
}
}

View file

@ -1,65 +1,167 @@
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.
/// Runs commands in the user's preferred terminal (#877). Every string that reaches AppleScript
/// `do script` / `write text` must be whitespace-joined argv where each token passes
/// `CodeburnCLI.isSafe` (regex allowlist that excludes shell metacharacters), OR a hardcoded
/// literal defined here. `runScript` re-validates defensively so a future caller can't bypass
/// the invariant.
///
/// The terminal itself is chosen from the closed `PreferredTerminal` enum, never from a
/// user-supplied string, so the `tell application "..."` target stays a compile-time literal.
///
/// Resolution is a chain, not a single pick, because "installed" does not imply "scriptable":
/// osascript can still fail on a missing Automation (TCC) approval, an app that is present but
/// broken, or terminology it cannot load. So each candidate is actually run and its exit status
/// checked, and only once every candidate has failed do we fall back to a detached headless
/// spawn -- which at least still runs the subcommand on machines with no scriptable terminal
/// (Ghostty/Warp/kitty users). Each step logs, so a user who sees nothing has a trail in
/// Console.app instead of an app that looks dead.
enum TerminalLauncher {
private static let terminalPaths = [
"/System/Applications/Utilities/Terminal.app",
"/Applications/Utilities/Terminal.app",
]
/// Upper bound on how long we wait for one `osascript` invocation.
///
/// The failure modes we care about are fast: a terminology/compile error returns in ~0.15s
/// and a denied Automation prompt is comparably quick. The only slow case is a *successful*
/// cold app launch, which is seconds. So a timeout is not the mechanism that detects
/// failure -- the exit status is -- and hitting it is treated as "it is still working",
/// not as a failure. Falling back on timeout would open a second window in a second
/// terminal, which is worse than waiting. The bound exists purely so a wedged osascript
/// cannot pin a background worker forever.
private static let scriptTimeout: TimeInterval = 30
static func open(subcommand: [String]) {
let argv = CodeburnCLI.baseArgv() + subcommand
guard argv.allSatisfy(CodeburnCLI.isSafe) else {
guard let command = safeCommand(argv: CodeburnCLI.baseArgv() + subcommand) 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 chain = terminalChain()
// Knowing whether osascript worked means waiting for it, and a cold app launch keeps it
// busy for a second or two. Callers are SwiftUI button actions on the main thread, so
// the whole chain runs on a background queue: the popover stays responsive and the
// fallback decision is made on a real exit status rather than on a guess.
DispatchQueue.global(qos: .userInitiated).async {
if runFirstWorking(chain: chain, command: command, attempt: runScript) != nil { return }
if !chain.isEmpty {
NSLog("CodeBurn: no terminal accepted the command; running it headless instead")
}
let headless = CodeburnCLI.makeProcess(subcommand: subcommand)
do {
try headless.run()
} catch {
NSLog("CodeBurn: headless fallback also failed: \(error.localizedDescription)")
}
}
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
/// Launches `claude login` in the preferred terminal 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.
///
/// Returns whether a scriptable terminal exists at all. It cannot report the eventual exit
/// status without blocking the main thread, so a later failure is logged rather than
/// returned; there is no headless fallback here because a login flow is interactive by
/// definition and would be useless without a window.
@discardableResult
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")
let chain = terminalChain()
guard !chain.isEmpty else {
NSLog("CodeBurn: no scriptable terminal present; user must run `claude login` manually")
return false
}
runInTerminal(command: "claude login", preValidated: true)
DispatchQueue.global(qos: .userInitiated).async {
if runFirstWorking(chain: chain, command: "claude login", attempt: runScript) == nil {
NSLog("CodeBurn: no terminal accepted `claude login`; user must run it manually")
}
}
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
}
/// Joins `argv` into the command string, or returns nil if any token fails the allowlist.
/// Extracted so the invariant is directly testable without launching anything.
static func safeCommand(argv: [String]) -> String? {
guard argv.allSatisfy(CodeburnCLI.isSafe) else { return nil }
return argv.joined(separator: " ")
}
/// Terminals to try, most preferred first: the configured one when installed, then
/// Terminal.app as the always-present backstop. Empty means nothing scriptable is present
/// and the caller should go headless. `isInstalled` is injectable for tests.
static func terminalChain(
preference: PreferredTerminal = PreferredTerminal.saved(),
isInstalled: (PreferredTerminal) -> Bool = { $0.isInstalled }
) -> [PreferredTerminal] {
var chain: [PreferredTerminal] = []
if isInstalled(preference) { chain.append(preference) }
if preference != .terminal, isInstalled(.terminal) { chain.append(.terminal) }
return chain
}
/// The terminal that will be attempted first, or nil when nothing scriptable is installed.
static func resolvedTerminal(
preference: PreferredTerminal = PreferredTerminal.saved(),
isInstalled: (PreferredTerminal) -> Bool = { $0.isInstalled }
) -> PreferredTerminal? {
terminalChain(preference: preference, isInstalled: isInstalled).first
}
/// Runs `command` in the first terminal of `chain` that actually succeeds and returns it,
/// or nil when every candidate failed. `attempt` is injectable so tests can exercise
/// "primary failed -> fell back" without launching anything.
@discardableResult
static func runFirstWorking(
chain: [PreferredTerminal],
command: String,
attempt: (PreferredTerminal, String) -> Bool
) -> PreferredTerminal? {
for terminal in chain {
if attempt(terminal, command) { return terminal }
NSLog("CodeBurn: \(terminal.label) did not run the command; trying the next fallback")
}
let script = """
tell application "Terminal"
activate
do script "\(command)"
end tell
"""
return nil
}
/// Drives one terminal via osascript and reports whether it worked.
private static func runScript(_ terminal: PreferredTerminal, command: String) -> Bool {
// Defence in depth: every caller validates already, but re-check so a future caller
// cannot reach osascript with an unvalidated string.
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.label)")
return false
}
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
process.arguments = ["-e", script]
try? process.run()
process.arguments = ["-e", terminal.script(command: command)]
let errorPipe = Pipe()
process.standardError = errorPipe
let finished = DispatchSemaphore(value: 0)
process.terminationHandler = { _ in finished.signal() }
do {
try process.run()
} catch {
NSLog("CodeBurn: could not spawn osascript for \(terminal.label): \(error.localizedDescription)")
return false
}
guard finished.wait(timeout: .now() + scriptTimeout) == .success else {
NSLog("CodeBurn: osascript for \(terminal.label) still running after \(Int(scriptTimeout))s; assuming its window opened")
return true
}
guard process.terminationStatus == 0 else {
// osascript writes one short line here, so reading after exit cannot deadlock on a
// full pipe buffer.
let detail = String(
data: errorPipe.fileHandleForReading.readDataToEndOfFile(),
encoding: .utf8
)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
NSLog("CodeBurn: osascript for \(terminal.label) exited \(process.terminationStatus): \(detail)")
return false
}
return true
}
}

View file

@ -0,0 +1,47 @@
import AppKit
import Foundation
/// Pure policy for the status-item right-click menu (#802).
///
/// Keeps the event-mask, debounce, and presentation choices out of AppDelegate
/// so they can be unit-tested without spinning up an NSStatusItem. The bugs this
/// encodes:
///
/// 1. **Flash** presenting on `rightMouseDown` lets the matching `rightMouseUp`
/// dismiss the menu immediately. Monitor (and legacy action) use mouse-*up*.
/// 2. **Jump/scroll** manual `NSMenu.popUp(at:in:)` tracks poorly while the
/// cursor still sits on the status item above the menu. Attach via
/// `statusItem.menu` + `performClick` instead.
/// 3. **Double-present** on macOS 26 both the button action and the global
/// monitor can fire for one click; debounce collapses them.
enum StatusItemContextMenuPolicy {
/// Global-monitor / action event. Must be mouse-up (see flash note above).
static let presentEventMask: NSEvent.EventTypeMask = .rightMouseUp
/// Minimum gap between presents. Covers the dual-path race on macOS 26.
static let presentDebounceSeconds: TimeInterval = 0.3
/// How the menu is shown once a present is accepted.
enum Presentation: Equatable {
/// Assign `statusItem.menu` then `button.performClick`. AppKit positions
/// and tracks the menu under the status item. Clear menu in menuDidClose.
case statusItemMenu
/// Manual `NSMenu.popUp(at:in:)`. Causes scroll-chevron jump on mouse move
/// when the cursor starts above the menu. Kept only as a named anti-pattern
/// so tests can lock that we do *not* use it.
case manualPopUp
}
static let presentation: Presentation = .statusItemMenu
/// Returns true and advances `lastPresentedAt` when a new present is allowed.
static func acceptPresent(
now: Date,
lastPresentedAt: inout Date,
debounce: TimeInterval = presentDebounceSeconds
) -> Bool {
guard now.timeIntervalSince(lastPresentedAt) > debounce else { return false }
lastPresentedAt = now
return true
}
}

View file

@ -494,8 +494,10 @@ extension ProviderFilter {
case .kiloCode: return Color(red: 0x00/255.0, green: 0x96/255.0, blue: 0x88/255.0)
case .kiro: return Color(red: 0x4A/255.0, green: 0x9E/255.0, blue: 0xC4/255.0)
case .kimi: return Color(red: 0xA4/255.0, green: 0xC6/255.0, blue: 0x39/255.0)
case .kimiCode: return Color(red: 0xA3/255.0, green: 0xE6/255.0, blue: 0x35/255.0)
case .lingtaiTui: return Color(red: 0x22/255.0, green: 0xA7/255.0, blue: 0xA0/255.0)
case .openclaw: return Color(red: 0xDA/255.0, green: 0x70/255.0, blue: 0x56/255.0)
case .openclaude: return Color(red: 0xC2/255.0, green: 0x41/255.0, blue: 0x6B/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)
case .qwen: return Color(red: 0x61/255.0, green: 0x5E/255.0, blue: 0xEB/255.0)

View file

@ -62,7 +62,7 @@ struct HeatmapSection: View {
// their own quota data sources.
InsightMode.allCases.filter { mode in
if mode == .plan {
return store.selectedProvider == .claude || store.selectedProvider == .codex
return store.selectedProvider == .claude || store.selectedProvider == .codex || store.selectedProvider == .kimiCode
}
return true
}
@ -80,6 +80,8 @@ struct HeatmapSection: View {
case .plan:
if store.selectedProvider == .codex {
CodexPlanInsight()
} else if store.selectedProvider == .kimiCode {
KimiPlanInsight()
} else {
PlanInsight(usage: store.subscription)
}
@ -2065,7 +2067,7 @@ private struct CodexPlanInsight: View {
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.primary)
Spacer()
if let resetsAt = (usage.primary ?? usage.secondary)?.resetsAt {
if let resetsAt = (usage.primary ?? usage.secondary)?.resetsAt ?? usage.creditLimit?.resetsAt {
Text("Resets \(relativeReset(resetsAt))")
.font(.system(size: 10.5))
.foregroundStyle(.secondary)
@ -2107,6 +2109,26 @@ private struct CodexPlanInsight: View {
)
}
}
// No rate windows here, so without this row the card is empty.
if let credits = usage.creditLimit {
UtilizationRow(
label: credits.displayLabel,
percent: credits.usedPercent,
resetsAt: credits.resetsAt,
projection: pace(for: credits)
)
} else if usage.creditsUnlimited {
// Uncapped on purpose, not a failed fetch.
HStack(alignment: .firstTextBaseline) {
Text("Credits")
.font(.system(size: 11, weight: .medium))
.foregroundStyle(.secondary)
Spacer()
Text("Unlimited")
.font(.system(size: 10.5))
.foregroundStyle(.secondary)
}
}
// Limit-reset credits the account is holding. Hidden at zero so
// plans that never receive these grants see no extra row.
if let resets = usage.resetCredits, resets.availableCount > 0 {
@ -2145,6 +2167,25 @@ private struct CodexPlanInsight: View {
)
}
/// Rate-window pace math over the spend control's calendar month.
private func pace(for credits: CodexUsage.CreditLimit) -> WindowProjection? {
guard let windowSeconds = credits.windowSeconds,
let result = QuotaPace.evaluate(
usedPercent: credits.usedPercent,
resetsAt: credits.resetsAt,
windowSeconds: windowSeconds
)
else { return nil }
return WindowProjection(
percent: result.projectedPercent,
willOverflow: result.willOverflow,
hitsLimitAt: result.hitsLimitAt,
source: .linear,
deltaPercent: result.deltaPercent,
compact: TimeInterval(windowSeconds) <= QuotaPace.etaSuppressionMaxSeconds
)
}
private func resetCreditsLabel(_ resets: CodexUsage.ResetCredits) -> String {
let count = "\(resets.availableCount) available"
guard let next = resets.nextExpiresAt else { return count }
@ -2158,6 +2199,117 @@ private struct CodexPlanInsight: View {
}
}
/// Plan tab for Kimi Code. Reads the CLI credential file (no keychain, no
/// OAuth refresh tokens are short-lived and only the CLI renews them), so
/// terminal failure means "run the CLI once to refresh your login".
private struct KimiPlanInsight: View {
@Environment(AppStore.self) private var store
var body: some View {
Group {
switch KimiQuotaPresentation.planContent(loadState: store.kimiLoadState, hasUsage: store.kimiUsage != nil) {
case .noCredentials:
PlanNoCredentialsView(
title: "No Kimi Code credentials found",
message: "Sign in with the Kimi CLI first. Then click Try Again."
) { Task { await store.bootstrapKimi() } }
case .loading:
PlanLoadingView(message: "Reading Kimi Code credentials...")
case .failed:
PlanFailedView(
error: store.kimiError
) { Task { await store.refreshKimi() } }
case .transientFailed:
PlanFailedView(
error: store.kimiError ?? "Kimi temporarily unreachable — retrying."
) { Task { await store.refreshKimi() } }
case let .reconnect(reason):
PlanReconnectView(
title: "Refresh Kimi Code login",
reason: reason,
fallback: "Kimi Code tokens are short-lived. Run the Kimi CLI once to refresh your login, then click Reconnect."
) { Task { await store.bootstrapKimi() } }
case let .usage(idle):
if let usage = store.kimiUsage {
loadedBody(usage: usage, idle: idle)
} else {
PlanLoadingView(message: "Reading Kimi Code credentials...")
}
}
}
}
@ViewBuilder
private func loadedBody(usage: KimiUsage, idle: Bool) -> some View {
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .firstTextBaseline) {
Text(usage.plan ?? "Kimi Code")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.primary)
Spacer()
if let resetsAt = usage.primary?.resetsAt {
Text("Resets \(relativeReset(resetsAt))")
.font(.system(size: 10.5))
.foregroundStyle(.secondary)
}
}
if let primary = usage.primary {
UtilizationRow(
label: "\(primary.label) window",
percent: primary.usedPercent,
resetsAt: primary.resetsAt,
projection: nil
)
}
ForEach(Array(usage.details.enumerated()), id: \.offset) { _, window in
UtilizationRow(
label: "\(window.label) window",
percent: window.usedPercent,
resetsAt: window.resetsAt,
projection: nil
)
}
if let parallel = usage.parallelLimit, parallel > 0 {
HStack(alignment: .firstTextBaseline) {
Text("Parallel sessions")
.font(.system(size: 11, weight: .medium))
.foregroundStyle(.secondary)
Spacer()
Text("\(parallel)")
.font(.system(size: 10.5))
.foregroundStyle(.secondary)
}
}
if idle {
Text("Login idle. Run the Kimi CLI to refresh.")
.font(.system(size: 10))
.foregroundStyle(.tertiary)
}
if KimiQuotaPresentation.isStale(fetchedAt: usage.fetchedAt) {
Text("as of \(shortTime(usage.fetchedAt))")
.font(.system(size: 10))
.foregroundStyle(.tertiary)
}
}
.padding(.horizontal, 14)
.padding(.top, 4)
.padding(.bottom, 8)
}
private func relativeReset(_ date: Date) -> String {
let f = RelativeDateTimeFormatter()
f.unitsStyle = .short
return f.localizedString(for: date, relativeTo: Date())
}
private func shortTime(_ date: Date) -> String {
let f = DateFormatter()
f.timeStyle = .short
f.dateStyle = .none
return f.string(from: date)
}
}
private struct WindowProjection {
enum Source { case linear, historicalBaseline }
let percent: Double

View file

@ -36,6 +36,7 @@ struct MenuBarContent: View {
ActivitySection()
Divider().opacity(0.5)
ModelsSection()
WorkflowSection()
Divider().opacity(0.5)
ToolingSection()
Divider().opacity(0.5)
@ -91,6 +92,10 @@ struct MenuBarContent: View {
private var isFilteredEmpty: Bool {
guard store.selectedProvider != .all else { return false }
// Plan-capable providers keep their sections visible so the Plan tab
// (live subscription quota) stays reachable even on days with no
// local usage the quota endpoint doesn't depend on local sessions.
if store.selectedProvider == .claude || store.selectedProvider == .codex || store.selectedProvider == .kimiCode { return false }
if store.payload.current.cost > 0 || store.payload.current.calls > 0 { return false }
if providerHasCostInAllPayload { return false }
return true
@ -519,7 +524,9 @@ private struct UpdateBadge: View {
var body: some View {
Button {
if updateChecker.updateAvailable || updateChecker.cliUpdateAvailable {
if updateChecker.updateFailureStage == .check {
Task { await updateChecker.check() }
} else if updateChecker.updateAvailable || updateChecker.cliUpdateAvailable {
updateChecker.performFullUpdate()
} else {
Task { await updateChecker.check() }
@ -537,7 +544,7 @@ private struct UpdateBadge: View {
Image(systemName: "arrow.down.circle.fill")
.font(.system(size: 10))
}
Text(updateChecker.isUpdating ? "Updating..." : (updateChecker.updateError == nil ? "Update" : "Failed"))
Text(updateChecker.updateBadgeLabel)
.font(.system(size: 10, weight: .medium))
}
.padding(.horizontal, 8)
@ -547,7 +554,9 @@ private struct UpdateBadge: View {
.tint(Theme.brandAccent)
.controlSize(.mini)
.disabled(updateChecker.isUpdating)
.help(updateChecker.updateError ?? "Update the CLI and menubar to the latest release")
.help(updateChecker.updateHelpText)
.accessibilityLabel(updateChecker.updateBadgeLabel)
.accessibilityHint(updateChecker.updateHelpText)
}
}

View file

@ -21,6 +21,10 @@ struct SettingsView: View {
.tabItem { Label("Codex", systemImage: "chevron.left.forwardslash.chevron.right") }
.tag("codex")
KimiSettingsTab()
.tabItem { Label("Kimi", systemImage: "moon.stars") }
.tag("kimi")
DevinSettingsTab()
.tabItem { Label("Devin", systemImage: "flame.fill") }
.tag("devin")
@ -29,7 +33,9 @@ struct SettingsView: View {
.tabItem { Label("About", systemImage: "info.circle") }
.tag("about")
}
.frame(width: 520, height: 430)
// 6 tabs need ~600pt to render as a visible tab bar; narrower widths
// make SwiftUI collapse the tab bar into a ">>" overflow menu.
.frame(width: 600, height: 430)
}
}
@ -52,6 +58,11 @@ private struct GeneralSettingsTab: View {
@AppStorage(UsageRefreshCadence.defaultsKey)
private var usageRefreshSeconds: Int = UsageRefreshCadence.default.rawValue
// Stored as the raw string so an unrecognised value (older build, manual
// `defaults write`) parses back to .terminal instead of failing to decode.
@AppStorage(PreferredTerminal.defaultsKey)
private var preferredTerminalRaw: String = PreferredTerminal.default.rawValue
private let costPresets: Set<Double> = [25, 50, 100, 200, 500]
private let tokenPresets: Set<Double> = [1_000_000, 5_000_000, 10_000_000, 25_000_000, 50_000_000, 100_000_000]
@ -143,6 +154,22 @@ private struct GeneralSettingsTab: View {
.foregroundStyle(.secondary)
}
Section("Terminal") {
Picker("Open commands in", selection: Binding(
get: { PreferredTerminal(rawValue: preferredTerminalRaw) ?? .default },
set: { preferredTerminalRaw = $0.rawValue }
)) {
ForEach(PreferredTerminal.allCases) { terminal in
Text(terminal.isInstalled ? terminal.label : "\(terminal.label) (not installed)")
.tag(terminal)
}
}
.pickerStyle(.menu)
Text("Where Full Report and Optimize open. If the chosen app isn't installed CodeBurn falls back to Terminal; if that's missing too the command runs in the background. Only terminals that can script a command into a live window are listed.")
.font(.system(size: 11))
.foregroundStyle(.secondary)
}
Section("Alerts") {
// The budget tracks whatever the menubar metric shows: dollars for
// the Cost metric, tokens for the Tokens / Total Tokens metrics.
@ -481,7 +508,7 @@ private struct CodexSettingsTab: View {
CodexConnectionRow()
}
Section {
Text("Codex live-quota tracking reads `~/.codex/auth.json` once on Connect, then keeps a local copy under Application Support so subsequent quota fetches don't re-read the original. Only ChatGPT-mode auth (Plus / Pro / Team / Business) is supported — API-key users are billed per request and have a different reporting surface.")
Text("Codex live-quota tracking reads `~/.codex/auth.json` once on Connect, then keeps a local copy under Application Support so subsequent quota fetches don't re-read the original. Only ChatGPT-mode auth (Plus / Pro / Team / Business / Edu / Enterprise) is supported. API-key users are billed per request and have a different reporting surface. Credit-metered workspaces report no rate-limit windows, so their monthly credit allowance is shown instead.")
.font(.system(size: 11))
.foregroundStyle(.secondary)
} header: {
@ -606,6 +633,131 @@ private struct CodexConnectionRow: View {
}
}
// MARK: - Kimi Code
private struct KimiSettingsTab: View {
var body: some View {
Form {
Section("Connection") {
KimiConnectionRow()
}
Section {
Text("Kimi Code live-quota tracking reads `~/.kimi-code/credentials/kimi-code.json` directly — nothing is copied or stored. Access tokens are short-lived (~15 minutes) and only the Kimi CLI refreshes them, so if the connection shows as expired, run the Kimi CLI once and click Reconnect.")
.font(.system(size: 11))
.foregroundStyle(.secondary)
} header: {
Text("How it works")
}
}
.formStyle(.grouped)
.padding()
}
}
private struct KimiConnectionRow: View {
@Environment(AppStore.self) private var store
@State private var showDisconnectConfirm = false
var body: some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: stateIcon)
.font(.system(size: 18))
.foregroundStyle(stateTint)
.frame(width: 22)
VStack(alignment: .leading, spacing: 2) {
Text(stateTitle)
.font(.system(size: 12, weight: .semibold))
Text(stateDetail)
.font(.system(size: 11))
.foregroundStyle(.secondary)
.lineLimit(2)
}
Spacer()
actionButton
}
.padding(.vertical, 4)
}
private var stateIcon: String {
switch store.kimiLoadState {
case .loaded: return "checkmark.circle.fill"
case .terminalFailure: return "exclamationmark.triangle.fill"
case .transientFailure: return "clock.arrow.circlepath"
case .bootstrapping, .loading: return "ellipsis.circle"
case .notBootstrapped, .dormant, .noCredentials: return "link.circle"
case .failed: return "xmark.circle"
}
}
private var stateTint: Color {
switch store.kimiLoadState {
case .loaded: return .green
case .terminalFailure, .failed: return .red
case .transientFailure: return .orange
default: return .secondary
}
}
private var stateTitle: String {
switch store.kimiLoadState {
case .loaded: return "Connected"
case let .terminalFailure(reason): return reason ?? "Login refresh required"
case .transientFailure: return "Backing off"
case .bootstrapping: return "Connecting…"
case .loading: return "Refreshing…"
case .dormant: return "Ready"
case .notBootstrapped, .noCredentials: return "Not connected"
case .failed: return "Couldn't load Kimi quota"
}
}
private var stateDetail: String {
switch store.kimiLoadState {
case .loaded:
return "Live quota tracked from api.kimi.com."
case .terminalFailure:
return "Run the Kimi CLI once to refresh your login, then click Reconnect."
case .transientFailure: return store.kimiError ?? "Kimi rate-limited; auto-retrying."
case .bootstrapping: return "Reading ~/.kimi-code credentials."
case .loading: return "Background refresh in progress."
case .dormant: return "Tap Load Quota to fetch live usage from api.kimi.com."
case .notBootstrapped, .noCredentials:
return "Sign in with the Kimi CLI first, then click Connect."
case .failed: return store.kimiError ?? ""
}
}
@ViewBuilder
private var actionButton: some View {
switch store.kimiLoadState {
case .loaded, .transientFailure, .loading:
Button("Disconnect") { showDisconnectConfirm = true }
.confirmationDialog(
"Disconnect Kimi Code?",
isPresented: $showDisconnectConfirm
) {
Button("Disconnect", role: .destructive) {
store.disconnectKimi()
}
Button("Cancel", role: .cancel) {}
} message: {
Text("CodeBurn will stop tracking Kimi Code quota. Your ~/.kimi-code credentials are untouched — the Kimi CLI keeps working.")
}
case .terminalFailure, .noCredentials, .failed:
Button("Reconnect") { Task { await store.bootstrapKimi() } }
.buttonStyle(.borderedProminent)
case .dormant:
Button("Load Quota") { Task { await store.bootstrapKimi() } }
.buttonStyle(.borderedProminent)
case .notBootstrapped:
Button("Connect") { Task { await store.bootstrapKimi() } }
.buttonStyle(.borderedProminent)
case .bootstrapping:
ProgressView().controlSize(.small)
}
}
}
// MARK: - Devin
private struct DevinSettingsTab: View {

View file

@ -0,0 +1,111 @@
import SwiftUI
/// Compact workflow-intelligence strip: one row of up to three stats plus one
/// coaching note. Reads whatever payload the store currently holds, so it
/// follows the selected agent tab automatically. Renders nothing when the
/// payload carries no workflow signal (older CLIs, or a period with nothing
/// measurable), so it never shows zero placeholders.
struct WorkflowSection: View {
@Environment(AppStore.self) private var store
var body: some View {
let model = WorkflowStripModel(
workflow: store.payload.current.workflow,
topReworkedFiles: store.payload.current.topReworkedFiles
)
if !model.isEmpty {
// Own the leading divider so the section slots into the popover's
// divider rhythm and leaves no doubled line when it's hidden.
VStack(spacing: 0) {
Divider().opacity(0.5)
VStack(alignment: .leading, spacing: 8) {
SectionCaption(text: "Workflow")
WorkflowStatRow(model: model)
if let note = model.note {
Text(note)
.font(.system(size: 11))
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.tail)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.padding(.horizontal, 14)
.padding(.vertical, 11)
}
}
}
}
private enum WorkflowStat: Identifiable {
case labeled(id: String, label: String, value: String)
case reworked(name: String, sessions: Int)
var id: String {
switch self {
case .labeled(let id, _, _): return id
case .reworked: return "reworked"
}
}
}
private struct WorkflowStatRow: View {
let model: WorkflowStripModel
private var stats: [WorkflowStat] {
var s: [WorkflowStat] = []
if let corrections = model.corrections {
s.append(.labeled(id: "corrections", label: "Corrections", value: corrections))
}
if let firstEdit = model.firstEdit {
s.append(.labeled(id: "firstEdit", label: "First edit", value: firstEdit))
}
if let name = model.reworkedName, let sessions = model.reworkedSessions {
s.append(.reworked(name: name, sessions: sessions))
}
return s
}
var body: some View {
HStack(spacing: 7) {
ForEach(Array(stats.enumerated()), id: \.element.id) { index, stat in
if index > 0 {
Text("·")
.font(.system(size: 11))
.foregroundStyle(.tertiary)
}
WorkflowStatCell(stat: stat)
}
Spacer(minLength: 0)
}
}
}
private struct WorkflowStatCell: View {
let stat: WorkflowStat
var body: some View {
HStack(spacing: 4) {
switch stat {
case .labeled(_, let label, let value):
Text(label)
.font(.system(size: 11))
.foregroundStyle(.tertiary)
Text(value)
.font(.codeMono(size: 12, weight: .medium))
.foregroundStyle(.primary)
.tracking(-0.2)
case .reworked(let name, let sessions):
Text(name)
.font(.system(size: 12, weight: .medium))
.foregroundStyle(.primary)
.lineLimit(1)
.truncationMode(.middle)
Text("×\(sessions)")
.font(.codeMono(size: 11, weight: .medium))
.foregroundStyle(.secondary)
.tracking(-0.2)
}
}
}
}

View file

@ -202,6 +202,113 @@ struct AppStoreRefreshRecoveryTests {
#expect(store.payload.combined == nil)
}
@Test("menubar badge shows the combined total under combined scope")
func menubarBadgeShowsCombinedTotal() {
let store = AppStore()
store.suppressRefreshesForTesting()
let period = store.menubarPeriod
// Local badge figure and a higher cross-device combined total, both for
// the badge's period.
store.setCachedPayloadForTesting(
menubarPayload(cost: 30),
scope: .local,
period: period,
provider: .all,
fetchedAt: Date()
)
store.setCachedPayloadForTesting(
menubarPayload(cost: 30, combined: combinedUsage(cost: 75)),
scope: .combined,
period: period,
provider: .all,
fetchedAt: Date()
)
// Local scope: no combined total, so the badge renders the local figure.
store.selectedScope = .local
#expect(store.menubarBadgeCombined == nil)
// Combined scope: the badge total is the cross-device aggregate ($75),
// not the local $30 this is the fix for the badge trailing the popover.
store.selectedScope = .combined
#expect(store.menubarBadgeCombined?.cost == 75)
}
@Test("badge reports a device shortfall when a paired peer is unreachable")
func menubarBadgeReportsDeviceShortfall() {
let store = AppStore()
store.suppressRefreshesForTesting()
let period = store.menubarPeriod
// Combined payload where only 1 of 2 paired devices reported this cycle
// (the peer is asleep/off-network), so the aggregate is degraded to local.
let degraded = CombinedUsage(
perDevice: [],
combined: CombinedUsageTotals(
cost: 30,
calls: 3,
sessions: 2,
inputTokens: 100,
outputTokens: 50,
cacheCreateTokens: 10,
cacheReadTokens: 20,
totalTokens: 180,
deviceCount: 2,
reachableCount: 1
)
)
store.setCachedPayloadForTesting(
menubarPayload(cost: 30, combined: degraded),
scope: .combined,
period: period,
provider: .all,
fetchedAt: Date()
)
store.selectedScope = .combined
let shortfall = store.menubarBadgeDeviceShortfall
#expect(shortfall?.reachable == 1)
#expect(shortfall?.total == 2)
}
@Test("badge reports no shortfall when every paired device reports")
func menubarBadgeNoShortfallWhenAllReachable() {
let store = AppStore()
store.suppressRefreshesForTesting()
let period = store.menubarPeriod
// combinedUsage() carries deviceCount == reachableCount == 1.
store.setCachedPayloadForTesting(
menubarPayload(cost: 30, combined: combinedUsage(cost: 30)),
scope: .combined,
period: period,
provider: .all,
fetchedAt: Date()
)
store.selectedScope = .combined
#expect(store.menubarBadgeDeviceShortfall == nil)
// Local scope never reports a shortfall.
store.selectedScope = .local
#expect(store.menubarBadgeDeviceShortfall == nil)
}
@Test("menubar badge falls back to local when no combined payload is cached")
func menubarBadgeFallsBackWhenCombinedMissing() {
let store = AppStore()
store.suppressRefreshesForTesting()
let period = store.menubarPeriod
store.setCachedPayloadForTesting(
menubarPayload(cost: 30),
scope: .local,
period: period,
provider: .all,
fetchedAt: Date()
)
// Combined scope selected but no combined payload cached yet (cold cache
// or an unreachable peer): the badge must fall back to the local figure.
store.selectedScope = .combined
#expect(store.menubarBadgeCombined == nil)
}
@Test("switching to combined resets selected provider to all")
func switchingToCombinedResetsSelectedProviderToAll() {
let store = AppStore()

View file

@ -0,0 +1,248 @@
import Foundation
import XCTest
@testable import CodeBurnMenubar
/// `plan_type` parsing and the credit-metered branch of the wham/usage decoder.
final class CodexPlanParsingTests: XCTestCase {
private func decode(_ json: String) throws -> CodexUsage {
try CodexSubscriptionService.decodeUsage(data: Data(json.utf8))
}
func testKnownTiersMapToDisplayNames() {
let expected: [String: String] = [
"guest": "Guest", "free": "Free", "go": "Go", "plus": "Plus", "pro": "Pro",
"prolite": "Pro Lite", "pro_lite": "Pro Lite", "pro-lite": "Pro Lite",
"free_workspace": "Free Workspace", "team": "Team", "business": "Business",
"education": "Education", "quorum": "Quorum", "k12": "K-12",
"enterprise": "Enterprise", "edu": "Edu",
]
for (raw, display) in expected {
XCTAssertEqual(CodexUsage.planType(from: raw).displayName, display, "plan_type: \(raw)")
}
}
func testTierMatchingIsCaseInsensitive() {
XCTAssertEqual(CodexUsage.planType(from: "pLuS"), .plus)
XCTAssertEqual(CodexUsage.planType(from: "ENTERPRISE"), .enterprise)
}
func testCreditBasedPricingCompositesNormalize() {
XCTAssertEqual(CodexUsage.planType(from: "enterprise_cbp_usage_based"), .enterprise)
XCTAssertEqual(CodexUsage.planType(from: "self_serve_business_usage_based"), .business)
XCTAssertEqual(CodexUsage.planType(from: "business_cbp"), .business)
}
func testHyphenSeparatedCompositesNormalizeToo() {
XCTAssertEqual(CodexUsage.planType(from: "enterprise-cbp-usage-based"), .enterprise)
XCTAssertEqual(CodexUsage.planType(from: "self-serve-business-usage-based"), .business)
XCTAssertEqual(CodexUsage.planType(from: "business-cbp"), .business)
}
func testUnknownTierNormalizesAndTitleCasesLikeTheDesktopDecoder() {
XCTAssertEqual(CodexUsage.planType(from: "some_future_tier_usage_based"),
.unknown("some_future_tier"))
XCTAssertEqual(CodexUsage.planType(from: "some_future_tier_usage_based").displayName,
"Some Future Tier")
XCTAssertEqual(CodexUsage.planType(from: nil), .unknown(""))
XCTAssertEqual(CodexUsage.planType(from: nil).displayName, "Subscription")
}
/// Captured from a live ChatGPT Enterprise workspace (identifiers replaced).
private let enterprisePayload = #"""
{
"plan_type": "business",
"rate_limit": null,
"code_review_rate_limit": null,
"additional_rate_limits": null,
"credits": {
"has_credits": false, "unlimited": false, "overage_limit_reached": false,
"balance": null, "approx_local_messages": null, "approx_cloud_messages": null
},
"spend_control": {
"reached": false,
"individual_limit": {
"source": "workspace_spend_controls",
"limit": "10000",
"used": "3028.9909675121307",
"remaining": "6971.009032487869",
"used_percent": 30,
"remaining_percent": 70,
"reset_after_seconds": 441896,
"reset_at": 1785542400
}
},
"rate_limit_reset_credits": {"available_count": 0, "applicable_available_count": 0}
}
"""#
func testEnterprisePayloadDecodesTheSpendControlLimit() throws {
let usage = try decode(enterprisePayload)
XCTAssertNil(usage.primary)
XCTAssertNil(usage.secondary)
XCTAssertTrue(usage.additionalLimits.isEmpty)
let credits = try XCTUnwrap(usage.creditLimit)
XCTAssertEqual(credits.limit, 10_000)
XCTAssertEqual(credits.used, 3028.9909675121307, accuracy: 0.0001)
XCTAssertEqual(credits.usedPercent, 30)
XCTAssertEqual(credits.resetsAt, Date(timeIntervalSince1970: 1_785_542_400))
XCTAssertFalse(credits.reached)
// July 2026, not the payload's `reset_after_seconds`.
XCTAssertEqual(try XCTUnwrap(credits.windowSeconds), 31 * 86_400)
XCTAssertEqual(usage.plan, .business)
XCTAssertNil(usage.creditsBalance)
XCTAssertFalse(usage.hasCredits)
XCTAssertFalse(usage.creditsUnlimited)
}
func testSpendControlIsReadAtEveryObservedPosition() throws {
let bodies = [
#"{"spend_control": {"individual_limit": {"limit": 10000, "used_percent": 25}}}"#,
#"{"spend_control": {"individualLimit": {"limit": 10000, "usedPercent": 25}}}"#,
#"{"individual_limit": {"limit": 10000, "used_percent": 25}}"#,
#"{"rate_limit": {"individual_limit": {"limit": 10000, "used_percent": 25}}}"#,
]
for body in bodies {
let credits = try XCTUnwrap(decode(body).creditLimit, body)
XCTAssertEqual(credits.usedPercent, 25, body)
XCTAssertEqual(credits.used, 2500, body)
}
}
func testPercentFallsBackThroughRemainingPercentThenRawRatio() throws {
let fromRemaining = try XCTUnwrap(
decode(#"{"spend_control": {"individual_limit": {"limit": 10000, "remaining_percent": 70}}}"#).creditLimit)
XCTAssertEqual(fromRemaining.usedPercent, 30, accuracy: 0.0001)
let fromRatio = try XCTUnwrap(
decode(#"{"spend_control": {"individual_limit": {"limit": 400, "used": 100}}}"#).creditLimit)
XCTAssertEqual(fromRatio.usedPercent, 25, accuracy: 0.0001)
}
func testUnusableSpendControlYieldsNoRow() throws {
let bodies = [
#"{"spend_control": {"individual_limit": {"limit": 0, "used": 5}}}"#,
#"{"spend_control": {"individual_limit": {"limit": null}}}"#,
#"{"spend_control": {"individual_limit": {"used_percent": 40}}}"#,
#"{"spend_control": {"individual_limit": null}}"#,
#"{"spend_control": null}"#,
"{}",
]
for body in bodies {
XCTAssertNil(try decode(body).creditLimit, body)
}
}
func testAllowanceWithoutAnyUsageSignalYieldsNoRow() throws {
let body = #"{"spend_control": {"individual_limit": {"limit": 10000, "reset_at": 1785542400}}}"#
XCTAssertNil(try decode(body).creditLimit)
}
func testBlankNumericStringIsAbsentNotZero() throws {
let credits = try XCTUnwrap(decode(#"""
{"spend_control": {"individual_limit": {"limit": "10000", "used": " ", "used_percent": 30}}}
"""#).creditLimit)
XCTAssertEqual(credits.used, 3000, accuracy: 0.001)
XCTAssertNil(try decode(#"{"spend_control": {"individual_limit": {"limit": ""}}}"#).creditLimit)
}
func testOverageKeepsCountsTruthfulWhileClampingPercent() throws {
let credits = try XCTUnwrap(decode(#"""
{"spend_control": {"individual_limit": {"limit": 10000, "used": 12000, "used_percent": 120}}}
"""#).creditLimit)
XCTAssertEqual(credits.used, 12000, accuracy: 0.001)
XCTAssertEqual(credits.usedPercent, 100)
}
func testMonthWindowIsTimezoneIndependent() throws {
// 2026-03-01Z reads as 28 days in UTC but 31 through a local calendar.
let body = #"{"spend_control": {"individual_limit": {"limit": 100, "used_percent": 10, "reset_at": 1772323200}}}"#
XCTAssertEqual(try XCTUnwrap(decode(body).creditLimit?.windowSeconds), 28 * 86_400)
}
func testOutOfRangeNumbersDoNotTrap() throws {
// `Int(_:)` on 1e100 traps, and a trap is not a catchable error.
for body in [
#"{"spend_control": {"individual_limit": {"limit": 100, "used_percent": 10, "reset_at": 1e100}}}"#,
#"{"spend_control": {"individual_limit": {"limit": "Infinity", "used_percent": 10}}}"#,
#"{"spend_control": {"individual_limit": {"limit": 100, "used_percent": "NaN"}}}"#,
] {
_ = try? decode(body)
}
let huge = #"{"spend_control": {"individual_limit": {"limit": 100, "used_percent": 10, "reset_at": "1e100"}}}"#
XCTAssertNil(try XCTUnwrap(decode(huge).creditLimit).resetsAt)
}
func testPercentOnlyOverageKeepsTheImpliedCount() throws {
let body = #"{"spend_control": {"individual_limit": {"limit": 10000, "used_percent": 120}}}"#
let credits = try XCTUnwrap(decode(body).creditLimit)
XCTAssertEqual(credits.used, 12000, accuracy: 0.001)
XCTAssertEqual(credits.usedPercent, 100)
XCTAssertEqual(credits.displayLabel, "Monthly usage limit · 12,000 / 10,000 credits")
}
func testOneMalformedAdditionalLimitDoesNotDiscardTheRest() throws {
let body = #"""
{"additional_rate_limits": [
null,
{"limit_name": "Spark", "rate_limit": {"primary_window": {"used_percent": 40, "limit_window_seconds": 18000}}}
]}
"""#
XCTAssertEqual(try decode(body).additionalLimits.map(\.name), ["Spark"])
}
func testReachedSpendControlIsCarriedThrough() throws {
let credits = try XCTUnwrap(decode(#"""
{"spend_control": {"reached": true, "individual_limit": {"limit": 10000, "used_percent": 100}}}
"""#).creditLimit)
XCTAssertTrue(credits.reached)
XCTAssertEqual(credits.usedPercent, 100)
}
func testCreditFlagsAndMixedNumberEncodings() throws {
let usage = try decode(#"""
{"credits": {"has_credits": true, "unlimited": true, "balance": "3410.40"}}
"""#)
XCTAssertTrue(usage.hasCredits)
XCTAssertTrue(usage.creditsUnlimited)
XCTAssertEqual(try XCTUnwrap(usage.creditsBalance), 3410.40, accuracy: 0.0001)
}
func testRateWindowsStillDecodeAlongsideASpendControl() throws {
let usage = try decode(#"""
{
"plan_type": "plus",
"rate_limit": {
"primary_window": {"used_percent": 20, "reset_at": 1800000000, "limit_window_seconds": 18000}
},
"spend_control": {"individual_limit": {"limit": 10000, "used_percent": 30}}
}
"""#)
XCTAssertEqual(usage.primary?.usedPercent, 20)
XCTAssertEqual(usage.primary?.windowLabel, "5-hour")
XCTAssertEqual(usage.creditLimit?.usedPercent, 30)
}
func testOnlyAZeroCountSkipsTheCompanionRequest() {
let zero = #"{"rate_limit_reset_credits": {"available_count": 0}}"#
let some = #"{"rate_limit_reset_credits": {"available_count": 3}}"#
XCTAssertEqual(CodexSubscriptionService.inlineResetCreditsShortcut(data: Data(zero.utf8))?.availableCount, 0)
XCTAssertNil(CodexSubscriptionService.inlineResetCreditsShortcut(data: Data(some.utf8)))
XCTAssertNil(CodexSubscriptionService.inlineResetCreditsShortcut(data: Data("{}".utf8)))
XCTAssertEqual(CodexSubscriptionService.inlineResetCredits(data: Data(some.utf8))?.availableCount, 3)
}
func testInlineResetCreditsParseFromTheUsagePayload() {
let inline = CodexSubscriptionService.inlineResetCredits(data: Data(enterprisePayload.utf8))
XCTAssertEqual(inline?.availableCount, 0)
XCTAssertNil(inline?.nextExpiresAt)
}
func testInlineResetCreditsAbsentSignalsFallback() {
XCTAssertNil(CodexSubscriptionService.inlineResetCredits(data: Data("{}".utf8)))
XCTAssertNil(CodexSubscriptionService.inlineResetCredits(data: Data("not json".utf8)))
XCTAssertNil(CodexSubscriptionService.inlineResetCredits(
data: Data(#"{"rate_limit_reset_credits": {}}"#.utf8)))
}
}

View file

@ -0,0 +1,85 @@
import Foundation
import Testing
@testable import CodeBurnMenubar
private func usage(
balance: Double? = nil,
hasCredits: Bool = false,
unlimited: Bool = false,
creditLimit: CodexUsage.CreditLimit? = nil
) -> CodexUsage {
CodexUsage(
plan: .business,
primary: nil,
secondary: nil,
additionalLimits: [],
creditsBalance: balance,
hasCredits: hasCredits,
creditsUnlimited: unlimited,
creditLimit: creditLimit,
resetCredits: nil,
fetchedAt: Date()
)
}
private func limit(used: Double, of total: Double, reached: Bool = false) -> CodexUsage.CreditLimit {
CodexUsage.CreditLimit(
used: used,
limit: total,
usedPercent: used / total * 100,
resetsAt: Date(timeIntervalSince1970: 1_785_542_400),
windowSeconds: 31 * 86_400,
reached: reached
)
}
@MainActor
private func store(_ usage: CodexUsage) -> AppStore {
let store = AppStore()
store.codexUsage = usage
// Pinned: the default depends on whether this machine has Codex connected.
store.codexLoadState = .loaded
return store
}
@MainActor
struct CodexQuotaSummaryTests {
@Test("credit-settled balances group without a currency symbol")
func creditSettledBalanceIsGroupedAndUnprefixed() {
let store = store(usage(balance: 3410.4, hasCredits: true))
#expect(store.quotaSummary(for: .codex)?.footerLines == ["Credits remaining · 3,410"])
}
@Test("dollar balances keep the currency formatting")
func dollarBalanceKeepsCurrencyFormatting() {
let store = store(usage(balance: 3410.4, hasCredits: false))
#expect(store.quotaSummary(for: .codex)?.footerLines == ["Credits remaining · $3,410.40"])
}
@Test("an exact-half credit balance rounds up, matching the desktop decoder")
func creditBalanceRoundsHalfUp() {
let store = store(usage(balance: 3410.5, hasCredits: true))
#expect(store.quotaSummary(for: .codex)?.footerLines == ["Credits remaining · 3,411"])
}
@Test("an uncapped credit account says so instead of showing nothing")
func uncappedAccountSaysUnlimited() {
let store = store(usage(hasCredits: true, unlimited: true))
#expect(store.quotaSummary(for: .codex)?.footerLines == ["Credits · Unlimited"])
}
@Test("the allowance row drives the chip with the short label")
func allowanceRowUsesTheShortLabel() {
let store = store(usage(creditLimit: limit(used: 3033, of: 10_000)))
let summary = store.quotaSummary(for: .codex)
#expect(summary?.primary?.label == "Monthly usage limit")
#expect(summary?.primary?.percent == 0.3033)
#expect(summary?.footerLines.isEmpty == true)
}
@Test("a spent-out allowance is called out on the chip")
func reachedAllowanceIsCalledOut() {
let store = store(usage(creditLimit: limit(used: 10_000, of: 10_000, reached: true)))
#expect(store.quotaSummary(for: .codex)?.primary?.label == "Monthly usage limit · limit reached")
}
}

View file

@ -0,0 +1,68 @@
import Foundation
import Testing
@testable import CodeBurnMenubar
/// Kimi Code tokens live ~15 min and only the CLI renews them, so
/// `.terminalFailure` is the dominant steady state between CLI uses. These
/// tests pin the display decision: a terminal login with a snapshot on hand
/// must keep showing the bars (with a quiet idle caption), and only the
/// no-data case falls through to the reconnect screen.
@Suite("Kimi quota presentation")
struct KimiQuotaPresentationTests {
typealias Presentation = KimiQuotaPresentation
@Test("terminal failure with a snapshot keeps the usage bars, flagged idle")
func terminalWithUsageShowsIdleUsage() {
let content = Presentation.planContent(loadState: .terminalFailure(reason: "expired"), hasUsage: true)
#expect(content == .usage(idle: true))
}
@Test("terminal failure with no snapshot falls through to reconnect")
func terminalWithoutUsageShowsReconnect() {
let content = Presentation.planContent(loadState: .terminalFailure(reason: "expired"), hasUsage: false)
#expect(content == .reconnect(reason: "expired"))
}
@Test("loaded with a snapshot shows usage without the idle caption")
func loadedShowsPlainUsage() {
#expect(Presentation.planContent(loadState: .loaded, hasUsage: true) == .usage(idle: false))
}
@Test("transient failure keeps the last snapshot, else shows the retry screen")
func transientFailureFallsBackToUsage() {
#expect(Presentation.planContent(loadState: .transientFailure(retryAt: nil), hasUsage: true) == .usage(idle: false))
#expect(Presentation.planContent(loadState: .transientFailure(retryAt: nil), hasUsage: false) == .transientFailed)
}
@Test("credential-absent states route to the connect prompt")
func credentialStatesRouteToNoCredentials() {
#expect(Presentation.planContent(loadState: .notBootstrapped, hasUsage: false) == .noCredentials)
#expect(Presentation.planContent(loadState: .noCredentials, hasUsage: false) == .noCredentials)
}
@Test("dormant and bootstrapping render the loading state")
func dormantAndBootstrappingLoad() {
#expect(Presentation.planContent(loadState: .dormant, hasUsage: false) == .loading)
#expect(Presentation.planContent(loadState: .bootstrapping, hasUsage: false) == .loading)
}
@Test("loading with a snapshot shows usage; without one, the loading state")
func loadingPrefersExistingSnapshot() {
#expect(Presentation.planContent(loadState: .loading, hasUsage: true) == .usage(idle: false))
#expect(Presentation.planContent(loadState: .loading, hasUsage: false) == .loading)
}
@Test("a fresh snapshot is not stamped stale")
func freshSnapshotIsNotStale() {
let now = Date()
let fetchedAt = now.addingTimeInterval(-60) // 1 min old
#expect(Presentation.isStale(fetchedAt: fetchedAt, now: now) == false)
}
@Test("a snapshot older than the threshold is stamped stale")
func oldSnapshotIsStale() {
let now = Date()
let fetchedAt = now.addingTimeInterval(-11 * 60) // 11 min old
#expect(Presentation.isStale(fetchedAt: fetchedAt, now: now) == true)
}
}

View file

@ -0,0 +1,114 @@
import XCTest
@testable import CodeBurnMenubar
/// Fixture-driven decode tests for the Kimi Code /coding/v1/usages response.
/// The API has shipped numbers as both JSON numbers and strings, and the
/// reset timestamp under several key spellings (resetTime / reset_at / ...),
/// so the parser must tolerate all of them.
final class KimiUsageParsingTests: XCTestCase {
func testParsesNumericShapeWithResetTime() throws {
let json = """
{
"usage": {"limit": 100, "used": 40, "remaining": 60, "resetTime": "2026-07-30T12:00:00Z"},
"limits": [
{"window": {"duration": 5, "timeUnit": "hour"},
"detail": {"limit": 20, "used": 10, "remaining": 10, "resetTime": "2026-07-23T21:00:00Z"}}
]
}
""".data(using: .utf8)!
let usage = try KimiSubscriptionService.parseUsage(data: json)
XCTAssertEqual(usage.primary?.limit, 100)
XCTAssertEqual(usage.primary?.used, 40)
XCTAssertEqual(usage.primary?.usedPercent ?? -1, 40, accuracy: 0.001)
XCTAssertEqual(usage.primary?.remaining, 60)
XCTAssertNotNil(usage.primary?.resetsAt)
XCTAssertEqual(usage.details.count, 1)
XCTAssertEqual(usage.details.first?.label, "5-hour")
XCTAssertEqual(usage.details.first?.usedPercent ?? -1, 50, accuracy: 0.001)
}
func testParsesStringNumbersAndSnakeCaseReset() throws {
let json = """
{
"usage": {"limit": "500", "used": "123", "remaining": "377", "reset_at": "2026-07-30T12:00:00.000Z"},
"limits": []
}
""".data(using: .utf8)!
let usage = try KimiSubscriptionService.parseUsage(data: json)
XCTAssertEqual(usage.primary?.limit, 500)
XCTAssertEqual(usage.primary?.used, 123)
XCTAssertNotNil(usage.primary?.resetsAt)
}
func testWeeklyWindowLabel() throws {
let json = """
{
"limits": [
{"window": {"duration": 7, "timeUnit": "day"},
"detail": {"limit": 1000, "used": 250}}
]
}
""".data(using: .utf8)!
let usage = try KimiSubscriptionService.parseUsage(data: json)
XCTAssertNil(usage.primary)
XCTAssertEqual(usage.details.first?.label, "Weekly")
}
func testEpochResetTime() throws {
let json = """
{"usage": {"limit": 10, "used": 5, "resetTime": "1784900000"}}
""".data(using: .utf8)!
let usage = try KimiSubscriptionService.parseUsage(data: json)
XCTAssertEqual(usage.primary?.resetsAt, Date(timeIntervalSince1970: 1_784_900_000))
}
func testNumericEpochResetTime() throws {
// A JSON number (not string) must not fail the whole decode.
let json = """
{"usage": {"limit": 10, "used": 5, "resetTime": 1784900000}}
""".data(using: .utf8)!
let usage = try KimiSubscriptionService.parseUsage(data: json)
XCTAssertEqual(usage.primary?.resetsAt, Date(timeIntervalSince1970: 1_784_900_000))
}
func testLiveResponseShape() {
// Captured from GET https://api.kimi.com/coding/v1/usages (2026-07-23).
let json = """
{
"user": {"userId": "x", "region": "REGION_OVERSEA",
"membership": {"level": "LEVEL_INTERMEDIATE"}},
"usage": {"limit": "100", "used": "5", "remaining": "95",
"resetTime": "2026-07-30T13:27:17.211180Z"},
"limits": [
{"window": {"duration": 300, "timeUnit": "TIME_UNIT_MINUTE"},
"detail": {"limit": "100", "remaining": "100",
"resetTime": "2026-07-23T23:27:17.211180Z"}}
],
"parallel": {"limit": "20"}
}
""".data(using: .utf8)!
let usage = try! KimiSubscriptionService.parseUsage(data: json)
XCTAssertEqual(usage.plan, "Intermediate")
XCTAssertEqual(usage.parallelLimit, 20)
XCTAssertEqual(usage.primary?.label, "Weekly")
XCTAssertEqual(usage.primary?.usedPercent ?? -1, 5, accuracy: 0.001)
// 300 minutes rolls up to a 5-hour label; used derives from remaining.
XCTAssertEqual(usage.details.count, 1)
XCTAssertEqual(usage.details.first?.label, "5-hour")
XCTAssertEqual(usage.details.first?.usedPercent ?? -1, 0, accuracy: 0.001)
XCTAssertNotNil(usage.details.first?.resetsAt)
}
func testEmptyEnvelopeThrows() {
let json = "{}".data(using: .utf8)!
XCTAssertThrowsError(try KimiSubscriptionService.parseUsage(data: json))
}
func testZeroLimitWindowDropped() throws {
let json = """
{"usage": {"limit": 0, "used": 0}, "limits": []}
""".data(using: .utf8)!
XCTAssertThrowsError(try KimiSubscriptionService.parseUsage(data: json))
}
}

View file

@ -0,0 +1,87 @@
import AppKit
import XCTest
@testable import CodeBurnMenubar
/// Locks the right-click menu policy that fixed flash + scroll-jump (#802).
/// Does not drive a real NSStatusItem that needs manual/AppKit integration.
final class StatusItemContextMenuPolicyTests: XCTestCase {
func testPresentEventMaskIsRightMouseUpNotDown() {
// Presenting on mouse-down lets the matching up dismiss the menu (flash).
XCTAssertEqual(
StatusItemContextMenuPolicy.presentEventMask,
NSEvent.EventTypeMask.rightMouseUp
)
XCTAssertNotEqual(
StatusItemContextMenuPolicy.presentEventMask,
NSEvent.EventTypeMask.rightMouseDown
)
// Mask must include up and must not include down (single-bit masks here).
XCTAssertTrue(StatusItemContextMenuPolicy.presentEventMask.contains(.rightMouseUp))
XCTAssertFalse(StatusItemContextMenuPolicy.presentEventMask.contains(.rightMouseDown))
}
func testPresentationUsesStatusItemMenuNotManualPopUp() {
// Manual popUp tracks against a point while the cursor sits on the status
// item above the menu scroll chevron / Today-row jump on mouse move.
XCTAssertEqual(
StatusItemContextMenuPolicy.presentation,
.statusItemMenu
)
XCTAssertNotEqual(
StatusItemContextMenuPolicy.presentation,
.manualPopUp
)
}
func testDebounceAcceptsFirstPresent() {
var last = Date.distantPast
let now = Date(timeIntervalSince1970: 1_000)
XCTAssertTrue(
StatusItemContextMenuPolicy.acceptPresent(now: now, lastPresentedAt: &last)
)
XCTAssertEqual(last, now)
}
func testDebounceRejectsWithinWindow() {
let t0 = Date(timeIntervalSince1970: 1_000)
var last = t0
// Just inside the 0.3s window
let t1 = t0.addingTimeInterval(0.299)
XCTAssertFalse(
StatusItemContextMenuPolicy.acceptPresent(now: t1, lastPresentedAt: &last)
)
XCTAssertEqual(last, t0, "reject must not advance lastPresentedAt")
}
func testDebounceAcceptsAfterWindow() {
let t0 = Date(timeIntervalSince1970: 1_000)
var last = t0
let t1 = t0.addingTimeInterval(StatusItemContextMenuPolicy.presentDebounceSeconds + 0.001)
XCTAssertTrue(
StatusItemContextMenuPolicy.acceptPresent(now: t1, lastPresentedAt: &last)
)
XCTAssertEqual(last, t1)
}
func testDebounceBoundaryIsStrictlyGreaterThan() {
// Gate uses `>` not `>=`: exactly debounce seconds later is still rejected.
let t0 = Date(timeIntervalSince1970: 1_000)
var last = t0
let exact = t0.addingTimeInterval(StatusItemContextMenuPolicy.presentDebounceSeconds)
XCTAssertFalse(
StatusItemContextMenuPolicy.acceptPresent(now: exact, lastPresentedAt: &last)
)
XCTAssertEqual(last, t0)
}
func testCustomDebounceOverride() {
var last = Date(timeIntervalSince1970: 0)
let now = Date(timeIntervalSince1970: 0.5)
XCTAssertFalse(
StatusItemContextMenuPolicy.acceptPresent(now: now, lastPresentedAt: &last, debounce: 1.0)
)
XCTAssertTrue(
StatusItemContextMenuPolicy.acceptPresent(now: now, lastPresentedAt: &last, debounce: 0.4)
)
}
}

View file

@ -0,0 +1,293 @@
import Foundation
import Testing
@testable import CodeBurnMenubar
@Suite("Preferred terminal selection and script generation")
struct TerminalLauncherTests {
// MARK: - Enum -> app path mapping
@Test("Terminal.app keeps both stock install locations")
func terminalKeepsStockPaths() {
#expect(PreferredTerminal.terminal.appPaths == [
"/System/Applications/Utilities/Terminal.app",
"/Applications/Utilities/Terminal.app",
])
}
@Test("iTerm2 probes the system and per-user Applications folders")
func iTermProbesBothApplicationsFolders() {
let paths = PreferredTerminal.iTerm2.appPaths
let home = FileManager.default.homeDirectoryForCurrentUser.path
#expect(paths == ["/Applications/iTerm.app", "\(home)/Applications/iTerm.app"])
}
@Test("every case maps to absolute .app bundle paths")
func everyCaseMapsToAbsoluteBundlePaths() {
for terminal in PreferredTerminal.allCases {
#expect(!terminal.appPaths.isEmpty)
for path in terminal.appPaths {
#expect(path.hasPrefix("/"))
#expect(path.hasSuffix(".app"))
}
}
}
// MARK: - Script generation per terminal
@Test("Terminal.app uses the `do script` dialect")
func terminalUsesDoScript() {
let script = PreferredTerminal.terminal.script(command: "codeburn report")
#expect(script.contains("tell application \"Terminal\""))
#expect(script.contains("do script \"codeburn report\""))
#expect(script.contains("activate"))
// iTerm2 verbs must not leak into the Terminal.app dialect.
#expect(!script.contains("write text"))
#expect(!script.contains("create window with default profile"))
}
@Test("iTerm2 uses the `create window` + `write text` dialect")
func iTermUsesWriteText() {
let script = PreferredTerminal.iTerm2.script(command: "codeburn report")
#expect(script.contains("tell application \"iTerm\""))
#expect(script.contains("create window with default profile"))
#expect(script.contains("write text \"codeburn report\""))
// `do script` is a Terminal.app-only verb; sending it to iTerm2 would fail silently.
#expect(!script.contains("do script"))
}
@Test("iTerm2 is addressed as `iTerm`, the bundle name, so it compiles while the app is quit")
func iTermIsAddressedByBundleName() {
// Regression guard. `tell application "iTerm2"` only compiles while iTerm2 already
// happens to be running; with the app quit AppleScript resolves the name through
// LaunchServices by bundle file name (iTerm.app) and otherwise fails with -2741,
// which made "Full Report" do nothing at all.
let script = PreferredTerminal.iTerm2.script(command: "codeburn report")
#expect(!script.contains("tell application \"iTerm2\""))
}
@Test("each case targets exactly one hardcoded application name")
func eachCaseTargetsOneHardcodedApplication() {
let names: [PreferredTerminal: String] = [.terminal: "Terminal", .iTerm2: "iTerm"]
for terminal in PreferredTerminal.allCases {
let script = terminal.script(command: "codeburn report")
let tells = script.components(separatedBy: "tell application ").count - 1
#expect(tells == 1)
#expect(script.contains("tell application \"\(names[terminal]!)\""))
}
}
@Test("the command is the only value interpolated into the script")
func commandIsTheOnlyInterpolatedValue() {
// Swapping the command must change nothing but the command occurrence, proving the
// app name and verbs are compile-time literals rather than stored strings.
for terminal in PreferredTerminal.allCases {
let a = terminal.script(command: "codeburn report")
let b = terminal.script(command: "codeburn optimize")
#expect(a != b)
#expect(a.replacingOccurrences(of: "codeburn report", with: "codeburn optimize") == b)
}
}
// MARK: - Fallback selection when an app is absent
@Test("the configured terminal is used when it is installed")
func configuredTerminalWins() {
let resolved = TerminalLauncher.resolvedTerminal(preference: .iTerm2, isInstalled: { _ in true })
#expect(resolved == .iTerm2)
}
@Test("a missing configured terminal falls back to Terminal.app")
func missingConfiguredTerminalFallsBackToTerminal() {
let resolved = TerminalLauncher.resolvedTerminal(
preference: .iTerm2,
isInstalled: { $0 == .terminal }
)
#expect(resolved == .terminal)
}
@Test("nil is returned when nothing scriptable exists so the caller goes headless")
func nothingInstalledResolvesToNil() {
#expect(TerminalLauncher.resolvedTerminal(preference: .iTerm2, isInstalled: { _ in false }) == nil)
#expect(TerminalLauncher.resolvedTerminal(preference: .terminal, isInstalled: { _ in false }) == nil)
}
@Test("Terminal.app preference never resolves to another terminal")
func terminalPreferenceNeverResolvesElsewhere() {
// iTerm2 installed but Terminal.app chosen and absent -> headless, not a surprise app.
let resolved = TerminalLauncher.resolvedTerminal(
preference: .terminal,
isInstalled: { $0 == .iTerm2 }
)
#expect(resolved == nil)
}
// MARK: - Chain construction
@Test("the chain is the configured terminal then Terminal.app as backstop")
func chainPutsPreferenceFirstThenTerminal() {
let chain = TerminalLauncher.terminalChain(preference: .iTerm2, isInstalled: { _ in true })
#expect(chain == [.iTerm2, .terminal])
}
@Test("Terminal.app is never listed twice when it is also the preference")
func chainDoesNotDuplicateTerminal() {
let chain = TerminalLauncher.terminalChain(preference: .terminal, isInstalled: { _ in true })
#expect(chain == [.terminal])
}
@Test("an uninstalled preference drops out of the chain entirely")
func chainSkipsUninstalledPreference() {
let chain = TerminalLauncher.terminalChain(preference: .iTerm2, isInstalled: { $0 == .terminal })
#expect(chain == [.terminal])
}
@Test("no installed terminal yields an empty chain so the caller goes headless")
func chainIsEmptyWhenNothingInstalled() {
#expect(TerminalLauncher.terminalChain(preference: .iTerm2, isInstalled: { _ in false }).isEmpty)
}
// MARK: - Runtime fallback when a terminal is installed but osascript fails
@Test("a terminal that fails at runtime falls through to the next candidate")
func runtimeFailureFallsBackToNextTerminal() {
// The real trigger: iTerm2 is installed, so it is picked, but osascript exits non-zero
// (terminology it cannot load, a denied Automation prompt, a broken bundle). Before
// this the launcher fired and forgot, so the user got no window and no error at all.
var attempted: [PreferredTerminal] = []
let used = TerminalLauncher.runFirstWorking(
chain: [.iTerm2, .terminal],
command: "codeburn report",
attempt: { terminal, _ in
attempted.append(terminal)
return terminal == .terminal
}
)
#expect(used == .terminal)
#expect(attempted == [.iTerm2, .terminal])
}
@Test("a working first terminal short-circuits the rest of the chain")
func successfulFirstTerminalStopsTheChain() {
var attempted: [PreferredTerminal] = []
let used = TerminalLauncher.runFirstWorking(
chain: [.iTerm2, .terminal],
command: "codeburn report",
attempt: { terminal, _ in
attempted.append(terminal)
return true
}
)
#expect(used == .iTerm2)
#expect(attempted == [.iTerm2])
}
@Test("every candidate failing returns nil so the caller can go headless")
func exhaustedChainReturnsNil() {
var attempted: [PreferredTerminal] = []
let used = TerminalLauncher.runFirstWorking(
chain: [.iTerm2, .terminal],
command: "codeburn report",
attempt: { terminal, _ in
attempted.append(terminal)
return false
}
)
#expect(used == nil)
#expect(attempted == [.iTerm2, .terminal])
}
@Test("an empty chain attempts nothing and reports failure immediately")
func emptyChainAttemptsNothing() {
var attempts = 0
let used = TerminalLauncher.runFirstWorking(
chain: [],
command: "codeburn report",
attempt: { _, _ in
attempts += 1
return true
}
)
#expect(used == nil)
#expect(attempts == 0)
}
@Test("the command reaches each attempted terminal unchanged")
func commandIsForwardedToEveryAttempt() {
var seen: [String] = []
_ = TerminalLauncher.runFirstWorking(
chain: [.iTerm2, .terminal],
command: "codeburn optimize",
attempt: { _, command in
seen.append(command)
return false
}
)
#expect(seen == ["codeburn optimize", "codeburn optimize"])
}
// MARK: - argv safety validation
@Test("safe argv joins into a command")
func safeArgvJoins() {
#expect(TerminalLauncher.safeCommand(argv: ["codeburn", "report"]) == "codeburn report")
#expect(
TerminalLauncher.safeCommand(argv: ["/opt/homebrew/bin/codeburn", "optimize"])
== "/opt/homebrew/bin/codeburn optimize"
)
}
@Test("shell metacharacters are still rejected before reaching AppleScript")
func unsafeArgvIsRejected() {
let hostile = [
"codeburn; rm -rf ~",
"codeburn && curl evil.sh",
"codeburn | tee /tmp/x",
"$(whoami)",
"`whoami`",
"codeburn \"quoted\"",
"codeburn'q",
"codeburn\nreport",
"codeburn > /tmp/x",
]
for token in hostile {
#expect(!CodeburnCLI.isSafe(token), "expected \(token) to be rejected")
#expect(TerminalLauncher.safeCommand(argv: ["codeburn", token]) == nil)
}
}
@Test("a single unsafe token poisons the whole argv")
func oneUnsafeTokenRejectsEverything() {
#expect(TerminalLauncher.safeCommand(argv: ["codeburn", "report", "; id"]) == nil)
#expect(TerminalLauncher.safeCommand(argv: [""]) == nil)
}
// MARK: - Persistence
@Test("preference defaults to Terminal.app when unset, preserving pre-#877 behaviour")
func defaultsToTerminalWhenUnset() {
let suiteName = "CodeBurnMenubarTests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName)!
defer { defaults.removePersistentDomain(forName: suiteName) }
#expect(PreferredTerminal.saved(defaults: defaults) == .terminal)
#expect(PreferredTerminal.default == .terminal)
}
@Test("preference round-trips and unknown values collapse to the default")
func preferenceRoundTripsAndRejectsGarbage() {
let suiteName = "CodeBurnMenubarTests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName)!
defer { defaults.removePersistentDomain(forName: suiteName) }
PreferredTerminal.iTerm2.persist(defaults: defaults)
#expect(defaults.string(forKey: PreferredTerminal.defaultsKey) == "iterm2")
#expect(PreferredTerminal.saved(defaults: defaults) == .iTerm2)
PreferredTerminal.terminal.persist(defaults: defaults)
#expect(PreferredTerminal.saved(defaults: defaults) == .terminal)
// A hand-written defaults value must never become a `tell application` target.
defaults.set("Terminal\" \nto do shell script \"id", forKey: PreferredTerminal.defaultsKey)
#expect(PreferredTerminal.saved(defaults: defaults) == .terminal)
}
}

View file

@ -59,6 +59,44 @@ struct UpdateCheckerTests {
}
}
@Suite("Update failure presentation")
@MainActor
struct UpdateFailurePresentationTests {
@Test("identifies an automatic update check failure")
func updateCheckFailure() {
let checker = UpdateChecker()
checker.updateFailureStage = .check
checker.updateError = "GitHub returned HTTP 403."
#expect(checker.updateBadgeLabel == "Update Check Failed")
#expect(checker.updateHelpText.contains("could not check GitHub for updates"))
#expect(checker.updateHelpText.contains("GitHub returned HTTP 403."))
#expect(checker.updateHelpText.contains("retry the update check"))
}
@Test("distinguishes CLI update failures")
func cliUpdateFailure() {
let checker = UpdateChecker()
checker.updateFailureStage = .cliUpdate
checker.updateError = "npm exited with status 1."
#expect(checker.updateBadgeLabel == "CLI Update Failed")
#expect(checker.updateHelpText.contains("could not update the CLI"))
#expect(checker.updateHelpText.contains("npm exited with status 1."))
}
@Test("distinguishes menubar update failures")
func menubarUpdateFailure() {
let checker = UpdateChecker()
checker.updateFailureStage = .menubarUpdate
checker.updateError = "Checksum mismatch."
#expect(checker.updateBadgeLabel == "Menubar Update Failed")
#expect(checker.updateHelpText.contains("could not update the menubar app"))
#expect(checker.updateHelpText.contains("Checksum mismatch."))
}
}
// MARK: - one-click full update: package-manager resolution

View file

@ -0,0 +1,235 @@
import Foundation
import Testing
@testable import CodeBurnMenubar
@Suite("WorkflowStrip -- duration formatter")
struct WorkflowStripDurationTests {
@Test("seconds under a minute")
func seconds() {
#expect(WorkflowStripModel.formatDuration(ms: 8_000) == "8s")
#expect(WorkflowStripModel.formatDuration(ms: 45_000) == "45s")
// Rounds to nearest second (away from zero on .5).
#expect(WorkflowStripModel.formatDuration(ms: 1_500) == "2s")
}
@Test("whole minutes below an hour")
func minutes() {
#expect(WorkflowStripModel.formatDuration(ms: 60_000) == "1m")
#expect(WorkflowStripModel.formatDuration(ms: 360_000) == "6m")
// 1m30s rounds up to 2m.
#expect(WorkflowStripModel.formatDuration(ms: 90_000) == "2m")
}
@Test("hours and minutes at and above an hour")
func hours() {
#expect(WorkflowStripModel.formatDuration(ms: 3_600_000) == "1h 0m")
#expect(WorkflowStripModel.formatDuration(ms: 3_900_000) == "1h 5m")
#expect(WorkflowStripModel.formatDuration(ms: 7_500_000) == "2h 5m")
}
@Test("coaching-note short formatter matches the CLI (seconds/minutes only)")
func shortFormatter() {
#expect(WorkflowStripModel.formatDurationShort(ms: 45_000) == "45s")
#expect(WorkflowStripModel.formatDurationShort(ms: 300_000) == "5m")
// Above an hour the note still reads minutes, unlike the stat (which is "1h 5m").
#expect(WorkflowStripModel.formatDurationShort(ms: 3_900_000) == "65m")
}
}
@Suite("WorkflowStrip -- coaching note selection")
struct WorkflowStripNoteTests {
private func file(_ path: String, sessions: Int, edits: Int) -> ReworkedFileEntry {
ReworkedFileEntry(path: path, sessions: sessions, edits: edits)
}
@Test("corrections note fires at threshold and wins over the others")
func correctionsWins() {
let note = WorkflowStripModel.coachingNote(
corrections: 5,
correctionRate: 0.2,
topFile: file("sdk.py", sessions: 15, edits: 42),
medianTimeToFirstEditMs: 600_000
)
#expect(note == "You corrected the assistant on 20% of prompts (5 times). State the requirements in the first message to cut the back and forth.")
}
@Test("corrections note gated by rate and count")
func correctionsGating() {
// Rate below 0.15 -> falls through to churn.
#expect(WorkflowStripModel.coachingNote(corrections: 9, correctionRate: 0.1, topFile: file("a.py", sessions: 4, edits: 8), medianTimeToFirstEditMs: nil)
== "a.py was reworked across 4 sessions (8 edits). A focused pass on it may cost less than the repeated churn.")
// Count below 3 -> falls through to churn.
#expect(WorkflowStripModel.coachingNote(corrections: 2, correctionRate: 0.9, topFile: file("a.py", sessions: 4, edits: 8), medianTimeToFirstEditMs: nil)
== "a.py was reworked across 4 sessions (8 edits). A focused pass on it may cost less than the repeated churn.")
}
@Test("churn note fires and uses the basename")
func churn() {
let note = WorkflowStripModel.coachingNote(
corrections: 0,
correctionRate: nil,
topFile: file("src/lib/sdk.py", sessions: 15, edits: 42),
medianTimeToFirstEditMs: nil
)
#expect(note == "sdk.py was reworked across 15 sessions (42 edits). A focused pass on it may cost less than the repeated churn.")
}
@Test("ttfe note fires at 5 minutes when nothing stronger does")
func ttfe() {
let note = WorkflowStripModel.coachingNote(
corrections: 0,
correctionRate: nil,
topFile: file("a.py", sessions: 2, edits: 3),
medianTimeToFirstEditMs: 300_000
)
#expect(note == "Median time to first edit is 5m. Point the assistant at the target file to cut the exploration before it starts editing.")
}
@Test("no note when nothing crosses a threshold")
func none() {
#expect(WorkflowStripModel.coachingNote(corrections: 1, correctionRate: 0.05, topFile: file("a.py", sessions: 1, edits: 2), medianTimeToFirstEditMs: 120_000) == nil)
#expect(WorkflowStripModel.coachingNote(corrections: 0, correctionRate: nil, topFile: nil, medianTimeToFirstEditMs: nil) == nil)
}
}
@Suite("WorkflowStrip -- model derivation")
struct WorkflowStripModelTests {
@Test("full payload renders all three stats and a note")
func full() {
let model = WorkflowStripModel(
workflow: WorkflowBlock(corrections: 5, correctionRate: 0.03, medianTimeToFirstEditMs: 360_000),
topReworkedFiles: [ReworkedFileEntry(path: "sdk.py", sessions: 15, edits: 42)]
)
#expect(model.corrections == "3% (5)")
#expect(model.firstEdit == "6m")
#expect(model.reworked == "sdk.py ×15")
#expect(model.isEmpty == false)
// rate 0.03 < 0.15 so corrections note doesn't fire; churn does.
#expect(model.note == "sdk.py was reworked across 15 sessions (42 edits). A focused pass on it may cost less than the repeated churn.")
}
@Test("basename is applied to the reworked stat")
func reworkedBasename() {
let model = WorkflowStripModel(
workflow: nil,
topReworkedFiles: [ReworkedFileEntry(path: "app/src/main.ts", sessions: 4, edits: 9)]
)
#expect(model.reworkedName == "main.ts")
#expect(model.reworked == "main.ts ×4")
}
@Test("nil workflow and empty files -> hidden")
func emptyHidden() {
let model = WorkflowStripModel(workflow: nil, topReworkedFiles: [])
#expect(model.isEmpty)
#expect(model.corrections == nil)
#expect(model.firstEdit == nil)
#expect(model.reworkedName == nil)
#expect(model.note == nil)
}
@Test("all-empty workflow (zeros/nulls) -> hidden, no zero placeholders")
func allEmptyHidden() {
let model = WorkflowStripModel(
workflow: WorkflowBlock(corrections: 0, correctionRate: 0.0, medianTimeToFirstEditMs: nil),
topReworkedFiles: []
)
#expect(model.isEmpty)
#expect(model.corrections == nil) // 0 corrections is never "0% (0)"
#expect(model.firstEdit == nil)
}
@Test("zero median and zero-session file are treated as absent")
func degenerateValues() {
let model = WorkflowStripModel(
workflow: WorkflowBlock(corrections: 0, correctionRate: nil, medianTimeToFirstEditMs: 0),
topReworkedFiles: [ReworkedFileEntry(path: "a.py", sessions: 0, edits: 0)]
)
#expect(model.firstEdit == nil)
#expect(model.reworkedName == nil)
#expect(model.isEmpty)
}
@Test("a correction rate that rounds to 0% hides the corrections stat")
func subOnePercentCorrections() {
// Mirrors real month data: 6 corrections over thousands of turns -> 0.17%.
let model = WorkflowStripModel(
workflow: WorkflowBlock(corrections: 6, correctionRate: 0.001692524682651622, medianTimeToFirstEditMs: 384_425),
topReworkedFiles: [ReworkedFileEntry(path: "sdk.py", sessions: 9, edits: 54)]
)
#expect(model.corrections == nil)
#expect(model.firstEdit == "6m")
#expect(model.reworked == "sdk.py ×9")
#expect(model.note == "sdk.py was reworked across 9 sessions (54 edits). A focused pass on it may cost less than the repeated churn.")
#expect(model.isEmpty == false)
}
@Test("only a reworked file present renders one stat and the churn note")
func onlyReworked() {
let model = WorkflowStripModel(
workflow: nil,
topReworkedFiles: [ReworkedFileEntry(path: "a.py", sessions: 4, edits: 10)]
)
#expect(model.corrections == nil)
#expect(model.firstEdit == nil)
#expect(model.reworked == "a.py ×4")
#expect(model.isEmpty == false)
#expect(model.note == "a.py was reworked across 4 sessions (10 edits). A focused pass on it may cost less than the repeated churn.")
}
}
@Suite("WorkflowStrip -- payload decoding")
struct WorkflowStripDecodeTests {
private func decodeCurrent(_ json: String) throws -> CurrentBlock {
try JSONDecoder().decode(CurrentBlock.self, from: Data(json.utf8))
}
@Test("decodes workflow and topReworkedFiles when present")
func present() throws {
let current = try decodeCurrent("""
{
"label": "Month", "cost": 1, "calls": 2, "sessions": 3,
"inputTokens": 4, "outputTokens": 5,
"workflow": { "corrections": 5, "correctionRate": 0.03, "medianTimeToFirstEditMs": 360000 },
"topReworkedFiles": [ { "path": "sdk.py", "sessions": 15, "edits": 42 } ]
}
""")
#expect(current.workflow?.corrections == 5)
#expect(current.workflow?.correctionRate == 0.03)
#expect(current.workflow?.medianTimeToFirstEditMs == 360_000)
#expect(current.topReworkedFiles.first?.path == "sdk.py")
#expect(current.topReworkedFiles.first?.sessions == 15)
#expect(current.topReworkedFiles.first?.edits == 42)
}
@Test("decodes null workflow rates to nil")
func nulls() throws {
let current = try decodeCurrent("""
{
"label": "Month", "cost": 1, "calls": 2, "sessions": 3,
"inputTokens": 4, "outputTokens": 5,
"workflow": { "corrections": 0, "correctionRate": null, "medianTimeToFirstEditMs": null },
"topReworkedFiles": []
}
""")
#expect(current.workflow?.corrections == 0)
#expect(current.workflow?.correctionRate == nil)
#expect(current.workflow?.medianTimeToFirstEditMs == nil)
#expect(current.topReworkedFiles.isEmpty)
}
@Test("older payloads without the keys decode fine")
func backwardCompatible() throws {
let current = try decodeCurrent("""
{
"label": "Month", "cost": 1, "calls": 2, "sessions": 3,
"inputTokens": 4, "outputTokens": 5
}
""")
#expect(current.workflow == nil)
#expect(current.topReworkedFiles.isEmpty)
// And an older payload yields a hidden strip.
let model = WorkflowStripModel(workflow: current.workflow, topReworkedFiles: current.topReworkedFiles)
#expect(model.isEmpty)
}
}

View file

@ -16,7 +16,9 @@
"build:cli": "tsup && node -e \"const fs=require('fs'); fs.copyFileSync('src/cli.ts','dist/cli.js'); fs.chmodSync('dist/cli.js',0o755)\"",
"build:dash": "cd dash && npm install --no-audit --no-fund --silent && npm run build",
"dev": "NODE_OPTIONS=--no-deprecation tsx src/cli.ts",
"test": "vitest",
"test": "vitest run tests --exclude \"tests/cache-refresh-lock*\"",
"test:locks": "vitest run tests/cache-refresh-lock.test.ts tests/cache-refresh-lock-corrupt-body.test.ts tests/cache-refresh-lock-process.test.ts --poolOptions.forks.singleFork=true",
"test:watch": "vitest tests --exclude \"tests/cache-refresh-lock*\"",
"prepublishOnly": "npm run build"
},
"keywords": [

View file

@ -39,16 +39,27 @@ const HONEST_FOOTER =
'Estimates are scaled to the measured window for comparability; the at-apply estimate is kept in --json. '
+ 'MCP and archive realized figures are derived from per-session baselines times session counts, not independently measured. '
+ 'Each fix measures only its own metric; effects are never attributed across signals. '
+ 'Guard rows are correlation, not attribution. Realized numbers are rounded down.'
+ 'Guard rows are correlation, not attribution. Realized numbers are rounded down. '
+ 'Deferral rows exclude servers an MCP remove/scope row already measures.'
const MCP_KINDS = new Set<ActionKind>(['mcp-remove', 'mcp-project-scope'])
// defer-* re-enable native MCP tool deferral (part 2 of #614): the same
// prefix schema tokens mcp-remove eliminates, deferral moves out of the
// upfront prefix. Realized the same way — per-session schema tokens times the
// post-apply sessions that benefited — but "benefited" flips: instead of a
// server no longer loading, it is deferral having become active (the session
// now carries a deferred-tools inventory, the detector's own signal).
const DEFER_KINDS = new Set<ActionKind>(['defer-enable', 'defer-alwaysload', 'defer-threshold'])
const ARCHIVE_DEF_TOKENS: Partial<Record<ActionKind, number>> = {
'archive-skill': TOKENS_PER_SKILL_DEF,
'archive-agent': TOKENS_PER_AGENT_DEF,
'archive-command': TOKENS_PER_COMMAND_DEF,
}
export type RealizedStatus = 'measured' | 'reverted' | 'not-measurable'
// 'pending' means the applied change has not taken effect in any post-apply
// session yet (e.g. deferral before a client restart) - distinct from
// 'reverted', which asserts the user undid it.
export type RealizedStatus = 'measured' | 'reverted' | 'not-measurable' | 'pending'
export type ActReportRow = {
id: string
@ -178,6 +189,28 @@ function countSessionsLoading(projects: ProjectSummary[], servers: string[]): nu
return allSessions(projects).filter(s => sessionLoadsAny(s, servers)).length
}
// Deferral is active in a session exactly when Claude Code emitted a
// deferred-tools inventory for it — the same signal the mcp-deferral-off
// detector uses (its absence, alongside MCP overhead, is what flags a gap).
function sessionHasDeferralActive(s: SessionSummary): boolean {
return (s.mcpInventory?.length ?? 0) > 0
}
// MCP servers observed loading in the window (via inventory or invocation).
// defer-enable / defer-threshold re-enable deferral for the whole MCP surface
// rather than a named set, so the affected servers are derived here.
function observedMcpServers(projects: ProjectSummary[]): string[] {
const servers = new Set<string>()
for (const s of allSessions(projects)) {
for (const fqn of s.mcpInventory ?? []) {
const seg = fqn.split('__')[1]
if (seg) servers.add(seg)
}
for (const server of Object.keys(s.mcpBreakdown)) servers.add(server)
}
return [...servers]
}
// A kind whose realized effect is a token saving (everything except guard,
// which is a dollars/yield correlation, and out-of-scope kinds).
function isTokenKind(kind: ActionKind): boolean {
@ -226,6 +259,45 @@ function mcpRow(
return { ...base, estimatedForWindow, status: 'measured', realizedTokens: Math.floor(perSessionTokens * savedSessions), confidence }
}
function deferRow(
base: ActReportRow, sessions: SessionSummary[],
baseline: ActionBaseline, afterStart: Date, now: Date,
mcpClaimedServers: ReadonlySet<string>,
): ActReportRow {
// Sum only the servers no MCP row claims (see mcpClaimedServers in
// computeActReport), so the same schema tokens are never realized twice.
const counted = Object.entries(baseline.metrics).filter(([server]) => !mcpClaimedServers.has(server))
const excludedServers = Object.keys(baseline.metrics).length - counted.length
const perSessionTokens = counted.reduce((a, [, tokens]) => a + tokens, 0)
if (perSessionTokens === 0) {
return {
...base,
note: excludedServers > 0
? 'not measurable: every server in this baseline is already measured by an MCP remove/scope row'
: 'not measurable: empty baseline',
}
}
if (sessions.length === 0) return { ...base, note: 'not measurable: no sessions in the window yet' }
const estimatedForWindow = Math.floor(perSessionTokens * sessions.length)
// A post-apply session realized the saving only if deferral actually became
// active in it. ENABLE_TOOL_SEARCH is read at process start, so sessions
// begun before the user restarted still run deferral-off — those aren't
// counted, and if none benefited we report it plainly rather than claim a
// saving that hasn't taken effect.
const deferredSessions = sessions.filter(sessionHasDeferralActive).length
const confidence = confidenceFor(sessions.length, baseline, afterStart, now)
if (deferredSessions === 0) {
return {
...base,
estimatedForWindow,
status: 'pending',
confidence,
note: `not yet in effect: deferral is still inactive in ${sessions.length} post-apply session${sessions.length === 1 ? '' : 's'} (takes effect on the next session; the client may not have restarted, or the change was reverted)`,
}
}
return { ...base, estimatedForWindow, status: 'measured', realizedTokens: Math.floor(perSessionTokens * deferredSessions), confidence }
}
function archiveRow(
base: ActReportRow, rec: ActionRecord, sessions: SessionSummary[],
baseline: ActionBaseline, afterStart: Date, now: Date,
@ -358,7 +430,7 @@ async function modelDefaultRow(
async function computeRow(
rec: ActionRecord, sessions: SessionSummary[], afterStart: Date, now: Date,
opts: ActReportOptions, modelDefaultProjectFound = true,
mcpClaimedServers: ReadonlySet<string>, opts: ActReportOptions, modelDefaultProjectFound = true,
): Promise<ActReportRow> {
const estimatedAtApply = rec.baseline?.estimatedTokens ?? 0
const base: ActReportRow = {
@ -378,6 +450,7 @@ async function computeRow(
if (!baseline) return { ...base, note: 'not measurable: no baseline captured at apply time' }
if (MCP_KINDS.has(rec.kind)) return mcpRow(base, rec, sessions, baseline, afterStart, now)
if (DEFER_KINDS.has(rec.kind)) return deferRow(base, sessions, baseline, afterStart, now, mcpClaimedServers)
if (rec.kind in ARCHIVE_DEF_TOKENS) return archiveRow(base, rec, sessions, baseline, afterStart, now)
if (rec.kind === 'claude-md-rule') return readEditRow(base, sessions, baseline, afterStart, now)
if (rec.kind === 'shell-config') return { ...base, note: 'not measurable: bash result token sizes are not retained in the summary' }
@ -434,6 +507,20 @@ export async function computeActReport(opts: ActReportOptions = {}): Promise<Act
const projects = await loadProjects({ start: windowStart, end: now })
const costRate = computeInputCostRate(projects)
// Servers a same-journal MCP row (mcp-remove / mcp-project-scope) already
// measures. Deferral baselines for defer-enable / defer-threshold span the
// whole observed MCP surface, so without this exclusion a defer row and an
// MCP row would both claim the same server's schema tokens over the same
// post-apply sessions, inflating totalRealizedTokens. Conservative by
// design: the defer row drops the server for its whole window even though
// pre-removal sessions were legitimately its own - under-claiming keeps the
// footer's "each fix measures only its own metric" literally true.
const mcpClaimedServers = new Set<string>()
for (const r of active) {
if (!MCP_KINDS.has(r.kind) || !r.baseline) continue
for (const server of Object.keys(r.baseline.metrics)) mcpClaimedServers.add(server)
}
const rows: ActReportRow[] = []
for (const rec of eligible) {
const afterStart = new Date(Math.max(new Date(rec.at).getTime(), windowStart.getTime()))
@ -441,7 +528,7 @@ export async function computeActReport(opts: ActReportOptions = {}): Promise<Act
? modelDefaultSessionsInWindow(rec, projects, afterStart, now)
: undefined
const sessions = modelDefaultWindow?.sessions ?? sessionsInWindow(projects, afterStart, now)
rows.push(await computeRow(rec, sessions, afterStart, now, opts, modelDefaultWindow?.projectFound))
rows.push(await computeRow(rec, sessions, afterStart, now, mcpClaimedServers, opts, modelDefaultWindow?.projectFound))
}
const measuredRows = rows.filter(r => r.status === 'measured' && isTokenKind(r.kind))
@ -483,6 +570,7 @@ export function buildOptimizeAppliedHeader(report: ActReport): string | null {
function realizedCell(r: ActReportRow): string {
if (r.status === 'reverted') return 'reverted'
if (r.status === 'pending') return 'not yet in effect'
if (r.status === 'not-measurable') return 'not measurable'
if (r.correlation) return `abandoned ${r.correlation.abandonedPctThen}% -> ${r.correlation.abandonedPctNow}% (corr.)`
if (r.kind === 'model-default') return 'correlation'
@ -585,7 +673,15 @@ function mcpServersFromApply(finding: WasteFinding): string[] {
}
function needsConfigBaseline(kind: ActionKind): boolean {
return MCP_KINDS.has(kind) || kind in ARCHIVE_DEF_TOKENS || kind === 'claude-md-rule' || kind === 'shell-config'
return MCP_KINDS.has(kind) || DEFER_KINDS.has(kind) || kind in ARCHIVE_DEF_TOKENS || kind === 'claude-md-rule' || kind === 'shell-config'
}
// Servers whose upfront schema deferral removes from the prefix. defer-alwaysload
// names them; defer-enable / defer-threshold re-enable deferral across the whole
// observed MCP surface.
function deferServers(finding: WasteFinding, ctx: CaptureCtx): string[] {
if (finding.apply?.kind === 'defer-alwaysload') return finding.apply.servers.map(s => s.server)
return observedMcpServers(ctx.projects)
}
export function captureBaseline(finding: WasteFinding, kind: ActionKind, ctx: CaptureCtx): ActionBaseline | undefined {
@ -608,6 +704,19 @@ export function captureBaseline(finding: WasteFinding, kind: ActionKind, ctx: Ca
return { ...common, sessions: countSessionsLoading(ctx.projects, servers), metrics }
}
if (DEFER_KINDS.has(kind)) {
const servers = deferServers(finding, ctx)
if (servers.length === 0) return undefined
const covByServer = new Map(ctx.coverage.map(c => [c.server, c]))
const metrics: Record<string, number> = {}
for (const server of servers) {
const cov = covByServer.get(server)
const tools = cov && cov.toolsAvailable > 0 ? cov.toolsAvailable : TOOLS_PER_MCP_SERVER
metrics[server] = tools * TOKENS_PER_MCP_TOOL
}
return { ...common, sessions: countSessionsLoading(ctx.projects, servers), metrics }
}
const defTokens = ARCHIVE_DEF_TOKENS[kind]
if (defTokens !== undefined) {
const names = finding.apply?.kind === 'archive' ? finding.apply.names : []

View file

@ -1,4 +1,4 @@
import { randomBytes } from 'crypto'
import { createHash, randomBytes } from 'crypto'
import { existsSync } from 'fs'
import { mkdir, open, readFile, stat, unlink, utimes, writeFile } from 'fs/promises'
import { homedir } from 'os'
@ -81,6 +81,13 @@ async function retryWindowsMutation(operation: () => Promise<void>, sleep: (ms:
return false
}
// The directory entry becomes visible before the awaited body write, so the
// file is briefly observable at zero bytes. Deliberately left as is: a corrupt
// body is only ever recovered once its mtime is older than staleMs, and this
// window is milliseconds wide on a file whose mtime is by definition now, so
// no observer can reach the age gate through it. Closing it would mean
// link()ing a temp file into place, which is not portable to filesystems
// without hard links.
async function createExclusive(path: string, body: string): Promise<'created' | 'exists' | 'unavailable'> {
try {
const handle = await open(path, 'wx', 0o600)
@ -92,7 +99,24 @@ async function createExclusive(path: string, body: string): Promise<'created' |
}
}
type Observation = { record: LockRecord; mtimeMs: number }
// A null record is a body whose stat bracket agreed across the read and that
// still does not parse into a lock record: a corrupt leftover of 0 bytes, a
// truncation, or a wrong shape. The bracket is a heuristic, not proof that the
// read was whole — a same-size rewrite moves neither size nor (on a coarse
// filesystem) mtime — which is why nothing here treats a single read as
// authoritative. It owns nothing, but it is a real file with a
// real mtime, not an infrastructure failure — classifying it 'unavailable'
// routed every later refresh to the read-only path and froze ingestion. It
// carries no authority: it is only ever recovered through the unmodified
// staleness gate, exactly like an abandoned but well-formed lock.
//
// `digest` fingerprints the exact bytes. A corrupt body has no token, so
// token equality between two corrupt observations degenerates to
// `undefined === undefined`, and mtime granularity is coarse on some
// filesystems (measured on macOS: a 2s grid on FAT32, 10ms on exFAT, sub-ms on
// APFS — and on all three a same-size rewrite moves neither mtime nor size), so
// mtime is not a reliable change signal on its own.
type Observation = { record: LockRecord | null; mtimeMs: number; digest: string }
type ObservationResult = Observation | 'missing' | 'changing' | 'unavailable'
async function observe(path: string): Promise<ObservationResult> {
@ -100,6 +124,7 @@ async function observe(path: string): Promise<ObservationResult> {
// written, and heartbeat rewrites briefly truncate it. Treat that bounded
// transition as contention, not broken infrastructure.
let sawChange = false
let corrupt: Observation | null = null
for (let attempt = 0; attempt < 3; attempt++) {
try {
const before = await stat(path)
@ -110,10 +135,23 @@ async function observe(path: string): Promise<ObservationResult> {
await delay(1)
continue
}
const parsed = JSON.parse(raw) as Partial<LockRecord>
if (typeof parsed.pid === 'number' && typeof parsed.token === 'string' && typeof parsed.at === 'number') {
return { record: { pid: parsed.pid, token: parsed.token, at: parsed.at }, mtimeMs: after.mtimeMs }
const digest = createHash('sha1').update(raw).digest('hex')
// A body that is valid JSON of the wrong shape is corrupt like any other,
// including one written by a future version with a different record
// shape. That is safe precisely because staleness is never waived: a
// foreign version's LIVE lock keeps its mtime fresh through its own
// heartbeat, so it is never taken — both versions just degrade to the
// read-only path. Only an abandoned one is recovered, and a lock record
// is per-run state with nothing in it worth preserving.
let parsed: Partial<LockRecord> | undefined
try { parsed = JSON.parse(raw) as Partial<LockRecord> } catch { parsed = undefined }
if (parsed && typeof parsed.pid === 'number' && typeof parsed.token === 'string' && typeof parsed.at === 'number') {
return { record: { pid: parsed.pid, token: parsed.token, at: parsed.at }, mtimeMs: after.mtimeMs, digest }
}
// Keep the most recent corrupt read. It is not evidence of stability on
// its own: tryTakeover re-observes under the guard and compares with
// sameObservation before acting, so stability is proven there, not here.
corrupt = { record: null, mtimeMs: after.mtimeMs, digest }
} catch (err) {
if (isMissingError(err)) return 'missing'
const code = (err as NodeJS.ErrnoException | undefined)?.code
@ -121,11 +159,19 @@ async function observe(path: string): Promise<ObservationResult> {
}
await delay(1)
}
return sawChange ? 'changing' : 'unavailable'
// Contention outranks corruption: a body seen mid-rewrite is a live owner's,
// and the caller must poll rather than treat it as recoverable.
if (sawChange) return 'changing'
return corrupt ?? 'unavailable'
}
function sameObservation(a: Observation, b: Observation): boolean {
return a.record.token === b.record.token && a.mtimeMs === b.mtimeMs
// A corrupt body and an owned one are never "the same observation", even
// though `a.record?.token === b.record?.token` cannot tell them apart once
// both sides are corrupt. Compare that boundary explicitly, then require the
// bytes themselves to match, so "unchanged" survives a coarse mtime.
if ((a.record === null) !== (b.record === null)) return false
return a.record?.token === b.record?.token && a.mtimeMs === b.mtimeMs && a.digest === b.digest
}
let singleFlightTail: Promise<void> = Promise.resolve()
@ -209,7 +255,7 @@ export async function acquireCacheRefreshLock(options: RefreshLockOptions = {}):
if (current === 'missing') return true
if (current === 'changing') return false
if (current === 'unavailable') return false
if (current.record.token !== token) return true
if (current.record?.token !== token) return true
return retryWindowsMutation(() => unlink(lockPath), sleep)
} finally {
await retryWindowsMutation(() => unlink(takeoverPath), sleep)
@ -221,7 +267,7 @@ export async function acquireCacheRefreshLock(options: RefreshLockOptions = {}):
if (guard !== 'created') return false
try {
const current = await observe(lockPath)
return current !== 'missing' && current !== 'changing' && current !== 'unavailable' && current.record.token === token
return current !== 'missing' && current !== 'changing' && current !== 'unavailable' && current.record?.token === token
} finally {
await retryWindowsMutation(() => unlink(takeoverPath), sleep)
}
@ -238,7 +284,23 @@ export async function acquireCacheRefreshLock(options: RefreshLockOptions = {}):
if (guard !== 'created') { heartbeatRunning = false; return }
try {
const current = await observe(lockPath)
if (current === 'missing' || current === 'changing' || current === 'unavailable' || current.record.token !== token) return
if (current === 'missing' || current === 'changing' || current === 'unavailable') return
// A corrupt body is NOT ours to rewrite, even though no parseable
// token contradicts us. Holding the takeover guard excludes the other
// guard-takers, but NOT createExclusive, which publishes a directory
// entry before its body — so an unparseable body may be a successor's
// lock a millisecond from being written, or a foreign version's whose
// record shape we cannot read. Stamping our token over it made this
// process an owner again after it had been legitimately replaced:
// verifyStillOwner then answered true for a displaced writer, and
// release()'s removeIfOwned deleted the live successor's lock.
//
// So a body we cannot prove is ours ends our ownership. The mtime
// stops advancing, the fence refuses to publish (the parse is
// discarded, which is the fail-safe direction), and a successor
// recovers the lock one staleMs later through the age gate. Losing a
// parse is the correct price for never having two owners.
if (current.record === null || current.record.token !== token) return
await writeFile(lockPath, body(), { encoding: 'utf-8' })
const now = new Date(clock.wallNow())
await utimes(lockPath, now, now)
@ -325,6 +387,12 @@ export async function acquireCacheRefreshLock(options: RefreshLockOptions = {}):
continue
}
// A corrupt observation takes this path unchanged. Staleness is never
// waived for it: an abandoned corrupt lock is older than staleMs and is
// recovered here, while a corrupt body younger than that is waited out
// and left alone, because it may belong to a live owner whose heartbeat
// will repair it. Worst case we time out and serve the prior snapshot
// read-only for one staleMs window instead of freezing forever.
const age = Math.max(0, clock.wallNow() - observation.mtimeMs)
if (age > staleMs) {
const takeover = await tryTakeover(observation)

View file

@ -11,9 +11,10 @@ import type { ParsedProviderCall } from './providers/types.js'
// v5: also attribute CLI-wrapped MCP calls (`mcp-cli call server tool`) that
// Codex logs as a plain exec_command (issue #478 follow-up). Force a re-parse
// so sessions cached under v4 pick up the CLI-MCP attribution.
// v6: rich-session-capture — per-call locAdded/locRemoved/editFailed from
// v6/v7: rich-session-capture — per-call locAdded/locRemoved/editFailed from
// patch_apply_end. Sessions cached under v5 lack these fields; re-parse to add.
const CODEX_CACHE_VERSION = 6
// v8: persist native MCP timing and compact invocation attribution.
const CODEX_CACHE_VERSION = 8
const CACHE_FILE = 'codex-results.json'
type FileFingerprint = { mtimeMs: number; sizeBytes: number }

521
src/codex-throughput.ts Normal file
View file

@ -0,0 +1,521 @@
import { open, stat } from 'node:fs/promises'
import { StringDecoder } from 'node:string_decoder'
export type CodexThroughputPoint = {
timestamp: string
model?: string
outputTokens: number
reasoningTokens: number
generatedTokens: number
taskGeneratedTokens?: number
elapsedSeconds?: number
generatedTokensPerSecond?: number
activeDurationSeconds?: number
activeGeneratedTokensPerSecond?: number
toolWaitSeconds?: number
}
type TokenUsage = {
output_tokens?: number
reasoning_output_tokens?: number
total_tokens?: number
}
type RolloutLine = {
type?: string
timestamp?: string
payload?: {
type?: string
turn_id?: string
call_id?: string
started_at?: number
duration_ms?: number
duration?: { secs?: number; nanos?: number } | string
model?: string
forked_from_id?: string
info?: {
last_token_usage?: TokenUsage
total_token_usage?: TokenUsage
}
}
}
const CHUNK_BYTES = 64 * 1024
const MAX_PENDING_LINE_CHARS = 4 * 1024 * 1024
const TRUNCATION_MARKER = '__CODEBURN_TRUNCATED_LINE__'
function rawString(source: string, field: string): string | undefined {
const match = new RegExp(`"${field}"\\s*:\\s*"((?:\\\\.|[^"\\\\])*)"`).exec(source)
if (!match) return undefined
try { return JSON.parse(`"${match[1]}"`) as string } catch { return undefined }
}
function rawNumber(source: string, field: string): number | undefined {
const match = new RegExp(`"${field}"\\s*:\\s*(-?\\d+(?:\\.\\d+)?)`).exec(source)
if (!match) return undefined
const value = Number(match[1])
return Number.isFinite(value) ? value : undefined
}
function compactUsage(source: string, field: 'last_token_usage' | 'total_token_usage'): TokenUsage | undefined {
const index = source.indexOf(`"${field}"`)
if (index < 0) return undefined
const body = source.slice(index, index + 4096)
return {
output_tokens: rawNumber(body, 'output_tokens'),
reasoning_output_tokens: rawNumber(body, 'reasoning_output_tokens'),
total_tokens: rawNumber(body, 'total_tokens'),
}
}
function parseRawDurationValue(value: string): number | undefined {
const objectMatch = /^\s*\{\s*"secs"\s*:\s*(-?\d+(?:\.\d+)?)\s*,\s*"nanos"\s*:\s*(-?\d+(?:\.\d+)?)\s*\}/.exec(value)
if (objectMatch) {
const seconds = Number(objectMatch[1])
const nanos = Number(objectMatch[2])
if (Number.isFinite(seconds) && Number.isFinite(nanos)) return seconds * 1000 + nanos / 1e6
}
const stringMatch = /^\s*"(\d+(?:\.\d+)?)(ms|s)?"/.exec(value)
if (stringMatch) {
const parsed = Number(stringMatch[1])
if (Number.isFinite(parsed)) return parsed * (stringMatch[2] === 's' ? 1000 : 1)
}
const numberMatch = /^\s*(-?\d+(?:\.\d+)?)/.exec(value)
if (numberMatch) {
const parsed = Number(numberMatch[1])
if (Number.isFinite(parsed)) return parsed
}
return undefined
}
function durationMs(payload: RolloutLine['payload']): number | undefined {
if (!payload) return undefined
if (typeof payload.duration_ms === 'number' && Number.isFinite(payload.duration_ms)) return payload.duration_ms
if (typeof payload.duration === 'object' && payload.duration) {
const seconds = payload.duration.secs
const nanos = payload.duration.nanos
if (typeof seconds === 'number' && typeof nanos === 'number' && Number.isFinite(seconds) && Number.isFinite(nanos)) {
return seconds * 1000 + nanos / 1e6
}
}
if (typeof payload.duration === 'string') {
const match = /^(\d+(?:\.\d+)?)(ms|s)?$/.exec(payload.duration.trim())
if (match) return Number(match[1]) * (match[2] === 's' ? 1000 : 1)
}
return undefined
}
function mergeToolIntervals(intervals: Array<[number, number]>, durationMs: number, taskStartedAt?: number, taskCompletedAt?: number): number {
const windowStart = taskStartedAt ?? (taskCompletedAt !== undefined ? taskCompletedAt - durationMs : undefined)
const windowEnd = windowStart !== undefined ? windowStart + durationMs : undefined
const clipped = intervals.map(([start, end]) => [
windowStart !== undefined ? Math.max(start, windowStart) : start,
windowEnd !== undefined ? Math.min(end, windowEnd) : end,
] as [number, number]).filter(([start, end]) => end > start)
const merged = clipped.sort((a, b) => a[0] - b[0]).reduce<Array<[number, number]>>((result, interval) => {
const previous = result.at(-1)
if (previous && interval[0] <= previous[1]) previous[1] = Math.max(previous[1], interval[1])
else result.push([...interval])
return result
}, [])
return Math.min(durationMs, merged.reduce((total, [start, end]) => total + end - start, 0))
}
function parseLine(line: string): RolloutLine | null {
const payloadStart = line.indexOf('"payload"')
const payloadHead = payloadStart >= 0 ? line.slice(payloadStart) : line
if (line.length > 256 * 1024 || line.startsWith(TRUNCATION_MARKER)) {
const payloadType = rawString(payloadHead, 'type')
const infoStart = payloadHead.indexOf('"info"')
const info = infoStart >= 0 ? payloadHead.slice(infoStart) : ''
return {
type: rawString(line, 'type'),
timestamp: rawString(line, 'timestamp'),
payload: {
type: payloadType,
turn_id: rawString(payloadHead, 'turn_id'),
call_id: rawString(payloadHead, 'call_id'),
started_at: rawNumber(payloadHead, 'started_at'),
duration_ms: rawNumber(payloadHead, 'duration_ms'),
duration: rawString(payloadHead, 'duration') ?? (rawNumber(payloadHead, 'secs') !== undefined
? { secs: rawNumber(payloadHead, 'secs'), nanos: rawNumber(payloadHead, 'nanos') }
: undefined),
model: rawString(payloadHead, 'model'),
forked_from_id: rawString(payloadHead, 'forked_from_id'),
info: {
last_token_usage: compactUsage(info, 'last_token_usage'),
total_token_usage: compactUsage(info, 'total_token_usage'),
},
},
}
}
try {
return JSON.parse(line) as RolloutLine
} catch {
return null
}
}
/**
* Estimate generated tokens/sec from a Codex rollout's persisted checkpoints.
* Codex JSONL has no per-token timestamps, so this is deliberately a
* checkpoint-to-checkpoint estimate, not live decode speed.
*/
type ThroughputState = {
model?: string
previousTotal?: number
previousOutput: number
previousReasoning: number
previousTimestamp?: number
currentTaskGenerated: number
currentTaskToolIntervals: Array<[number, number]>
currentTaskStartedAt?: number
toolStarts: Map<string, number>
latestPoint?: CodexThroughputPoint
points: CodexThroughputPoint[]
forkCutoffMs?: number
}
function newThroughputState(): ThroughputState {
return {
previousOutput: 0,
previousReasoning: 0,
currentTaskGenerated: 0,
currentTaskToolIntervals: [],
toolStarts: new Map(),
points: [],
}
}
/**
* Incrementally parses a rollout. Watch mode feeds only newly appended bytes
* to this reader, so a growing JSONL file is not reparsed from byte zero.
*/
export class CodexThroughputReader {
private offset = 0
private pending = ''
private decoder = new StringDecoder('utf8')
private pendingDurationMs: number | undefined
private scanDepth = 0
private scanPayloadDepth: number | undefined
private scanInString = false
private scanEscape = false
private scanString = ''
private scanLastString = ''
private scanAwaitingColon = false
private scanCurrentKey: string | undefined
private scanCapture: { mode: 'string' | 'object' | 'primitive'; text: string; depth: number } | undefined
private state = newThroughputState()
reset(): void {
this.offset = 0
this.pending = ''
this.decoder = new StringDecoder('utf8')
this.pendingDurationMs = undefined
this.scanDepth = 0
this.scanPayloadDepth = undefined
this.scanInString = false
this.scanEscape = false
this.scanString = ''
this.scanLastString = ''
this.scanAwaitingColon = false
this.scanCurrentKey = undefined
this.scanCapture = undefined
this.state = newThroughputState()
}
private finishDurationCapture(): void {
if (!this.scanCapture) return
const value = this.scanCapture.mode === 'string' ? `"${this.scanCapture.text}"` : this.scanCapture.text
const parsed = parseRawDurationValue(value)
if (parsed !== undefined && this.pendingDurationMs === undefined) this.pendingDurationMs = parsed
this.scanCapture = undefined
}
private scanDurationSegment(source: string): void {
for (let i = 0; i < source.length; i++) {
const char = source[i]!
if (this.scanInString) {
if (this.scanEscape) {
this.scanEscape = false
if (this.scanCapture?.mode === 'object') this.scanCapture.text += char
else if (this.scanCapture?.mode === 'string') this.scanCapture.text += char
else this.scanString += char
continue
}
if (char === '\\') {
this.scanEscape = true
if (this.scanCapture?.mode === 'object' || this.scanCapture?.mode === 'string') this.scanCapture.text += char
continue
}
if (char === '"') {
if (this.scanCapture?.mode === 'object') this.scanCapture.text += char
this.scanInString = false
if (this.scanCapture?.mode === 'string') this.finishDurationCapture()
else if (this.scanCapture?.mode === 'object') {
this.scanAwaitingColon = false
this.scanCurrentKey = undefined
} else {
this.scanLastString = this.scanString
this.scanAwaitingColon = true
}
continue
}
if (this.scanCapture?.mode === 'object' || this.scanCapture?.mode === 'string') this.scanCapture.text += char
else this.scanString += char
continue
}
if (this.scanCapture?.mode === 'primitive') {
if (char === ',' || char === '}' || char === ']') this.finishDurationCapture()
else { this.scanCapture.text += char; continue }
}
if (this.scanAwaitingColon) {
if (/\s/.test(char)) continue
if (char === ':') {
this.scanCurrentKey = this.scanLastString
this.scanAwaitingColon = false
continue
}
this.scanAwaitingColon = false
}
if (char === '"') {
this.scanString = ''
if (this.scanCapture?.mode === 'object') this.scanCapture.text += char
if (this.scanCurrentKey === 'duration' && this.scanPayloadDepth === this.scanDepth) {
this.scanCapture = { mode: 'string', text: '', depth: this.scanDepth }
this.scanCurrentKey = undefined
}
this.scanInString = true
continue
}
if (char === '{' || char === '[') {
if (this.scanCurrentKey === 'payload' && char === '{' && this.scanPayloadDepth === undefined) {
this.scanPayloadDepth = this.scanDepth + 1
}
if (this.scanCurrentKey === 'duration' && this.scanPayloadDepth === this.scanDepth) {
this.scanCapture = { mode: 'object', text: char, depth: this.scanDepth + 1 }
this.scanCurrentKey = undefined
} else if (this.scanCapture?.mode === 'object') {
this.scanCapture.text += char
}
this.scanDepth++
continue
}
if (char === '}' || char === ']') {
if (this.scanCapture?.mode === 'object') this.scanCapture.text += char
this.scanDepth = Math.max(0, this.scanDepth - 1)
if (this.scanCapture?.mode === 'object' && this.scanDepth < this.scanCapture.depth) this.finishDurationCapture()
continue
}
if (this.scanCurrentKey === 'duration' && this.scanPayloadDepth === this.scanDepth && !/\s/.test(char)) {
this.scanCapture = { mode: 'primitive', text: char, depth: this.scanDepth }
this.scanCurrentKey = undefined
continue
}
if (this.scanCapture?.mode === 'object') this.scanCapture.text += char
}
}
private processLine(line: string, durationOverride?: number): void {
const entry = parseLine(line)
if (!entry) return
if (durationOverride !== undefined && (line.startsWith(TRUNCATION_MARKER) || line.length > 256 * 1024) && entry.type === 'event_msg' && (entry.payload?.type === 'mcp_tool_call_end' || entry.payload?.type === 'task_complete')) {
entry.payload = { ...entry.payload, duration_ms: durationOverride }
}
const state = this.state
if (entry.type === 'session_meta') {
if (entry.payload?.model) state.model = entry.payload.model
if (entry.payload?.forked_from_id && entry.timestamp) {
const timestamp = Date.parse(entry.timestamp)
if (Number.isFinite(timestamp)) state.forkCutoffMs = timestamp + 5000
}
return
}
if (entry.type === 'turn_context' && entry.payload?.model) state.model = entry.payload.model
const entryTimestamp = entry.timestamp ? Date.parse(entry.timestamp) : NaN
const isForkReplay = state.forkCutoffMs !== undefined && Number.isFinite(entryTimestamp) && entryTimestamp < state.forkCutoffMs
if (isForkReplay && (
entry.payload?.type === 'task_started' ||
entry.payload?.type === 'task_complete' ||
entry.payload?.type === 'function_call' ||
entry.payload?.type === 'function_call_output' ||
entry.payload?.type === 'custom_tool_call' ||
entry.payload?.type === 'custom_tool_call_output' ||
entry.payload?.type === 'mcp_tool_call_end' ||
entry.payload?.type === 'patch_apply_end' ||
entry.payload?.type === 'token_count'
)) return
if (entry.type === 'event_msg' && entry.payload?.type === 'task_started') {
state.currentTaskGenerated = 0
state.currentTaskToolIntervals = []
const startedAt = entry.timestamp ? Date.parse(entry.timestamp) : NaN
state.currentTaskStartedAt = Number.isFinite(startedAt) ? startedAt : undefined
state.toolStarts.clear()
}
if (entry.type === 'response_item' && (entry.payload?.type === 'function_call' || entry.payload?.type === 'custom_tool_call') && entry.payload.call_id && entry.timestamp) {
const started = Date.parse(entry.timestamp)
if (Number.isFinite(started)) state.toolStarts.set(entry.payload.call_id, started)
}
if (entry.type === 'response_item' && (entry.payload?.type === 'function_call_output' || entry.payload?.type === 'custom_tool_call_output') && entry.payload.call_id && entry.timestamp) {
const ended = Date.parse(entry.timestamp)
const started = state.toolStarts.get(entry.payload.call_id)
if (started !== undefined && Number.isFinite(ended) && ended > started) state.currentTaskToolIntervals.push([started, ended])
state.toolStarts.delete(entry.payload.call_id)
}
if (entry.type === 'event_msg' && entry.payload?.type === 'mcp_tool_call_end' && entry.timestamp) {
const ended = Date.parse(entry.timestamp)
const elapsed = durationMs(entry.payload)
if (Number.isFinite(ended) && elapsed !== undefined && elapsed > 0) state.currentTaskToolIntervals.push([ended - elapsed, ended])
}
if (entry.type === 'event_msg' && entry.payload?.type === 'task_complete') {
const taskDurationMs = durationMs(entry.payload)
if (state.latestPoint && typeof taskDurationMs === 'number' && taskDurationMs > 0 && state.currentTaskGenerated > 0) {
state.latestPoint.taskGeneratedTokens = state.currentTaskGenerated
const completedAt = entry.timestamp ? Date.parse(entry.timestamp) : undefined
const toolWaitMs = mergeToolIntervals(state.currentTaskToolIntervals, taskDurationMs, state.currentTaskStartedAt, Number.isFinite(completedAt) ? completedAt : undefined)
const activeMs = taskDurationMs - toolWaitMs
if (activeMs > 0) {
state.latestPoint.activeDurationSeconds = activeMs / 1000
state.latestPoint.toolWaitSeconds = toolWaitMs / 1000
state.latestPoint.activeGeneratedTokensPerSecond = state.currentTaskGenerated / (activeMs / 1000)
}
}
}
if (entry.type !== 'event_msg' || entry.payload?.type !== 'token_count') return
const info = entry.payload.info
if (!info || !entry.timestamp) return
const last = info.last_token_usage
const total = info.total_token_usage
const cumulative = total?.total_tokens
if (cumulative !== undefined && cumulative === state.previousTotal) return
let outputTokens = last?.output_tokens ?? 0
let reasoningTokens = last?.reasoning_output_tokens ?? 0
if (!last && total && cumulative !== undefined && state.previousTotal !== undefined) {
outputTokens = Math.max(0, (total.output_tokens ?? 0) - state.previousOutput)
reasoningTokens = Math.max(0, (total.reasoning_output_tokens ?? 0) - state.previousReasoning)
}
if (cumulative !== undefined) {
state.previousTotal = cumulative
state.previousOutput = total?.output_tokens ?? state.previousOutput
state.previousReasoning = total?.reasoning_output_tokens ?? state.previousReasoning
}
const generatedTokens = outputTokens + reasoningTokens
if (generatedTokens <= 0) return
const timestampMs = Date.parse(entry.timestamp)
if (!Number.isFinite(timestampMs)) return
const point: CodexThroughputPoint = {
timestamp: entry.timestamp,
model: state.model,
outputTokens,
reasoningTokens,
generatedTokens,
}
state.currentTaskGenerated += generatedTokens
state.latestPoint = point
if (state.previousTimestamp !== undefined && timestampMs > state.previousTimestamp) {
const elapsedSeconds = (timestampMs - state.previousTimestamp) / 1000
point.elapsedSeconds = elapsedSeconds
point.generatedTokensPerSecond = generatedTokens / elapsedSeconds
}
state.previousTimestamp = timestampMs
state.points.push(point)
if (state.points.length > 10000) state.points.splice(0, state.points.length - 10000)
}
async update(filePath: string, limit = 10, finalize = false): Promise<CodexThroughputPoint[]> {
const info = await stat(filePath)
if (info.size < this.offset) this.reset()
const bytesToRead = info.size - this.offset
if (bytesToRead > 0) {
const file = await open(filePath, 'r')
try {
let position = this.offset
while (position < info.size) {
const buffer = Buffer.allocUnsafe(Math.min(CHUNK_BYTES, info.size - position))
const { bytesRead } = await file.read(buffer, 0, buffer.length, position)
if (bytesRead === 0) break
position += bytesRead
this.offset = position
let chunk = this.decoder.write(buffer.subarray(0, bytesRead))
while (chunk.length > 0) {
const newlineIndex = chunk.search(/\r?\n/)
const segment = newlineIndex >= 0 ? chunk.slice(0, newlineIndex) : chunk
this.pending += segment
this.scanDurationSegment(segment)
if (newlineIndex < 0) break
const newlineLength = chunk[newlineIndex] === '\r' ? 2 : 1
const line = this.pending
const durationOverride = this.pendingDurationMs
this.pending = ''
this.pendingDurationMs = undefined
this.scanDepth = 0
this.scanPayloadDepth = undefined
this.scanInString = false
this.scanEscape = false
this.scanString = ''
this.scanLastString = ''
this.scanAwaitingColon = false
this.scanCurrentKey = undefined
this.scanCapture = undefined
this.processLine(line, durationOverride)
chunk = chunk.slice(newlineIndex + newlineLength)
}
if (this.pending.length > MAX_PENDING_LINE_CHARS) {
const body = this.pending.startsWith(TRUNCATION_MARKER)
? this.pending.slice(TRUNCATION_MARKER.length)
: this.pending
this.pending = TRUNCATION_MARKER + body.slice(0, 256 * 1024) + body.slice(-256 * 1024)
}
}
} finally {
await file.close()
}
}
if (finalize && this.pending) {
this.processLine(this.pending, this.pendingDurationMs)
this.pending = ''
this.pendingDurationMs = undefined
}
return limit > 0 ? this.state.points.slice(-limit) : this.state.points.slice()
}
}
export async function readCodexThroughput(filePath: string, limit = 10): Promise<CodexThroughputPoint[]> {
return new CodexThroughputReader().update(filePath, limit, true)
}
export async function newestCodexSession(sessions: Array<{ path: string }>): Promise<string | undefined> {
let newest: { path: string; mtimeMs: number } | undefined
for (const session of sessions) {
try {
const info = await stat(session.path)
if (!newest || info.mtimeMs > newest.mtimeMs) newest = { path: session.path, mtimeMs: info.mtimeMs }
} catch {
// A session can disappear while Codex rotates or archives it.
}
}
return newest?.path
}
export function renderCodexThroughput(points: CodexThroughputPoint[], filePath: string): string {
const latest = points.at(-1)
if (!latest) return `No token_count checkpoints found in ${filePath}.`
const lines = [
'CodeBurn Codex throughput estimate',
`Session: ${filePath}`,
`Latest checkpoint: ${latest.timestamp}`,
`Latest checkpoint tokens: ${latest.generatedTokens.toLocaleString()} (${latest.outputTokens.toLocaleString()} output + ${latest.reasoningTokens.toLocaleString()} reasoning)`,
]
if (latest.taskGeneratedTokens !== undefined) lines.push(`Completed task total: ${latest.taskGeneratedTokens.toLocaleString()} generated tokens`)
if (latest.activeGeneratedTokensPerSecond !== undefined) {
lines.push(`Active throughput: ${latest.activeGeneratedTokensPerSecond.toFixed(1)} generated tokens/sec over ${latest.activeDurationSeconds!.toFixed(1)}s`)
lines.push(`Excluded tool wait: ${latest.toolWaitSeconds!.toFixed(1)}s`)
} else if (latest.generatedTokensPerSecond !== undefined) {
lines.push(`Checkpoint estimate: ${latest.generatedTokensPerSecond.toFixed(1)} generated tokens/sec over ${latest.elapsedSeconds!.toFixed(1)}s`)
} else {
lines.push('Throughput: unavailable (waiting for a completed turn)')
}
lines.push('Note: offline JSONL estimate; tool intervals are removed, but server/prompt latency may remain.')
return lines.join('\n')
}

View file

@ -2,6 +2,7 @@ import { readdir, readFile } from 'fs/promises'
import { join } from 'path'
import type { ProjectSummary } from './types.js'
import { getShortModelName } from './models.js'
const PLANNING_TOOLS = new Set(['TaskCreate', 'TaskUpdate', 'TodoWrite', 'EnterPlanMode', 'ExitPlanMode'])
@ -73,6 +74,16 @@ export function aggregateModelStats(projects: ProjectSummary[]): ModelStats[] {
return [...byModel.values()].sort((a, b) => b.cost - a.cost)
}
/// Look up a model by the exact canonical id (what --model-a/--model-b has
/// always accepted) or, failing that, by its display name (what the compare
/// picker actually shows the user, e.g. "Opus 4.8") - case-insensitively.
/// Reuses getShortModelName, the existing canonical -> display mapping,
/// rather than a new alias table.
export function findModelStat(models: ModelStats[], input: string): ModelStats | undefined {
return models.find(m => m.model === input)
?? models.find(m => getShortModelName(m.model).toLowerCase() === input.toLowerCase())
}
export type ComparisonRow = {
section: string
label: string

View file

@ -2,7 +2,7 @@ import React, { useState, useEffect, useRef } from 'react'
import { render, Box, Text, useInput, useApp, useStdout } from 'ink'
import type { ModelStats, ComparisonRow, CategoryComparison, WorkingStyleRow } from './compare-stats.js'
import { aggregateModelStats, computeComparison, computeCategoryComparison, computeWorkingStyle, scanSelfCorrections } from './compare-stats.js'
import { aggregateModelStats, computeComparison, computeCategoryComparison, computeWorkingStyle, findModelStat, scanSelfCorrections } from './compare-stats.js'
import { formatCost } from './format.js'
import { parseAllSessions, setInteractiveScanUI } from './parser.js'
import { getAllProviders } from './providers/index.js'
@ -333,9 +333,13 @@ function ComparisonResults({ modelA, modelB, rows, categories, workingStyle, onB
type CompareViewProps = {
projects: ProjectSummary[]
onBack: () => void
// Pre-resolved canonical model ids from --model-a/--model-b (already
// validated to exist in `projects`' aggregated stats by the caller). When
// set, comparison results load immediately instead of showing the picker.
presetModels?: [string, string]
}
export function CompareView({ projects, onBack }: CompareViewProps) {
export function CompareView({ projects, onBack, presetModels }: CompareViewProps) {
const { exit } = useApp()
const [phase, setPhase] = useState<'select' | 'loading' | 'results'>('select')
const [models, setModels] = useState<ModelStats[]>(() => aggregateModelStats(projects))
@ -347,13 +351,13 @@ export function CompareView({ projects, onBack }: CompareViewProps) {
}
return recs
})
const [pickedNames, setPickedNames] = useState<[string, string] | null>(null)
const [pickedNames, setPickedNames] = useState<[string, string] | null>(presetModels ?? null)
const [selectedA, setSelectedA] = useState<ModelStats | null>(null)
const [selectedB, setSelectedB] = useState<ModelStats | null>(null)
const [rows, setRows] = useState<ComparisonRow[]>([])
const [categories, setCategories] = useState<CategoryComparison[]>([])
const [style, setStyle] = useState<WorkingStyleRow[]>([])
const [loadTrigger, setLoadTrigger] = useState(0)
const [loadTrigger, setLoadTrigger] = useState(presetModels ? 1 : 0)
const projectsRef = useRef(projects)
projectsRef.current = projects
@ -504,7 +508,7 @@ export function CompareView({ projects, onBack }: CompareViewProps) {
)
}
export async function renderCompare(range: DateRange, provider: string): Promise<void> {
export async function renderCompare(range: DateRange, provider: string, modelA?: string, modelB?: string): Promise<void> {
// Interactive Ink UI: suppress the CLI scan-progress line for the whole
// lifetime so it can't print over the rendered comparison. Plain CLI
// commands still show progress.
@ -517,8 +521,28 @@ export async function renderCompare(range: DateRange, provider: string): Promise
patchStdoutForWindows()
const projects = await parseAllSessions(range, provider)
// --model-a/--model-b: resolve up front (by canonical id or display name,
// same lookup the JSON path uses) so the TUI jumps straight to results
// instead of ignoring the flags and showing the picker.
let presetModels: [string, string] | undefined
if (modelA && modelB) {
const models = aggregateModelStats(projects)
const a = findModelStat(models, modelA)
const b = findModelStat(models, modelB)
if (!a) {
process.stderr.write(`codeburn compare: model not found: "${modelA}".\n`)
process.exit(1)
}
if (!b) {
process.stderr.write(`codeburn compare: model not found: "${modelB}".\n`)
process.exit(1)
}
presetModels = [a.model, b.model]
}
const { waitUntilExit } = render(
<CompareView projects={projects} onBack={() => process.exit(0)} />
<CompareView projects={projects} onBack={() => process.exit(0)} presetModels={presetModels} />
)
await waitUntilExit()
}

View file

@ -60,8 +60,13 @@ async function countMcpTools(projectPath?: string): Promise<number> {
}
async function countSkills(projectPath?: string): Promise<number> {
const dirs = [join(homedir(), '.claude', 'skills')]
if (projectPath) dirs.push(join(projectPath, '.claude', 'skills'))
// Dedupe by resolved path: when the project IS the home dir, the home and
// project skills dirs are the same directory, and counting both double-counts
// every skill (and inflates the context budget).
const dirs = [...new Set([
join(homedir(), '.claude', 'skills'),
...(projectPath ? [join(projectPath, '.claude', 'skills')] : []),
])]
let count = 0
for (const dir of dirs) {
@ -91,7 +96,12 @@ async function scanMemoryFiles(projectPath?: string): Promise<Array<{ name: stri
paths.push({ path: join(projectPath, 'CLAUDE.local.md'), name: 'CLAUDE.local.md' })
}
// Dedupe by path so a project that IS the home dir does not read (and count)
// ~/.claude/CLAUDE.md twice.
const seenPaths = new Set<string>()
for (const { path, name } of paths) {
if (seenPaths.has(path)) continue
seenPaths.add(path)
if (!existsSync(path)) continue
const content = await readSessionFile(path)
if (content === null) continue

View file

@ -5,7 +5,23 @@ import { homedir } from 'os'
import { join } from 'path'
import type { DateRange, ProjectSummary } from './types.js'
// Bumped to 15: per-project daily rollups. Days and provider slices now carry
// Bumped to 17: copilot CLI sessions were misclassified as VS Code transcripts
// (#944), so days finalized at v16 or earlier carry output-only copilot costs —
// the session.shutdown rollup's input/cache tokens were dropped. Raising
// MIN_SUPPORTED_VERSION forces the one-time re-derivation under the
// provenance-based classification; sourceless days carry forward as-is.
//
// v16: Codex discovery is structural instead of originator-gated
// (#873/#626), so rollouts written by third-party frontends driving
// `codex app-server` ("t3code_desktop", "JetBrains.IntelliJ IDEA", ...) now
// contribute usage that v15 rollups never contained. Those files were rejected
// before they were ever parsed, so nothing downstream can notice on its own:
// `usage-aggregator` serves every day before today from this cache, and
// retention is ten years, so an upgrading user with a warm cache would keep the
// pre-fix history forever while today's numbers silently disagreed with it.
// Raising MIN_SUPPORTED_VERSION forces the one-time re-derivation.
//
// v15: per-project daily rollups. Days and provider slices now carry
// a `projects` breakdown (cost/calls/savings/sessions per project) so project
// history outlives the session files, like models and categories already do.
// This bump is the first to ride the v14 carry-forward: the old cache is
@ -57,8 +73,8 @@ import type { DateRange, ProjectSummary } from './types.js'
// that older binaries skipped. v8 added local-model savings to the daily
// rollup; the `savingsConfigHash` field is invalidated separately when the
// user changes their `localModelSavings` mapping.
export const DAILY_CACHE_VERSION = 15
const MIN_SUPPORTED_VERSION = 15
export const DAILY_CACHE_VERSION = 17
const MIN_SUPPORTED_VERSION = 17
// Version-suffixed so different binaries each own a distinct file and never
// clobber an incompatible schema. Bumping the version mints a fresh filename;
// adoptOlderDailyCaches then unions days out of every previous file (including
@ -150,6 +166,14 @@ export type DailyCache = {
/// as incomplete and is fully re-backfilled. Absent on caches written before
/// this field existed → treated as incomplete (one self-healing re-backfill).
complete?: boolean
/// True once a COMPLETE parse finalized this watermark. The pull-back below
/// only distrusts caches WITHOUT this stamp: a degraded parse can no longer
/// set `complete`, so a stamped cache whose watermark sits past its newest
/// populated day is a legitimately idle tail (recent days had no activity),
/// not a frozen hole, and re-deriving it every launch is pure waste. Absent
/// on caches written before this field: distrusted once (one healing
/// pull-back), then stamped.
watermarkTrusted?: boolean
}
function getCacheDir(): string {
@ -257,7 +281,13 @@ function sanitizeProjects(raw: unknown): { projects?: DailyEntry['projects'] } {
if (!isRecord(raw)) return {}
const out: NonNullable<DailyEntry['projects']> = {}
for (const [name, p] of Object.entries(raw)) {
if (name in Object.prototype || !isRecord(p)) continue
// A project key is a directory basename, so it can legitimately be a
// prototype-member name ("constructor", "valueOf", ...). `setOwn` writes it
// as an own property via defineProperty, so keeping it is pollution-safe —
// and dropping it would silently subtract that project's cost from a
// --project/--exclude total (the day's split would no longer sum to its own
// cost, which the filtered headline relies on).
if (!isRecord(p)) continue
setOwn(out, name, {
cost: num(p.cost),
calls: num(p.calls),
@ -294,7 +324,7 @@ function migrateDays(days: Record<string, unknown>[]): DailyEntry[] {
}))
}
function migratedFrom(parsed: { version: number; lastComputedDate: string | null; savingsConfigHash?: string; tzKey?: string; days: Record<string, unknown>[]; complete?: boolean }): DailyCache {
function migratedFrom(parsed: { version: number; lastComputedDate: string | null; savingsConfigHash?: string; tzKey?: string; days: Record<string, unknown>[]; complete?: boolean; watermarkTrusted?: boolean }): DailyCache {
return {
version: DAILY_CACHE_VERSION,
savingsConfigHash: parsed.savingsConfigHash ?? '',
@ -306,6 +336,9 @@ function migratedFrom(parsed: { version: number; lastComputedDate: string | null
// Only a cache explicitly marked complete stays trusted; one written before
// the marker existed reads false and is re-backfilled once.
complete: parsed.complete === true,
// Absent on a pre-fix cache: the watermark is distrusted once (healing
// pull-back), then re-stamped by the finalize that follows.
watermarkTrusted: parsed.watermarkTrusted === true,
}
}
@ -409,6 +442,7 @@ async function adoptOlderDailyCaches(): Promise<DailyCache> {
// accounting: leave complete unset so the next hydration re-derives every
// day whose sources survive (the merge keeps the rest).
complete: rest.length === candidates.length ? false : base.complete,
watermarkTrusted: rest.length === candidates.length ? false : base.watermarkTrusted,
}
await saveDailyCache(adopted).catch(() => {})
return adopted
@ -451,6 +485,7 @@ export function addNewDays(cache: DailyCache, incoming: DailyEntry[], newestDate
lastComputedDate: nextLast,
days: applyRetention(merged, newestDate),
complete: cache.complete,
watermarkTrusted: cache.watermarkTrusted,
}
}
@ -491,19 +526,32 @@ function emptyModelStats(): ModelDayStats {
/// day but whose turns all landed on another) only contributes its session
/// count, deduplicated by max — the same real session may be counted on both
/// sides.
function addSliceIntoDay(day: DailyEntry, provider: string, slice: ProviderDaySlice): void {
/// `residual` marks a slice that came out of the tz subtraction (issue #770):
/// the subtraction already removed the placeholder's sessions (the ones the
/// fresh parse explained), so the residual sessions are all distinct from the
/// placeholder's and must ADD to it, not max-dedup against it. Max would clamp
/// max(placeholder, residual) and permanently drop the source-gone sessions the
/// residual still carries.
function addSliceIntoDay(day: DailyEntry, provider: string, slice: ProviderDaySlice, residual = false): void {
// Reads keyed by names from foreign caches use hasOwn throughout: a plain
// lookup of "__proto__" returns the prototype object, and accumulating into
// it pollutes every object in the process.
const placeholder = Object.hasOwn(day.providers, provider) ? day.providers[provider] : undefined
const placeholderSessions = placeholder?.sessions ?? 0
const merged = structuredClone(slice)
if (placeholderSessions > (merged.sessions ?? 0)) merged.sessions = placeholderSessions
if (residual) {
// The subtraction removed the placeholder's sessions from this residual, so
// every remaining session is distinct from the placeholder's - add, don't
// max (max would clamp 1 + 1 to 1 and lose the source-gone session).
merged.sessions = placeholderSessions + (merged.sessions ?? 0)
} else if (placeholderSessions > (merged.sessions ?? 0)) {
merged.sessions = placeholderSessions
}
setOwn(day.providers, provider, merged)
day.cost += slice.cost
day.calls += slice.calls
day.savingsUSD += slice.savingsUSD ?? 0
day.sessions += Math.max(0, (slice.sessions ?? 0) - placeholderSessions)
day.sessions += residual ? (slice.sessions ?? 0) : Math.max(0, (slice.sessions ?? 0) - placeholderSessions)
day.inputTokens += slice.inputTokens ?? 0
day.outputTokens += slice.outputTokens ?? 0
day.cacheReadTokens += slice.cacheReadTokens ?? 0
@ -543,7 +591,7 @@ function addSliceIntoDay(day: DailyEntry, provider: string, slice: ProviderDaySl
// project sessions were already counted into the day when the fresh day
// was built, so only the excess is added.
const placeholderProjectSessions = Object.hasOwn(placeholderProjects, name) ? num(placeholderProjects[name]?.sessions) : 0
acc.sessions += Math.max(0, num(p.sessions) - placeholderProjectSessions)
acc.sessions += residual ? num(p.sessions) : Math.max(0, num(p.sessions) - placeholderProjectSessions)
setOwn(dayProjects, name, acc)
}
// Placeholder-only projects (session counted fresh, calls landed elsewhere)
@ -553,7 +601,11 @@ function addSliceIntoDay(day: DailyEntry, provider: string, slice: ProviderDaySl
for (const [name, p] of Object.entries(placeholderProjects)) {
if (!p || typeof p !== 'object') continue
if (Object.hasOwn(mergedProjects, name)) {
if (num(p.sessions) > num(mergedProjects[name]!.sessions)) mergedProjects[name]!.sessions = num(p.sessions)
if (residual) {
mergedProjects[name]!.sessions = num(mergedProjects[name]!.sessions) + num(p.sessions)
} else if (num(p.sessions) > num(mergedProjects[name]!.sessions)) {
mergedProjects[name]!.sessions = num(p.sessions)
}
} else {
setOwn(mergedProjects, name, { cost: 0, calls: 0, savingsUSD: 0, sessions: num(p.sessions) })
}
@ -569,6 +621,246 @@ function setOwn<T>(target: Record<string, T>, key: string, value: T): void {
Object.defineProperty(target, key, { value, enumerable: true, writable: true, configurable: true })
}
// --- tz-aware carry subtraction (issue #770) ---------------------------------
//
// After a timezone change the full re-derive re-aggregates the same session
// parse under the CURRENT tz and merges it over the cached (old-tz) days.
// mergeDayEntries carries a baseline slice only when the fresh day has no data
// slice for that (date, provider), so a turn that re-bucketed across local
// midnight leaves its old day sliceless, gets carried there, AND counts again on
// its new day. The fix subtracts from each carried baseline slice the content
// the fresh parse still attributes to that (date, provider) under the OLD
// bucketing (`freshUnderOldTz`): exactly the re-bucketed turns, nothing else.
// A sources-gone slice has no such content and survives untouched; a slice fully
// explained away is dropped.
/// Reduce `base` by `sub` at the slice level, clamping every field at 0 and
/// dropping nested entries that reduce to nothing. Returns null when no positive
/// data remains; the merge then drops the slice instead of carrying an empty
/// one. `sub` is always a subset of `base` in practice (same parse, old bucketing
/// vs cached baseline), so the clamp only guards rounding and cache/baseline skew.
function subtractSlice(base: ProviderDaySlice, sub: ProviderDaySlice): ProviderDaySlice | null {
const calls = Math.max(0, base.calls - (sub.calls ?? 0))
const cost = Math.max(0, base.cost - (sub.cost ?? 0))
const savingsUSD = Math.max(0, (base.savingsUSD ?? 0) - (sub.savingsUSD ?? 0))
const sessions = Math.max(0, (base.sessions ?? 0) - (sub.sessions ?? 0))
const inputTokens = Math.max(0, (base.inputTokens ?? 0) - (sub.inputTokens ?? 0))
const outputTokens = Math.max(0, (base.outputTokens ?? 0) - (sub.outputTokens ?? 0))
const cacheReadTokens = Math.max(0, (base.cacheReadTokens ?? 0) - (sub.cacheReadTokens ?? 0))
const cacheWriteTokens = Math.max(0, (base.cacheWriteTokens ?? 0) - (sub.cacheWriteTokens ?? 0))
const editTurns = Math.max(0, (base.editTurns ?? 0) - (sub.editTurns ?? 0))
const oneShotTurns = Math.max(0, (base.oneShotTurns ?? 0) - (sub.oneShotTurns ?? 0))
const models = subtractModels(base.models, sub.models)
const categories = subtractCategories(base.categories, sub.categories)
const projects = subtractProjects(base.projects, sub.projects)
const out: ProviderDaySlice = {
calls, cost, savingsUSD,
...(sessions > 0 ? { sessions } : {}),
...(inputTokens > 0 ? { inputTokens } : {}),
...(outputTokens > 0 ? { outputTokens } : {}),
...(cacheReadTokens > 0 ? { cacheReadTokens } : {}),
...(cacheWriteTokens > 0 ? { cacheWriteTokens } : {}),
...(editTurns > 0 ? { editTurns } : {}),
...(oneShotTurns > 0 ? { oneShotTurns } : {}),
...(models ? { models } : {}),
...(categories ? { categories } : {}),
...(projects ? { projects } : {}),
}
return hasSliceData(out) || (out.sessions ?? 0) > 0 ? out : null
}
function subtractModelStats(base: ModelDayStats, sub: ModelDayStats): ModelDayStats | null {
const calls = Math.max(0, base.calls - (sub.calls ?? 0))
const cost = Math.max(0, base.cost - (sub.cost ?? 0))
const savingsUSD = Math.max(0, (base.savingsUSD ?? 0) - (sub.savingsUSD ?? 0))
const inputTokens = Math.max(0, base.inputTokens - (sub.inputTokens ?? 0))
const outputTokens = Math.max(0, base.outputTokens - (sub.outputTokens ?? 0))
const cacheReadTokens = Math.max(0, base.cacheReadTokens - (sub.cacheReadTokens ?? 0))
const cacheWriteTokens = Math.max(0, base.cacheWriteTokens - (sub.cacheWriteTokens ?? 0))
if (calls === 0 && cost === 0 && savingsUSD === 0 && inputTokens === 0 && outputTokens === 0 && cacheReadTokens === 0 && cacheWriteTokens === 0) return null
return { calls, cost, savingsUSD, inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens }
}
function subtractModels(base: DailyEntry['models'] | undefined, sub: DailyEntry['models'] | undefined): DailyEntry['models'] | undefined {
if (!base) return undefined
const out: DailyEntry['models'] = {}
for (const [name, stats] of Object.entries(base)) {
const s = sub && Object.hasOwn(sub, name) ? sub[name] : undefined
const reduced = s ? subtractModelStats(stats, s) : stats
if (reduced) setOwn(out, name, reduced)
}
return Object.keys(out).length > 0 ? out : undefined
}
function subtractCategoryStats(base: CategoryDayStats, sub: CategoryDayStats): CategoryDayStats | null {
const turns = Math.max(0, base.turns - (sub.turns ?? 0))
const cost = Math.max(0, base.cost - (sub.cost ?? 0))
const savingsUSD = Math.max(0, (base.savingsUSD ?? 0) - (sub.savingsUSD ?? 0))
const editTurns = Math.max(0, base.editTurns - (sub.editTurns ?? 0))
const oneShotTurns = Math.max(0, base.oneShotTurns - (sub.oneShotTurns ?? 0))
if (turns === 0 && cost === 0 && savingsUSD === 0 && editTurns === 0 && oneShotTurns === 0) return null
return { turns, cost, savingsUSD, editTurns, oneShotTurns }
}
function subtractCategories(base: DailyEntry['categories'] | undefined, sub: DailyEntry['categories'] | undefined): DailyEntry['categories'] | undefined {
if (!base) return undefined
const out: DailyEntry['categories'] = {}
for (const [name, stats] of Object.entries(base)) {
const s = sub && Object.hasOwn(sub, name) ? sub[name] : undefined
const reduced = s ? subtractCategoryStats(stats, s) : stats
if (reduced) setOwn(out, name, reduced)
}
return Object.keys(out).length > 0 ? out : undefined
}
function subtractProjectStats(base: ProjectDayStats, sub: ProjectDayStats): ProjectDayStats | null {
const cost = Math.max(0, base.cost - (sub.cost ?? 0))
const calls = Math.max(0, base.calls - (sub.calls ?? 0))
const savingsUSD = Math.max(0, (base.savingsUSD ?? 0) - (sub.savingsUSD ?? 0))
const sessions = Math.max(0, (base.sessions ?? 0) - (sub.sessions ?? 0))
if (cost === 0 && calls === 0 && savingsUSD === 0 && sessions === 0) return null
return { cost, calls, savingsUSD, sessions, ...(base.path ? { path: base.path } : {}) }
}
function subtractProjects(base: DailyEntry['projects'] | undefined, sub: DailyEntry['projects'] | undefined): DailyEntry['projects'] | undefined {
if (!base) return undefined
const out: DailyEntry['projects'] = {}
for (const [name, stats] of Object.entries(base)) {
const s = sub && Object.hasOwn(sub, name) ? sub[name] : undefined
const reduced = s ? subtractProjectStats(stats, s) : stats
if (reduced) setOwn(out, name, reduced)
}
return Object.keys(out).length > 0 ? out : undefined
}
/// How much a nested stat entry actually lost: `base` before minus `reduced`
/// after, or null when nothing was lost. The raw `sub` is only a lower bound -
/// with tz skew it can exceed the slice, and subtracting it would eat OTHER
/// providers' share of the day-level breakdown.
function modelStatsDelta(base: ModelDayStats, reduced: ModelDayStats): ModelDayStats | null {
const calls = base.calls - reduced.calls
const cost = base.cost - reduced.cost
const savingsUSD = (base.savingsUSD ?? 0) - (reduced.savingsUSD ?? 0)
const inputTokens = base.inputTokens - reduced.inputTokens
const outputTokens = base.outputTokens - reduced.outputTokens
const cacheReadTokens = base.cacheReadTokens - reduced.cacheReadTokens
const cacheWriteTokens = base.cacheWriteTokens - reduced.cacheWriteTokens
if (calls === 0 && cost === 0 && savingsUSD === 0 && inputTokens === 0 && outputTokens === 0 && cacheReadTokens === 0 && cacheWriteTokens === 0) return null
return { calls, cost, savingsUSD, inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens }
}
function categoryStatsDelta(base: CategoryDayStats, reduced: CategoryDayStats): CategoryDayStats | null {
const turns = base.turns - reduced.turns
const cost = base.cost - reduced.cost
const savingsUSD = (base.savingsUSD ?? 0) - (reduced.savingsUSD ?? 0)
const editTurns = base.editTurns - reduced.editTurns
const oneShotTurns = base.oneShotTurns - reduced.oneShotTurns
if (turns === 0 && cost === 0 && savingsUSD === 0 && editTurns === 0 && oneShotTurns === 0) return null
return { turns, cost, savingsUSD, editTurns, oneShotTurns }
}
function projectStatsDelta(base: ProjectDayStats, reduced: ProjectDayStats): ProjectDayStats | null {
const cost = base.cost - reduced.cost
const calls = base.calls - reduced.calls
const savingsUSD = (base.savingsUSD ?? 0) - (reduced.savingsUSD ?? 0)
const sessions = (base.sessions ?? 0) - (reduced.sessions ?? 0)
if (cost === 0 && calls === 0 && savingsUSD === 0 && sessions === 0) return null
return { cost, calls, savingsUSD, sessions }
}
/// Remove `sub`'s contribution from a carried baseline day (the baseline-only
/// date branch of the merge, where the whole day clones over). Reduces the
/// provider's slice, the day-level totals, and the day-level models/categories/
/// projects maps that `addSliceIntoDay` would have grown them by.
///
/// Every day-level subtraction uses the EFFECTIVE removal - what the provider
/// slice actually lost (current before minus reduced after) - not the raw `sub`.
/// With tz skew (`freshUnderOldTz` content larger than the baseline slice), the
/// raw sub exceeds the slice and subtracting it would over-remove the day's
/// totals and its nested maps, eating unrelated providers' carried history and
/// breaking the invariant that a day's totals sum to its slices. A provider
/// slice that was absent has an effective removal of zero: nothing is subtracted
/// from the day.
function subtractSliceFromDay(day: DailyEntry, provider: string, sub: ProviderDaySlice): void {
const current = Object.hasOwn(day.providers, provider) ? day.providers[provider] : undefined
if (!current) return
const reduced = subtractSlice(current, sub)
if (reduced) setOwn(day.providers, provider, reduced)
else delete day.providers[provider]
day.cost = Math.max(0, day.cost - (current.cost - (reduced?.cost ?? 0)))
day.calls = Math.max(0, day.calls - (current.calls - (reduced?.calls ?? 0)))
day.savingsUSD = Math.max(0, (day.savingsUSD ?? 0) - ((current.savingsUSD ?? 0) - (reduced?.savingsUSD ?? 0)))
day.sessions = Math.max(0, day.sessions - ((current.sessions ?? 0) - (reduced?.sessions ?? 0)))
day.inputTokens = Math.max(0, day.inputTokens - ((current.inputTokens ?? 0) - (reduced?.inputTokens ?? 0)))
day.outputTokens = Math.max(0, day.outputTokens - ((current.outputTokens ?? 0) - (reduced?.outputTokens ?? 0)))
day.cacheReadTokens = Math.max(0, day.cacheReadTokens - ((current.cacheReadTokens ?? 0) - (reduced?.cacheReadTokens ?? 0)))
day.cacheWriteTokens = Math.max(0, day.cacheWriteTokens - ((current.cacheWriteTokens ?? 0) - (reduced?.cacheWriteTokens ?? 0)))
day.editTurns = Math.max(0, day.editTurns - ((current.editTurns ?? 0) - (reduced?.editTurns ?? 0)))
day.oneShotTurns = Math.max(0, day.oneShotTurns - ((current.oneShotTurns ?? 0) - (reduced?.oneShotTurns ?? 0)))
for (const [name, m] of Object.entries(current.models ?? {})) {
const rm = reduced?.models && Object.hasOwn(reduced.models, name) ? reduced.models[name] : undefined
const removed = rm ? modelStatsDelta(m, rm) : m
if (!removed) continue
const acc = Object.hasOwn(day.models, name) ? day.models[name] : undefined
if (!acc) continue
const reducedM = subtractModelStats(acc, removed)
if (reducedM) setOwn(day.models, name, reducedM)
else delete day.models[name]
}
for (const [cat, c] of Object.entries(current.categories ?? {})) {
const rc = reduced?.categories && Object.hasOwn(reduced.categories, cat) ? reduced.categories[cat] : undefined
const removed = rc ? categoryStatsDelta(c, rc) : c
if (!removed) continue
const acc = Object.hasOwn(day.categories, cat) ? day.categories[cat] : undefined
if (!acc) continue
const reducedC = subtractCategoryStats(acc, removed)
if (reducedC) setOwn(day.categories, cat, reducedC)
else delete day.categories[cat]
}
if (!day.projects) return
for (const [name, p] of Object.entries(current.projects ?? {})) {
const rp = reduced?.projects && Object.hasOwn(reduced.projects, name) ? reduced.projects[name] : undefined
const removed = rp ? projectStatsDelta(p, rp) : p
if (!removed) continue
const acc = Object.hasOwn(day.projects, name) ? day.projects[name] : undefined
if (!acc) continue
const reducedP = subtractProjectStats(acc, removed)
if (reducedP) setOwn(day.projects, name, reducedP)
else delete day.projects[name]
}
}
/// Did the tz subtraction leave any positive data on a carried baseline day?
/// Mirrors the merge's own carry criterion (`hasSliceData` or sessions) at the
/// day level, extended to the day's other scalar and nested content.
function hasPositiveDayContent(day: DailyEntry): boolean {
if (day.cost > 0 || day.calls > 0 || (day.savingsUSD ?? 0) > 0 || day.sessions > 0) return true
if (day.inputTokens > 0 || day.outputTokens > 0 || day.cacheReadTokens > 0 || day.cacheWriteTokens > 0) return true
if (day.editTurns > 0 || day.oneShotTurns > 0) return true
if (Object.keys(day.providers).length > 0) return true
if (Object.keys(day.models).length > 0 || Object.keys(day.categories).length > 0) return true
if (day.projects && Object.keys(day.projects).length > 0) return true
return false
}
/// Index `freshUnderOldTz` (the same parse re-aggregated under the cache's OLD
/// tzKey) by date then provider, so the merge can subtract exactly what the
/// fresh parse still explains under the old bucketing.
function buildTzSubtraction(days: DailyEntry[]): ReadonlyMap<string, ReadonlyMap<string, ProviderDaySlice>> {
const byDate = new Map<string, Map<string, ProviderDaySlice>>()
for (const day of days) {
if (Object.keys(day.providers).length === 0) continue
const byProvider = new Map<string, ProviderDaySlice>()
for (const [provider, slice] of Object.entries(day.providers)) {
byProvider.set(provider, slice)
}
byDate.set(day.date, byProvider)
}
return byDate
}
/// Merge two day lists per (date, provider): `primary` wins wherever both have
/// data; `secondary` only fills dates primary lacks entirely and provider
/// slices primary lacks on shared dates. Nothing in secondary can overwrite or
@ -584,13 +876,36 @@ function setOwn<T>(target: Record<string, T>, key: string, value: T): void {
/// A primary slice blocks a secondary one only when it carries DATA; a
/// zero-data placeholder (sessions only) is merged into, not treated as a
/// re-derivation of the provider's day.
export function mergeDayEntries(primary: DailyEntry[], secondary: DailyEntry[], markSecondaryCarried: boolean): DailyEntry[] {
/// `subtract`, present ONLY on the tz-change re-derive, maps (date, provider)
/// to the content the fresh parse still attributes there under the OLD
/// bucketing. Every baseline slice the merge would otherwise carry has that
/// content subtracted first (clamped at 0, dropped when nothing positive
/// remains), so turns that re-bucketed across local midnight are not counted on
/// both their old and new days. Absent (undefined) on every other path, which
/// keeps those merges byte-identical to the pre-fix behavior.
export function mergeDayEntries(
primary: DailyEntry[],
secondary: DailyEntry[],
markSecondaryCarried: boolean,
subtract?: ReadonlyMap<string, ReadonlyMap<string, ProviderDaySlice>>,
): DailyEntry[] {
const byDate = new Map<string, DailyEntry>()
for (const day of primary) byDate.set(day.date, structuredClone(day))
for (const day of secondary) {
const existing = byDate.get(day.date)
if (!existing) {
const copy = structuredClone(day)
if (subtract) {
const subForDate = subtract.get(day.date)
if (subForDate) {
for (const [provider, slice] of Object.entries(copy.providers)) {
const subSlice = subForDate.get(provider)
if (!subSlice) continue
subtractSliceFromDay(copy, provider, subSlice)
}
if (!hasPositiveDayContent(copy)) continue
}
}
if (markSecondaryCarried) copy.carried = true
byDate.set(day.date, copy)
continue
@ -602,7 +917,22 @@ export function mergeDayEntries(primary: DailyEntry[], secondary: DailyEntry[],
if (!hasSliceData(slice) && !(slice.sessions ?? 0)) continue
const existingSlice = Object.hasOwn(existing.providers, provider) ? existing.providers[provider] : undefined
if (existingSlice && hasSliceData(existingSlice)) continue
addSliceIntoDay(existing, provider, slice)
let toAdd = slice
let residual = false
if (subtract) {
const subSlice = subtract.get(day.date)?.get(provider)
if (subSlice) {
const reduced = subtractSlice(slice, subSlice)
if (!reduced) continue
toAdd = reduced
// The subtraction already removed the sessions the fresh parse
// explained, so the residual's sessions are distinct from the fresh
// placeholder's: merging over it must ADD, not max-dedup (fix round
// 1 - max would drop the source-gone sessions the residual carries).
residual = true
}
}
addSliceIntoDay(existing, provider, toAdd, residual)
if (markSecondaryCarried) existing.carried = true
}
}
@ -650,6 +980,12 @@ export async function ensureCacheHydrated(
/// So the backfill is only marked `complete` when this returns true. Defaults
/// to a trusting `true` for callers that don't (or can't) supply it.
sessionComplete: () => boolean = () => true,
/// Re-aggregate the SAME parsed projects under an explicit timezone instead of
/// the machine's local one. Used only on a tz-change re-derive: the result is
/// compared against the fresh local-tz days to subtract the turns that
/// re-bucketed across local midnight from the carried baseline (issue #770).
/// Absent, the tz-change path carries forward exactly as it did before.
aggregateDaysInTz?: (projects: ProjectSummary[], tz: string) => DailyEntry[],
): Promise<DailyCache> {
const now = new Date()
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())
@ -671,6 +1007,27 @@ export async function ensureCacheHydrated(
c = { ...c, days: freshDays, lastComputedDate: latestFresh }
}
// A cache can claim `complete` while its watermark points PAST its newest
// populated day — what a run finalizing off a degraded (read-only) parse
// leaves behind: it advanced lastComputedDate over days the parse never
// covered. Since gapStart is lastComputedDate + 1, that hole is invisible
// to the gap logic forever. Trust the DATA over the marker: pull the
// watermark back to the newest day actually present so the ordinary gap
// parse re-derives the tail. Nothing is dropped — the cached days all stay.
//
// Only UNSTAMPED caches are distrusted here. A degraded parse can no longer
// set `complete` (that is this fix), so the corrupt state can only be
// written by pre-fix code: an unstamped cache. A stamped one whose watermark
// outruns its newest day is a legitimately idle tail (recent days had no
// activity), and re-deriving that empty tail on every launch is the
// regression this guard avoids. A cache with NO days is exempt: it has no
// newest day to trust, and a machine with no history at all must still be
// able to finalize (below) rather than re-backfill on every launch.
const newestCachedDate = c.days.reduce<string | null>((max, d) => (max === null || d.date > max ? d.date : max), null)
if (c.watermarkTrusted !== true && newestCachedDate !== null && c.lastComputedDate !== null && c.lastComputedDate > newestCachedDate) {
c = { ...c, lastComputedDate: newestCachedDate }
}
// Three reasons to re-derive the whole retention window:
// 1. Savings config changed — cached `savingsUSD` totals are stale.
// 2. The cache was never finalized against a COMPLETE session parse (an old
@ -691,26 +1048,70 @@ export async function ensureCacheHydrated(
const tzChanged = c.tzKey !== undefined && c.tzKey !== tzKey
if (c.savingsConfigHash !== savingsConfigHash || c.complete !== true || tzChanged) {
const baseline = c.days
const priorWatermark = c.lastComputedDate
const backfillStart = new Date(now.getFullYear(), now.getMonth(), now.getDate() - BACKFILL_DAYS)
let freshDays: DailyEntry[] = []
let projects: ProjectSummary[] = []
if (backfillStart.getTime() <= yesterdayEnd.getTime()) {
freshDays = aggregateDays(await parseSessions({ start: backfillStart, end: yesterdayEnd }))
// Hoisted so a tz-change re-derive can aggregate the SAME parse twice
// (once under the current tz as freshDays, once under the cache's old
// tzKey as freshUnderOldTz) without a second session parse.
//
// The parse stops at yesterdayEnd. Keeping it a HISTORY parse is what
// makes the parser slice a midnight-straddling turn at the yesterday
// boundary: day-N's turn-level category/counts then carry only the
// pre-midnight half and today's live parse carries the rest, so the two
// sides reconcile (issue #852). Widening THIS parse through now would
// leave the full turn on day N while today's half was excluded from the
// cache, breaking that reconciliation - so the subtraction below gets
// its own through-now parse instead.
projects = await parseSessions({ start: backfillStart, end: yesterdayEnd })
freshDays = aggregateDays(projects)
}
const parseWasComplete = sessionComplete()
// A PARTIAL parse must not overwrite finalized baseline days with
// undercounts (if their sources die before the next complete parse, the
// undercount would be what survives). Partial fresh data only fills days
// and slices the baseline lacks; the next complete parse gets to win.
//
// On a complete-parse TZ re-derive (savings config untouched), subtract
// from each carried baseline slice the content the fresh parse still
// attributes to that (date, provider) under the OLD bucketing: the turns
// that re-bucketed across local midnight. That is the issue #770
// double-count; re-pricing drift (a savings-hash change) must never be
// subtracted, so a hash change in the same re-derive skips this entirely.
let tzSubtraction: ReadonlyMap<string, ReadonlyMap<string, ProviderDaySlice>> | undefined
if (parseWasComplete && tzChanged && c.savingsConfigHash === savingsConfigHash && aggregateDaysInTz && c.tzKey !== undefined) {
// The subtraction re-parses THROUGH NOW (fix round 1): a call bucketed
// to OLD-tz yesterday that re-buckets to NEW-tz TODAY sits past the
// history parse's yesterdayEnd, so `freshUnderOldTz` built from `projects`
// would never see it - the baseline slice would be carried un-subtracted
// while today's live parse counts it again. This second parse exists
// ONLY for the subtraction; it never feeds freshDays, so the merged
// days written to the cache stay exactly the history days and today is
// still owned by the caller's live parse.
const wideProjects = await parseSessions({ start: backfillStart, end: now })
tzSubtraction = buildTzSubtraction(aggregateDaysInTz(wideProjects, c.tzKey))
}
const merged = parseWasComplete
? mergeDayEntries(freshDays, baseline, true)
? mergeDayEntries(freshDays, baseline, true, tzSubtraction)
: mergeDayEntries(baseline, freshDays, false)
c = {
version: DAILY_CACHE_VERSION,
savingsConfigHash,
tzKey,
lastComputedDate: yesterdayStr,
// The watermark records how far history has actually been derived, so
// only a COMPLETE parse may advance it. A partial one produced no data
// for whatever it could not read; moving the watermark to yesterday
// anyway would place those days behind the next run's gapStart and
// freeze the hole in (retention still anchors on yesterdayStr — the
// real calendar edge — so holding the watermark can't evict anything).
lastComputedDate: parseWasComplete ? yesterdayStr : priorWatermark,
days: applyRetention(merged, yesterdayStr),
complete: parseWasComplete,
// Stamp the watermark as trusted only when a COMPLETE parse produced it,
// so a later idle tail under this watermark is not distrusted above.
watermarkTrusted: parseWasComplete,
}
await saveDailyCache(c)
return c
@ -733,18 +1134,23 @@ export async function ensureCacheHydrated(
const gapRange: DateRange = { start: gapStart, end: yesterdayEnd }
const gapProjects = await parseSessions(gapRange)
const gapDays = aggregateDays(gapProjects)
const parseWasComplete = sessionComplete()
const priorWatermark = c.lastComputedDate
c = addNewDays(c, gapDays, yesterdayStr)
// Finalize as complete ONLY when the session parse that produced these days
// was itself complete. If it was partial, leave `complete: false` so the
// next launch (once the session cache is whole) re-backfills instead of
// freezing the partial history.
c = { ...c, complete: sessionComplete() }
// freezing the partial history — and hold the watermark where it was, for
// the same reason as the re-derive path above: a partial parse cannot
// vouch for the days it never read, and gapStart is the only thing that
// will ever bring them back.
c = { ...c, lastComputedDate: parseWasComplete ? c.lastComputedDate : priorWatermark, complete: parseWasComplete, watermarkTrusted: parseWasComplete }
await saveDailyCache(c)
} else if (c.complete !== true && sessionComplete()) {
// No gap to fill (already current through yesterday) but not yet marked —
// e.g. a brand-new machine whose only data is today. Finalize so future
// launches don't re-backfill the whole window every time.
c = { ...c, complete: true }
c = { ...c, complete: true, watermarkTrusted: true }
await saveDailyCache(c)
}
return c

File diff suppressed because it is too large Load diff

View file

@ -26,6 +26,23 @@ export function dateKey(iso: string): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
/// Bucket an ISO timestamp under an explicit IANA timezone instead of the
/// machine's local one. `en-CA` emits the ISO-ish YYYY-MM-DD layout directly,
/// so formatToParts under the given `timeZone` yields exactly that shape. Used
/// to re-aggregate the same parse under a cache's OLD tzKey when a timezone
/// change forces a full re-derive (issue #770): comparing that bucketing to the
/// fresh one shows exactly which turns re-bucketed across local midnight.
export function dateKeyInTz(iso: string, tz: string): string {
const parts = new Intl.DateTimeFormat('en-CA', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit' }).formatToParts(new Date(iso))
let year = '', month = '', day = ''
for (const p of parts) {
if (p.type === 'year') year = p.value
else if (p.type === 'month') month = p.value
else if (p.type === 'day') day = p.value
}
return `${year}-${month}-${day}`
}
function emptySlice(): ProviderDaySlice {
return {
calls: 0, cost: 0, savingsUSD: 0,
@ -34,7 +51,7 @@ function emptySlice(): ProviderDaySlice {
}
}
export function aggregateProjectsIntoDays(projects: ProjectSummary[]): DailyEntry[] {
export function aggregateProjectsIntoDays(projects: ProjectSummary[], dateKeyFn: (iso: string) => string = dateKey): DailyEntry[] {
const byDate = new Map<string, DailyEntry>()
const ensure = (date: string): DailyEntry => {
let d = byDate.get(date)
@ -61,7 +78,7 @@ export function aggregateProjectsIntoDays(projects: ProjectSummary[]): DailyEntr
for (const project of projects) {
for (const session of project.sessions) {
const sessionDate = dateKey(session.firstTimestamp)
const sessionDate = dateKeyFn(session.firstTimestamp)
const sessionDay = ensure(sessionDate)
sessionDay.sessions += 1
ensureProject(sessionDay, session.project, project.projectPath).sessions += 1
@ -75,15 +92,26 @@ export function aggregateProjectsIntoDays(projects: ProjectSummary[]): DailyEntr
for (const turn of session.turns) {
if (turn.assistantCalls.length === 0) continue
// Turn-anchored bucketing: attribute the WHOLE turn — every one of its
// calls — to the day of the turn's user-message timestamp, matching the
// live headline/report rollup (main.ts daily). Falls back to the first
// assistant-call timestamp when the user line is missing (continuation
// sessions that begin mid-conversation). Previously the calls were
// bucketed per-call by each call's own timestamp, so a midnight-
// straddling turn split across two days and history.daily / the provider
// breakdown never reconciled to current.cost (a constant offset).
const turnDate = dateKey(turn.timestamp || turn.assistantCalls[0]!.timestamp)
// Two bucketing rules, deliberately different per level:
// - Turn-level judgments (category, editTurns, oneShotTurns) stay
// anchored to the turn's day (its timestamp — the user-message time,
// or the re-anchored first surviving call when the parser sliced
// the turn to a range, and falling back to the first assistant call
// when the user line is missing). They describe the whole exchange,
// not a per-call sum, so a sliced straddling turn reports them on
// each side's anchor day — summed across days they inflate, which
// is the accepted, documented semantics (see review on #852).
// - Call-derived values (cost/savings/calls/tokens and the model,
// project, and provider-slice rollups built from them) bucket under
// EACH CALL's own local day (the per-call loop below). The parser
// slices straddling turns per range (issue #852), so every parse
// only holds in-range calls and per-call bucketing keeps day-N +
// day-N+1 equal to the whole range — and history.daily reconciled
// to the headline built from the same days. (Before the parser
// sliced per call, per-call bucketing here was what caused the
// constant offset against the whole-turn headline; the slice is
// what makes it exact now.)
const turnDate = dateKeyFn(turn.timestamp || turn.assistantCalls[0]!.timestamp)
const turnDay = ensure(turnDate)
const editTurns = turn.hasEdits ? 1 : 0
@ -140,21 +168,26 @@ export function aggregateProjectsIntoDays(projects: ProjectSummary[]): DailyEntr
for (const call of turn.assistantCalls) {
const callSavings = call.savingsUSD ?? 0
// Call-derived values bucket under the call's OWN day (see the
// two-rule comment above). An unparseable call timestamp falls back
// to the turn's anchor day rather than producing a garbage date key.
const callDate = Number.isNaN(new Date(call.timestamp).getTime()) ? turnDate : dateKeyFn(call.timestamp)
const callDay = ensure(callDate)
turnDay.cost += call.costUSD
turnDay.savingsUSD += callSavings
turnDay.calls += 1
turnDay.inputTokens += call.usage.inputTokens
turnDay.outputTokens += call.usage.outputTokens
turnDay.cacheReadTokens += call.usage.cacheReadInputTokens
turnDay.cacheWriteTokens += call.usage.cacheCreationInputTokens
callDay.cost += call.costUSD
callDay.savingsUSD += callSavings
callDay.calls += 1
callDay.inputTokens += call.usage.inputTokens
callDay.outputTokens += call.usage.outputTokens
callDay.cacheReadTokens += call.usage.cacheReadInputTokens
callDay.cacheWriteTokens += call.usage.cacheCreationInputTokens
const dayProject = ensureProject(turnDay, session.project, project.projectPath)
const dayProject = ensureProject(callDay, session.project, project.projectPath)
dayProject.cost += call.costUSD
dayProject.calls += 1
dayProject.savingsUSD += callSavings
const model = turnDay.models[call.model] ?? {
const model = callDay.models[call.model] ?? {
calls: 0, cost: 0, savingsUSD: 0,
inputTokens: 0, outputTokens: 0,
cacheReadTokens: 0, cacheWriteTokens: 0,
@ -166,9 +199,9 @@ export function aggregateProjectsIntoDays(projects: ProjectSummary[]): DailyEntr
model.outputTokens += call.usage.outputTokens
model.cacheReadTokens += call.usage.cacheReadInputTokens
model.cacheWriteTokens += call.usage.cacheCreationInputTokens
turnDay.models[call.model] = model
callDay.models[call.model] = model
const slice = ensureSlice(turnDay, call.provider)
const slice = ensureSlice(callDay, call.provider)
slice.calls += 1
slice.cost += call.costUSD
slice.savingsUSD += callSavings

Some files were not shown because too many files have changed in this diff Show more