Merge pull request #679 from getagentseal/feat/desktop-app

CodeBurn Desktop: standalone Electron app
This commit is contained in:
Resham Joshi 2026-07-16 07:54:05 -07:00 committed by GitHub
commit b7e3bac263
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
134 changed files with 23398 additions and 41 deletions

View file

@ -2,6 +2,8 @@
This document describes the actual steps a maintainer takes to cut a CLI or macOS menubar release. CLI releases are run by hand with `npm publish`; macOS menubar releases are automated by `.github/workflows/release-menubar.yml` when a `mac-v*` tag is pushed.
The Electron desktop app (`app/`) is not part of either flow yet — it has no version tag pattern or CI automation. See `app/DISTRIBUTION.md` for how to build and distribute it as an ad-hoc-signed, non-notarized macOS build.
## Versioning
CodeBurn uses semantic versioning (major.minor.patch). The CLI and macOS menubar share the same version number for clarity.

5
app/.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
node_modules/
dist/
release/
*.log
.vite/

178
app/DISTRIBUTION.md Normal file
View file

@ -0,0 +1,178 @@
# Distributing CodeBurn Desktop
This document describes how to produce a distributable macOS build of the
Electron desktop app **without a paid Apple Developer account**. There is no
CI automation for this yet (unlike the CLI and menubar release processes in
`../RELEASING.md`) — packaging is run by hand on a maintainer's machine.
## Prerequisite on the target machine: the codeburn CLI
The desktop app does not bundle the CLI — every screen gets its data by
spawning `codeburn`, resolved from the persisted path file, Homebrew/node
version-manager locations, or `PATH` (`electron/cli.ts`). On a machine
without it, the app launches into its "CLI not found" state until the user
runs:
```sh
npm install -g codeburn
```
Bundling `dist/cli.js` into the app (`extraResources` + spawning it with
Electron's own binary via `ELECTRON_RUN_AS_NODE`) is the known path to a
zero-dependency install; not implemented yet.
## Versioning
`app/package.json`'s `version` tracks the CLI's version (root
`package.json`) — one CodeBurn version across CLI, menubar, and desktop.
Bump it in the same change that bumps the root version; the splash, the
About dialog, and the artifact filenames all read it from there.
## Build
```sh
npm --prefix app install
npm --prefix app run package # both arm64 and x64
npm --prefix app run package:arm64 # arm64 only (faster on Apple Silicon)
npm --prefix app run package:x64 # x64 only
```
`package` runs `npm run build` (compiles `electron/` with `tsc`, builds the
renderer with `vite`) and then `electron-builder --mac`.
### Artifacts
electron-builder writes to `app/release/` (gitignored, like `dist/`):
- `CodeBurn-<version>-arm64.dmg`, `CodeBurn-<version>.dmg` — installer images
- `CodeBurn-<version>-arm64-mac.zip`, `CodeBurn-<version>-mac.zip` — zipped `.app` bundles
- `release/mac-arm64/CodeBurn.app`, `release/mac/CodeBurn.app` — the raw unpacked bundles (arm64 and x64 respectively)
- `.blockmap` files alongside each zip/dmg (used by electron-builder's differential-update mechanism; unused since this app has no auto-updater yet)
Both `dmg` and `zip` targets are built for both `arm64` and `x64` — four
artifacts total, not a universal binary. This keeps each download roughly
half the size of a universal build. Pick the zip if you just want to unpack
and drag to `/Applications`; the dmg gives users the familiar drag-to-Applications
installer window.
### Build configuration
The `build` block lives in `app/package.json` (small enough not to warrant a
separate `electron-builder.yml`):
- `appId: "org.agentseal.codeburn-desktop"` — reuses the `org.agentseal.*`
prefix from the menubar app's bundle id (`org.agentseal.codeburn-menubar`,
see `mac/Scripts/package-app.sh`); there is no `com.codeburn.*` bundle id
anywhere in the codebase, so `org.agentseal.*` is the actual house
convention.
- `productName: "CodeBurn"`.
- `files`: only `dist/electron/**/*`, `dist/renderer/**/*`, and `package.json`.
The Electron main process has no npm runtime dependencies (only Node/Electron
builtins — see `app/electron/cli.ts` and `app/electron/quota/*.ts`), and the
renderer is a single Vite bundle, so `node_modules` does not need to ship at
all.
- `mac.identity: "-"` — forces ad-hoc signing. **`identity: null` does NOT
ad-hoc sign — it skips signing entirely**, which produces a bundle with a
broken/absent seal (`codesign --verify --deep --strict` fails with
`code has no resources but signature indicates they must be present`, and
Apple Silicon refuses to run it at all). `"-"` is the same ad-hoc identity
`mac/Scripts/package-app.sh` uses for the menubar app's local/CI builds.
- `mac.hardenedRuntime: false` — hardened runtime is for notarized builds;
leaving it on for an ad-hoc signature with no entitlements can prevent the
app from launching.
- `mac.gatekeeperAssess: false` — skips electron-builder's post-sign
`spctl` check, which would always fail for an unnotarized app.
- `icon: build/icon.png` — a pre-existing 1024x1024 PNG at
`app/build/icon.png`. No `.icns` exists in the repo; electron-builder
generates one from the PNG at build time. This is the same source PNG
used for the app icon; the menubar app has its own separate icon
(`assets/menubar-logo.png`, converted to `.icns` in `package-app.sh`).
- `directories.output: "release"` — electron-builder's default output dir is
`dist`, which collides with this app's existing `tsc`/`vite` build output
(`app/dist/electron`, `app/dist/renderer`) that `files` reads from. Using
a separate `release/` directory keeps build inputs and packaging outputs apart.
## Verifying a build
```sh
codesign -dv --verbose=2 app/release/mac-arm64/CodeBurn.app
codesign --verify --deep --strict app/release/mac-arm64/CodeBurn.app
```
Expect `Signature=adhoc`, a real `Identifier=org.agentseal.codeburn-desktop`,
and `Sealed Resources` present. The deep-verify command should exit 0.
To smoke-test that the packaged renderer actually loads (the classic failure
is a white screen from a wrong `loadFile` path once assets are behind
`app.asar`), launch the built binary directly and confirm the process tree
stays up and the main process logs no `did-fail-load` errors:
```sh
"app/release/mac-arm64/CodeBurn.app/Contents/MacOS/CodeBurn" --user-data-dir=/tmp/codeburn-smoke
```
A healthy launch spawns `CodeBurn`, `CodeBurn Helper` (gpu-process),
`CodeBurn Helper` (utility/network), and `CodeBurn Helper (Renderer)`
processes and keeps running with no stderr output. `main.ts`'s
`did-fail-load` handler (`console.error('Renderer failed to load ...')`)
prints to that same stderr if the packaged `loadFile(path.join(__dirname,
'..', 'renderer', 'index.html'))` path is ever wrong.
## The Gatekeeper story (no paid Apple Developer account)
Ad-hoc signing satisfies the *kernel's* code-signing requirement (Apple
Silicon refuses to execute anything with no signature at all), but it is not
a Developer ID signature and the app is not notarized. Concretely:
- `spctl --assess --type execute` on the built app returns **`rejected`**,
ad-hoc-signed or not, quarantined or not. `spctl`'s static assessment
checks for a Developer ID + notarization ticket, which this build does
not have and cannot have without a paid account.
- Any file downloaded through a browser (or unzipped by Finder's Archive
Utility from a browser download) gets a `com.apple.quarantine` extended
attribute. The first time a quarantined, non-notarized app is opened,
Gatekeeper blocks a plain double-click with "Apple could not verify that
\[CodeBurn] is free of malware."
- **This is expected and correct for an unpaid, unnotarized build.** Being
a known GitHub author, signing the repo's commits, or ad-hoc signing the
binary does **not** change this — none of that is a substitute for an
Apple-issued Developer ID certificate plus notarization.
### First-open instructions for users
Pick one:
1. **Right-click (or Control-click) the app in Finder → Open → Open** in the
confirmation dialog. This is required only once; subsequent launches work
with a normal double-click.
2. On macOS Ventura and later, if step 1's dialog does not offer an Open
button: **System Settings → Privacy & Security → scroll to Security →
"\[CodeBurn] was blocked..." → Open Anyway**, then confirm in the dialog
that appears on the next open attempt.
3. From the command line, strip the quarantine attribute before first launch
(equivalent effect, no dialog at all):
```sh
xattr -cr /Applications/CodeBurn.app
```
None of these steps are needed for a `dmg`/`zip` built and opened locally on
the same machine (no quarantine attribute is applied to files that were never
downloaded) — they only apply to a build distributed to someone else, e.g.
via a GitHub Release.
## Upgrade path: paid account + notarization
When a paid Apple Developer Program membership is available, the same
`electron-builder` config takes the upgrade with a few changes, no new
tooling:
- Set `mac.identity` to the real `"Developer ID Application: <Name> (<TEAMID>)"`
certificate name (or let electron-builder auto-discover it from the
keychain by removing `identity` entirely), and set `mac.hardenedRuntime:
true` with an entitlements file.
- Add a `notarize` block (or the `afterSign` hook electron-builder's
`@electron/notarize` integration expects) with an app-specific password or
API key, and remove `gatekeeperAssess: false` so electron-builder verifies
the notarized result itself.
- Everything else — `appId`, `files`, `mac.target` (dmg/zip, arm64+x64),
`icon`, `directories.output` — stays as-is.

70
app/README.md Normal file
View file

@ -0,0 +1,70 @@
# CodeBurn Desktop
Electron desktop shell for CodeBurn's local-first usage views. M1 runs as a developer app and reads data by spawning the installed `codeburn` CLI; it does not run a daemon or HTTP server.
## Development
```sh
npm --prefix app install
npm --prefix app run dev
```
Validation:
```sh
npm --prefix app run test
npm --prefix app run typecheck
```
## CLI Dependency
The app depends on a working `codeburn` CLI on the local machine. Electron resolves and spawns the CLI from the main process, then sends decoded JSON through the secure preload bridge into the renderer.
This follows the menubar pattern:
- `contextIsolation: true`, `nodeIntegration: false`, and `sandbox: true`.
- Renderer code calls `window.codeburn` only through `app/renderer/lib/ipc.ts`.
- Main process handlers return JSON envelopes so structured CLI errors survive IPC.
- Missing CLI, bad JSON, timeout, and nonzero exits are surfaced as honest UI states.
- The renderer never imports CodeBurn engine code from `src/`; the data contract is spawn CLI, decode JSON, poll.
## Data Contract
Current bridge calls:
- Overview: `codeburn status --format menubar-json --period <period> [--provider <provider>]`
- Plans: `codeburn status --format json --period <period>`
- Models: `codeburn models --format json --period <period> [--provider <provider>] [--by-task]`
- Optimize: `codeburn yield --format json --period <period>`
- Spend flow: `codeburn spend --format flow-json --period <period> [--provider <provider>]`
- Devices: `codeburn devices --format json --period <period>`
- Device scan: `codeburn devices scan --format json`
- Share status: `codeburn share status --format json`
- Identity: `codeburn identity --format json`
Supported M1 periods are `today`, `week`, `30days`, `month`, and `all`. Provider filtering is passed through where the CLI command supports it.
## Sections
- Overview: daily spend, spend stats, waste summary, and expensive sessions from `menubar-json`.
- Spend: project/activity/tool/MCP/subagent lenses plus model-to-project flow.
- Optimize: waste findings from `menubar-json` and reverted/abandoned yield data.
- Models: model and task tables from `models --format json`.
- Plans: plan pacing from `status --format json`.
- Settings: device identity, nearby scan results, paired-device usage, and M2 visual affordances.
## Packaging
`npm run package` produces an ad-hoc-signed macOS `.dmg`/`.zip` (arm64 and x64) via `electron-builder`, no paid Apple Developer account required. See `DISTRIBUTION.md` for build instructions, artifact locations, and the Gatekeeper first-open story.
## M2 Backlog
- Deliver a self-contained app that bundles the CodeBurn engine and auto-updates itself with Electron `autoUpdater`.
- Ship end-user installs via `.dmg` and `install.sh`; end users should not need npm.
- Keep npm as a separate CLI-user channel at the same version as the desktop app.
- Add macOS code signing with a paid Developer ID and notarization (ad-hoc packaging exists today; see `DISTRIBUTION.md`).
- Add a `codeburn desktop` launcher subcommand.
- Implement in-app pairing, approve, pull, and visibility mutations currently shown as M2 affordances.
- Build the Models Compare sheet.
- Add light theme support.
- Expand `codeburn optimize --format json` with evidence and fix commands so Optimize can show richer actionable fixes.

BIN
app/build/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 441 KiB

213
app/electron/cli.test.ts Normal file
View file

@ -0,0 +1,213 @@
// @vitest-environment node
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, chmodSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { spawnCli, spawnCliAction, killAll, CliError, nodeManagerDirs, resolveCodeburnPath } from './cli'
let dir: string
const originalBin = process.env.CODEBURN_BIN
const originalPathDirs = process.env.CODEBURN_PATH_DIRS
const originalPathFile = process.env.CODEBURN_CLI_PATH_FILE
const originalViteUrl = process.env.VITE_DEV_SERVER_URL
/** Writes an executable node script and points CODEBURN_BIN at it. */
function fakeBin(name: string, body: string): string {
const p = join(dir, name)
writeFileSync(p, `#!/usr/bin/env node\n${body}\n`, { mode: 0o755 })
chmodSync(p, 0o755)
process.env.CODEBURN_BIN = p
return p
}
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'codeburn-cli-'))
})
afterEach(() => {
if (originalBin === undefined) delete process.env.CODEBURN_BIN
else process.env.CODEBURN_BIN = originalBin
if (originalPathDirs === undefined) delete process.env.CODEBURN_PATH_DIRS
else process.env.CODEBURN_PATH_DIRS = originalPathDirs
if (originalPathFile === undefined) delete process.env.CODEBURN_CLI_PATH_FILE
else process.env.CODEBURN_CLI_PATH_FILE = originalPathFile
if (originalViteUrl === undefined) delete process.env.VITE_DEV_SERVER_URL
else process.env.VITE_DEV_SERVER_URL = originalViteUrl
rmSync(dir, { recursive: true, force: true })
})
describe('resolveCodeburnPath (Vite development)', () => {
it('prefers the executable repo dist/cli.js when the Vite dev server is set', () => {
delete process.env.CODEBURN_BIN
process.env.CODEBURN_PATH_DIRS = ''
process.env.CODEBURN_CLI_PATH_FILE = join(dir, 'no-persisted-path')
process.env.VITE_DEV_SERVER_URL = 'http://localhost:5173'
expect(resolveCodeburnPath()).toMatch(/dist\/cli\.js$/)
})
it('prefers the repo dev CLI over a persisted-path file (stale global) in dev', () => {
// A persisted global (e.g. an older Homebrew codeburn) must NOT shadow the
// repo build in dev, or newly-added commands break. Regression: 0.9.15
// lacked `sessions`, so the persisted path produced a CLI error.
delete process.env.CODEBURN_BIN
process.env.CODEBURN_PATH_DIRS = ''
const persistedTarget = join(dir, 'stale-codeburn')
writeFileSync(persistedTarget, '#!/usr/bin/env node\n', { mode: 0o755 })
chmodSync(persistedTarget, 0o755)
const persistedFile = join(dir, 'cli-path.v1')
writeFileSync(persistedFile, persistedTarget)
process.env.CODEBURN_CLI_PATH_FILE = persistedFile
process.env.VITE_DEV_SERVER_URL = 'http://localhost:5173'
const resolved = resolveCodeburnPath()
expect(resolved).toMatch(/dist\/cli\.js$/)
expect(resolved).not.toBe(persistedTarget)
})
it('does not return the repo dev CLI outside the Vite dev server', () => {
delete process.env.CODEBURN_BIN
process.env.CODEBURN_PATH_DIRS = ''
process.env.CODEBURN_CLI_PATH_FILE = join(dir, 'no-persisted-path')
delete process.env.VITE_DEV_SERVER_URL
expect(resolveCodeburnPath()).toBeNull()
})
})
describe('spawnCli', () => {
it('resolves parsed JSON on success', async () => {
fakeBin('ok.js', 'process.stdout.write(JSON.stringify({ ok: 1 }))')
await expect(spawnCli(['status'])).resolves.toEqual({ ok: 1 })
})
it('rejects with kind "nonzero" on a non-zero exit', async () => {
fakeBin('fail.js', 'process.stderr.write("boom"); process.exit(2)')
await expect(spawnCli(['status'])).rejects.toMatchObject({ kind: 'nonzero' } satisfies Partial<CliError>)
})
it('rejects with kind "bad-json" on non-JSON stdout', async () => {
fakeBin('garbage.js', 'process.stdout.write("not json at all")')
await expect(spawnCli(['status'])).rejects.toMatchObject({ kind: 'bad-json' })
})
it('rejects with kind "timeout" when the binary hangs', async () => {
fakeBin('hang.js', 'setInterval(() => {}, 1000)')
await expect(spawnCli(['status'], { timeoutMs: 150 })).rejects.toMatchObject({ kind: 'timeout' })
})
it('rejects with kind "not-found" when no binary resolves', async () => {
delete process.env.CODEBURN_BIN
process.env.CODEBURN_PATH_DIRS = '' // force an empty search space
process.env.CODEBURN_CLI_PATH_FILE = join(dir, 'no-such-persisted-path')
try {
await expect(spawnCli(['status'])).rejects.toMatchObject({ kind: 'not-found' })
} finally {
delete process.env.CODEBURN_PATH_DIRS
delete process.env.CODEBURN_CLI_PATH_FILE
}
})
it('rejects with kind "too-large" and kills a binary that floods stdout', async () => {
fakeBin('flood.js', "const s='x'.repeat(1024*1024); for(let i=0;i<20;i++) process.stdout.write(s); setInterval(()=>{},1000)")
await expect(spawnCli(['status'])).rejects.toMatchObject({ kind: 'too-large' } satisfies Partial<CliError>)
})
})
describe('spawnCli coalescing (read-only)', () => {
it('shares one child between two concurrent identical calls', async () => {
const countFile = join(dir, 'spawns')
fakeBin('counter.js', `require('fs').appendFileSync(${JSON.stringify(countFile)},'x'); process.stdout.write(JSON.stringify({ok:1}))`)
const [a, b] = await Promise.all([spawnCli(['status']), spawnCli(['status'])])
expect(a).toEqual({ ok: 1 })
expect(b).toEqual({ ok: 1 })
expect(readFileSync(countFile, 'utf8')).toBe('x') // exactly one spawn
})
it('spawns again once the 5s result cache has expired', async () => {
vi.useFakeTimers({ toFake: ['Date'] })
try {
const countFile = join(dir, 'spawns')
fakeBin('counter-ttl.js', `require('fs').appendFileSync(${JSON.stringify(countFile)},'x'); process.stdout.write(JSON.stringify({ok:1}))`)
vi.setSystemTime(0)
await spawnCli(['status'])
vi.setSystemTime(6_000)
await spawnCli(['status'])
expect(readFileSync(countFile, 'utf8')).toBe('xx') // cache expired → new spawn
} finally {
vi.useRealTimers()
}
})
it('never coalesces config-mutating action calls', async () => {
const countFile = join(dir, 'spawns')
fakeBin('action-counter.js', `require('fs').appendFileSync(${JSON.stringify(countFile)},'x'); process.stdout.write('done')`)
await Promise.all([spawnCliAction(['currency', 'EUR']), spawnCliAction(['currency', 'EUR'])])
expect(readFileSync(countFile, 'utf8')).toBe('xx') // two independent spawns
})
it('flushes the read cache when an action completes, so post-action refetches are fresh', async () => {
const countFile = join(dir, 'spawns')
fakeBin('mixed.js', `require('fs').appendFileSync(${JSON.stringify(countFile)},'x'); process.stdout.write(JSON.stringify({ok:1}))`)
await spawnCli(['model-alias', '--list']) // primes the 5s cache
await spawnCliAction(['model-alias', 'a', 'b']) // config change → cache flush
await spawnCli(['model-alias', '--list']) // must NOT serve the pre-action cache
expect(readFileSync(countFile, 'utf8')).toBe('xxx')
})
})
describe('killAll', () => {
it('reaps an in-flight child so its promise settles', async () => {
fakeBin('hang-kill.js', 'setInterval(() => {}, 1000)')
const pending = spawnCli(['status'], { timeoutMs: 60_000 })
// Let the child spawn before reaping.
await new Promise(resolve => setTimeout(resolve, 50))
killAll()
await expect(pending).rejects.toMatchObject({ kind: 'nonzero' })
})
})
describe('spawnCliAction', () => {
it('returns stdout and ok:true on success', async () => {
fakeBin('action-ok.js', 'process.stdout.write("currency updated")')
await expect(spawnCliAction(['currency', 'EUR'])).resolves.toEqual({ ok: true, stdout: 'currency updated', stderr: '', code: 0 })
})
it('returns stderr and ok:false on a non-zero exit', async () => {
fakeBin('action-fail.js', 'process.stderr.write("invalid alias"); process.exit(3)')
await expect(spawnCliAction(['model-alias', 'a', 'b'])).resolves.toEqual({ ok: false, stdout: '', stderr: 'invalid alias', code: 3 })
})
})
describe('nodeManagerDirs (nvm resolution)', () => {
const savedNvm = process.env.NVM_DIR
afterEach(() => {
if (savedNvm === undefined) delete process.env.NVM_DIR
else process.env.NVM_DIR = savedNvm
})
it('scans nvm version dirs newest-first and takes the first that holds codeburn', () => {
// Two versions; the lexicographically-"newest" (v9.0.0 > v22.0.0 as strings)
// has NO codeburn, while the real newer v22.0.0 does. The old `sort().reverse()[0]`
// would pick v9.0.0's bin and miss the CLI entirely.
const nvm = mkdtempSync(join(tmpdir(), 'codeburn-nvm-'))
try {
const versions = join(nvm, 'versions', 'node')
const v9bin = join(versions, 'v9.0.0', 'bin')
const v22bin = join(versions, 'v22.0.0', 'bin')
mkdirSync(v9bin, { recursive: true })
mkdirSync(v22bin, { recursive: true })
const codeburn = join(v22bin, 'codeburn')
writeFileSync(codeburn, '#!/bin/sh\n', { mode: 0o755 })
chmodSync(codeburn, 0o755)
process.env.NVM_DIR = nvm
const dirs = nodeManagerDirs()
expect(dirs).toContain(v22bin)
expect(dirs).not.toContain(v9bin)
} finally {
rmSync(nvm, { recursive: true, force: true })
}
})
})

275
app/electron/cli.ts Normal file
View file

@ -0,0 +1,275 @@
import { spawn, type ChildProcess } from 'node:child_process'
import { accessSync, constants, existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
import { homedir, platform } from 'node:os'
import { delimiter, join } from 'node:path'
// Runs entirely in the Electron main process. This module must NOT import
// `electron` so it stays unit-testable in a plain node environment.
export type CliErrorKind = 'not-found' | 'nonzero' | 'bad-json' | 'timeout' | 'too-large' | 'bad-args'
export type ActionResult = { ok: boolean; stdout: string; stderr: string; code: number | null }
/** Structured failure so the renderer can pick the right empty/permission state. */
export class CliError extends Error {
readonly kind: CliErrorKind
constructor(kind: CliErrorKind, message: string) {
super(message)
this.name = 'CliError'
this.kind = kind
}
}
const DEFAULT_TIMEOUT_MS = 45_000
// A runaway CLI (or a compromised binary) must not exhaust main-process memory.
const MAX_OUTPUT_BYTES = 16 * 1024 * 1024
// Same-cadence pollers fire near-identical read spawns; share one child and hold
// its result briefly so six overview hooks don't launch six processes at once.
const COALESCE_TTL_MS = 5_000
// Every live child so `before-quit` can reap them (Electron does not on macOS).
const activeChildren = new Set<ChildProcess>()
const readInflight = new Map<string, Promise<unknown>>()
const readCache = new Map<string, { at: number; value: unknown }>()
/** SIGKILL every in-flight child. Wired to Electron's `before-quit`. */
export function killAll(): void {
for (const child of activeChildren) child.kill('SIGKILL')
activeChildren.clear()
}
// Homebrew + common Node version managers, mirroring mac/CodeburnCLI.swift so a
// GUI-launched app (minimal PATH) still finds a globally-installed `codeburn`.
export function nodeManagerDirs(): string[] {
const home = homedir()
const dirs = [
'/opt/homebrew/bin',
'/usr/local/bin',
join(home, '.volta', 'bin'),
join(home, '.npm-global', 'bin'),
join(home, '.asdf', 'shims'),
]
const nvmDir = process.env.NVM_DIR || join(home, '.nvm')
const nvmVersions = join(nvmDir, 'versions', 'node')
try {
// Scan version dirs newest-first and take the first whose bin actually holds
// `codeburn`. A lexicographic max ("v9" > "v22") is not a real "newest", and
// the top dir may not even contain the CLI — so verify, matching CodeburnCLI.swift.
const entries = readdirSync(nvmVersions).sort().reverse()
for (const entry of entries) {
const bin = join(nvmVersions, entry, 'bin')
if (isExecutableFile(join(bin, 'codeburn'))) {
dirs.push(bin)
break
}
}
} catch {
// no nvm — ignore
}
return dirs
}
/** The dirs searched for a `codeburn` executable. `CODEBURN_PATH_DIRS` overrides
* the whole search space (delimiter-separated) used by tests and advanced setups. */
function searchDirs(): string[] {
const override = process.env.CODEBURN_PATH_DIRS
if (override !== undefined) return override.split(delimiter).filter(Boolean)
const pathDirs = (process.env.PATH || '').split(delimiter).filter(Boolean)
return [...pathDirs, ...nodeManagerDirs()]
}
function isExecutableFile(p: string): boolean {
try {
if (!statSync(p).isFile()) return false
accessSync(p, constants.X_OK)
return true
} catch {
return false
}
}
// Persisted-path file written by the (future) first-run "locate CLI" flow,
// mirroring the mac app's Application Support/CodeBurn/codeburn-cli-path.v1.
function persistedPathFile(): string {
const override = process.env.CODEBURN_CLI_PATH_FILE
if (override) return override
const home = homedir()
if (platform() === 'darwin') {
return join(home, 'Library', 'Application Support', 'CodeBurn', 'codeburn-cli-path.v1')
}
const base = process.env.XDG_CONFIG_HOME || join(home, '.config')
return join(base, 'CodeBurn', 'codeburn-cli-path.v1')
}
function readPersistedPath(): string | null {
try {
const file = persistedPathFile()
if (!existsSync(file)) return null
const value = readFileSync(file, 'utf-8').trim()
if (value && value.startsWith('/') && isExecutableFile(value)) return value
} catch {
// unreadable — fall through to PATH search
}
return null
}
/**
* Resolve the absolute path to the `codeburn` binary, or null if not found.
* Order: dev override (`CODEBURN_BIN`) repo CLI in Vite development
* persisted-path file PATH / brew / nvm / volta / asdf null.
*
* The dev repo CLI intentionally beats the persisted-path file: in `npm run
* dev` the developer is iterating on this repo, so its freshly-built
* `dist/cli.js` must win over a stale globally-installed/persisted binary
* (which may lack newly-added commands). Setting `CODEBURN_BIN` still overrides
* everything. In production `VITE_DEV_SERVER_URL` is unset, so the persisted
* path behaves exactly as before.
*/
export function resolveCodeburnPath(): string | null {
const override = process.env.CODEBURN_BIN
if (override && override.startsWith('/') && isExecutableFile(override)) return override
// Dev convenience: when launched by the Vite dev server, prefer the repo's own
// freshly-built CLI over a stale globally-installed/persisted one, so
// newly-added commands (sessions/compare/act JSON) work without CODEBURN_BIN.
if (process.env.VITE_DEV_SERVER_URL) {
const devBin = join(__dirname, '..', '..', '..', 'dist', 'cli.js')
if (isExecutableFile(devBin)) return devBin
// Vitest loads this source module from app/electron rather than the emitted
// app/dist/electron directory; keep the same repo CLI discoverable there.
const sourceDevBin = join(__dirname, '..', '..', 'dist', 'cli.js')
if (isExecutableFile(sourceDevBin)) return sourceDevBin
}
const persisted = readPersistedPath()
if (persisted) return persisted
for (const bin of searchDirs().map(dir => join(dir, 'codeburn'))) {
if (isExecutableFile(bin)) return bin
}
return null
}
function runCli(bin: string, args: string[], timeoutMs: number): Promise<unknown> {
return new Promise<unknown>((resolve, reject) => {
const child = spawn(bin, args, { shell: false, stdio: ['ignore', 'pipe', 'pipe'] })
activeChildren.add(child)
let stdout = ''
let stderr = ''
let total = 0
let settled = false
const finish = (fn: () => void) => {
if (settled) return
settled = true
clearTimeout(timer)
activeChildren.delete(child)
fn()
}
const timer = setTimeout(() => {
finish(() => {
child.kill('SIGKILL')
reject(new CliError('timeout', `codeburn ${args[0] ?? ''} timed out after ${timeoutMs}ms`))
})
}, timeoutMs)
const bump = (n: number) => {
total += n
if (total > MAX_OUTPUT_BYTES) {
finish(() => {
child.kill('SIGKILL')
reject(new CliError('too-large', `codeburn ${args[0] ?? ''} produced more than ${MAX_OUTPUT_BYTES} bytes`))
})
}
}
child.stdout.on('data', chunk => { stdout += chunk; bump(chunk.length) })
child.stderr.on('data', chunk => { stderr += chunk; bump(chunk.length) })
child.on('error', err => {
finish(() => reject(new CliError('not-found', err.message)))
})
child.on('close', code => {
finish(() => {
if (code !== 0) {
reject(new CliError('nonzero', stderr.trim() || `codeburn exited with code ${code}`))
return
}
try {
resolve(JSON.parse(stdout))
} catch {
reject(new CliError('bad-json', 'codeburn produced output that was not valid JSON'))
}
})
})
})
}
/**
* Spawn `codeburn <args>` with plain argv (never a shell), collect stdout, and
* decode it as JSON. Rejects with a structured {@link CliError}:
* not-found no binary resolved
* nonzero process exited with a non-zero code (stderr surfaced)
* bad-json stdout was not valid JSON
* timeout the process was killed after `timeoutMs`
* too-large stdout+stderr exceeded {@link MAX_OUTPUT_BYTES}
*
* Read-only, so concurrent identical calls share one child and a 5s result cache
* absorbs same-cadence pollers. Never use this for config-mutating commands.
*/
export function spawnCli(args: string[], opts: { timeoutMs?: number } = {}): Promise<unknown> {
const bin = resolveCodeburnPath()
if (!bin) return Promise.reject(new CliError('not-found', 'codeburn CLI not found'))
const key = JSON.stringify([bin, ...args])
const cached = readCache.get(key)
if (cached && Date.now() - cached.at < COALESCE_TTL_MS) return Promise.resolve(cached.value)
const existing = readInflight.get(key)
if (existing) return existing
const flight = runCli(bin, args, opts.timeoutMs ?? DEFAULT_TIMEOUT_MS)
.then(value => { readCache.set(key, { at: Date.now(), value }); return value })
.finally(() => { readInflight.delete(key) })
readInflight.set(key, flight)
return flight
}
/** Spawn a config-mutating CLI command and return its text output verbatim. */
export function spawnCliAction(args: string[], opts: { timeoutMs?: number } = {}): Promise<ActionResult> {
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS
return new Promise<ActionResult>(resolve => {
const bin = resolveCodeburnPath()
if (!bin) {
resolve({ ok: false, stdout: '', stderr: 'codeburn CLI not found', code: null })
return
}
const child = spawn(bin, args, { shell: false, stdio: ['ignore', 'pipe', 'pipe'] })
activeChildren.add(child)
let stdout = ''
let stderr = ''
let settled = false
const finish = (result: ActionResult) => {
if (settled) return
settled = true
clearTimeout(timer)
activeChildren.delete(child)
// The action may have changed config the read cache still reflects; a
// Settings refetch fires immediately after, so serve it fresh data.
readCache.clear()
resolve(result)
}
const timer = setTimeout(() => {
child.kill('SIGKILL')
finish({ ok: false, stdout, stderr: `codeburn ${args[0] ?? ''} timed out after ${timeoutMs}ms`, code: null })
}, timeoutMs)
child.stdout.on('data', chunk => { stdout += chunk })
child.stderr.on('data', chunk => { stderr += chunk })
child.on('error', err => finish({ ok: false, stdout, stderr: err.message, code: null }))
child.on('close', code => finish({ ok: code === 0, stdout, stderr, code }))
})
}

270
app/electron/main.test.ts Normal file
View file

@ -0,0 +1,270 @@
// @vitest-environment node
import { describe, it, expect, vi } from 'vitest'
// Stub electron so importing main.ts does not require an Electron runtime.
vi.mock('electron', () => ({
app: { name: 'CodeBurn', whenReady: () => Promise.resolve(), on: () => {}, quit: () => {} },
BrowserWindow: class {},
dialog: { showOpenDialog: vi.fn() },
ipcMain: { handle: () => {} },
Menu: { buildFromTemplate: (template: unknown) => template, setApplicationMenu: () => {} },
shell: { openExternal: vi.fn() },
}))
import { createApplicationMenuTemplate, createBridgeHandlers } from './main'
import { CliError } from './cli'
function fakeSpawn(result: unknown = { current: { cost: 12.34 } }) {
const calls: string[][] = []
const spawnCli = vi.fn(async (args: string[]) => {
calls.push(args)
return result
})
const spawnCliAction = vi.fn(async (args: string[]) => {
calls.push(args)
return { ok: true, stdout: 'updated', stderr: '', code: 0 }
})
return { spawnCli, spawnCliAction, calls }
}
// Every codeburn:* channel with a representative arg tuple → the exact argv it
// must spawn. cliStatus is the one channel that resolves without spawning.
const CHANNELS = [
'codeburn:getOverview',
'codeburn:getQuota',
'codeburn:getPlans',
'codeburn:getActReport',
'codeburn:getModels',
'codeburn:getSessions',
'codeburn:getCompareModels',
'codeburn:getCompare',
'codeburn:getYield',
'codeburn:getSpendFlow',
'codeburn:getOptimizeReport',
'codeburn:getDevices',
'codeburn:getDevicesScan',
'codeburn:getShareStatus',
'codeburn:getIdentity',
'codeburn:getAliases',
'codeburn:getProxyPaths',
'codeburn:getAudit',
'codeburn:getPriceOverrides',
'codeburn:setCurrency',
'codeburn:resetCurrency',
'codeburn:addAlias',
'codeburn:removeAlias',
'codeburn:setPriceOverride',
'codeburn:removePriceOverride',
'codeburn:removeDevice',
'codeburn:setPlan',
'codeburn:resetPlan',
'codeburn:exportData',
'codeburn:cliStatus',
] as const
const ARGV_CASES: Array<{ channel: string; args: unknown[]; argv: string[] }> = [
{ channel: 'codeburn:getOverview', args: ['30days', 'claude'], argv: ['status', '--format', 'menubar-json', '--period', '30days', '--provider', 'claude'] },
{ channel: 'codeburn:getOverview', args: ['30days', 'all'], argv: ['status', '--format', 'menubar-json', '--period', '30days'] },
{ channel: 'codeburn:getPlans', args: ['week'], argv: ['status', '--format', 'json', '--period', 'week'] },
{ channel: 'codeburn:getActReport', args: [], argv: ['act', 'report', '--json'] },
{ channel: 'codeburn:getModels', args: ['week', 'claude', true], argv: ['models', '--format', 'json', '--period', 'week', '--provider', 'claude', '--by-task'] },
{ channel: 'codeburn:getModels', args: ['week', 'all', false], argv: ['models', '--format', 'json', '--period', 'week'] },
{ channel: 'codeburn:getSessions', args: ['week', 'all'], argv: ['sessions', '--format', 'json', '--period', 'week'] },
{ channel: 'codeburn:getSessions', args: ['30days', 'claude', { from: '2026-07-01', to: '2026-07-11' }], argv: ['sessions', '--format', 'json', '--period', '30days', '--provider', 'claude', '--from', '2026-07-01', '--to', '2026-07-11'] },
{ channel: 'codeburn:getCompareModels', args: ['month', 'codex'], argv: ['compare', '--format', 'json', '--period', 'month', '--provider', 'codex'] },
{ channel: 'codeburn:getCompare', args: ['month', 'all', 'model-a', 'model-b'], argv: ['compare', '--format', 'json', '--period', 'month', '--model-a', 'model-a', '--model-b', 'model-b'] },
{ channel: 'codeburn:getYield', args: ['today', 'all'], argv: ['yield', '--format', 'json', '--period', 'today'] },
{ channel: 'codeburn:getYield', args: ['today', 'claude'], argv: ['yield', '--format', 'json', '--period', 'today', '--provider', 'claude'] },
{ channel: 'codeburn:getSpendFlow', args: ['month', 'openai'], argv: ['spend', '--format', 'flow-json', '--period', 'month', '--provider', 'openai'] },
{ channel: 'codeburn:getOptimizeReport', args: ['month', 'openai'], argv: ['optimize', '--format', 'json', '--period', 'month', '--provider', 'openai'] },
{ channel: 'codeburn:getOverview', args: ['30days', 'all', { from: '2026-07-01', to: '2026-07-11' }], argv: ['status', '--format', 'menubar-json', '--period', '30days', '--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', '--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', '--provider', 'claude', '--from', '2026-07-01', '--to', '2026-07-11', '--claude-config-source', 'claude-desktop:980e1e488a654830'] },
{ 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'] },
{ channel: 'codeburn:getOptimizeReport', args: ['month', 'all', { from: '2026-07-01', to: '2026-07-11' }], argv: ['optimize', '--format', 'json', '--period', 'month', '--from', '2026-07-01', '--to', '2026-07-11'] },
{ channel: 'codeburn:getDevices', args: ['week'], argv: ['devices', '--format', 'json', '--period', 'week'] },
{ channel: 'codeburn:getDevicesScan', args: [], argv: ['devices', 'scan', '--format', 'json'] },
{ channel: 'codeburn:getShareStatus', args: [], argv: ['share', 'status', '--format', 'json'] },
{ channel: 'codeburn:getIdentity', args: [], argv: ['identity', '--format', 'json'] },
{ channel: 'codeburn:getAliases', args: [], argv: ['model-alias', '--list', '--format', 'json'] },
{ channel: 'codeburn:getProxyPaths', args: [], argv: ['proxy-path', '--list', '--format', 'json'] },
{ channel: 'codeburn:getAudit', args: ['month', 'claude'], argv: ['audit', '--format', 'json', '--period', 'month', '--provider', 'claude'] },
{ channel: 'codeburn:getAudit', args: ['30days', 'all', { from: '2026-07-01', to: '2026-07-11' }], argv: ['audit', '--format', 'json', '--period', '30days', '--from', '2026-07-01', '--to', '2026-07-11'] },
{ channel: 'codeburn:getPriceOverrides', args: [], argv: ['price-override', '--list', '--format', 'json'] },
{ channel: 'codeburn:setPriceOverride', args: ['unpriced/test-model', { input: 0.27, output: 1.1 }], argv: ['price-override', 'unpriced/test-model', '--input', '0.27', '--output', '1.1'] },
{ channel: 'codeburn:setPriceOverride', args: ['unpriced/test-model', { input: 0.27, output: 1.1, cacheRead: 0.03, cacheCreation: 0.42 }], argv: ['price-override', 'unpriced/test-model', '--input', '0.27', '--output', '1.1', '--cache-read', '0.03', '--cache-creation', '0.42'] },
{ channel: 'codeburn:removePriceOverride', args: ['unpriced/test-model'], argv: ['price-override', '--remove', 'unpriced/test-model'] },
{ channel: 'codeburn:setCurrency', args: ['EUR'], argv: ['currency', 'EUR'] },
{ channel: 'codeburn:resetCurrency', args: [], argv: ['currency', '--reset'] },
{ channel: 'codeburn:addAlias', args: ['unknown-model', 'priced-model'], argv: ['model-alias', 'unknown-model', 'priced-model'] },
{ channel: 'codeburn:removeAlias', args: ['unknown-model'], argv: ['model-alias', '--remove', 'unknown-model'] },
{ channel: 'codeburn:removeDevice', args: ['studio-mac'], argv: ['devices', 'rm', 'studio-mac'] },
{ channel: 'codeburn:setPlan', args: ['claude-max', 'claude'], argv: ['plan', 'set', 'claude-max', '--provider', 'claude'] },
{ channel: 'codeburn:resetPlan', args: ['cursor'], argv: ['plan', 'reset', '--provider', 'cursor'] },
{ channel: 'codeburn:exportData', args: ['json', 'all', '/tmp/codeburn-export'], argv: ['export', '-f', 'json', '-o', '/tmp/codeburn-export', '--provider', 'all'] },
]
function flattenMenuItems(items: any[]): any[] {
return items.flatMap(item => {
const submenu = Array.isArray(item.submenu) ? flattenMenuItems(item.submenu) : []
return [item, ...submenu]
})
}
describe('createBridgeHandlers (channel → argv for all channels)', () => {
const deps = (extra = {}) => ({ spawnCli: vi.fn(), spawnCliAction: vi.fn(), resolveCodeburnPath: () => null, getQuota: vi.fn(async () => []), ...extra })
it('exposes exactly the bridge channels', () => {
const handlers = createBridgeHandlers(deps())
expect(Object.keys(handlers).sort()).toEqual([...CHANNELS].sort())
})
it.each(ARGV_CASES)('$channel with $args spawns the expected argv', async ({ channel, args, argv }) => {
const { spawnCli, spawnCliAction, calls } = fakeSpawn()
const handlers = createBridgeHandlers(deps({ spawnCli, spawnCliAction, resolveCodeburnPath: () => '/bin/codeburn' }))
const res = await handlers[channel]!(...args)
expect(calls[0]).toEqual(argv)
expect(res).toMatchObject({ ok: true })
})
it('codeburn:cliStatus resolves from resolveCodeburnPath without spawning', async () => {
const spawnCli = vi.fn()
const handlers = createBridgeHandlers(deps({ spawnCli, resolveCodeburnPath: () => '/opt/homebrew/bin/codeburn' }))
const res = await handlers['codeburn:cliStatus']!()
expect(spawnCli).not.toHaveBeenCalled()
expect(res).toEqual({ ok: true, value: { found: true, path: '/opt/homebrew/bin/codeburn' } })
})
})
describe('createBridgeHandlers (IPC wiring)', () => {
const withQuota = <T extends object>(value: T) => ({ ...value, getQuota: vi.fn(async () => []) })
it('returns normalized quota through its own IPC channel and sanitizes unexpected failures', async () => {
const base = { spawnCli: vi.fn(), spawnCliAction: vi.fn(), resolveCodeburnPath: () => null }
const value = [{ provider: 'claude' as const, connection: 'connected' as const, primary: null, details: [], planLabel: 'Pro', footerLines: [] }]
const ok = createBridgeHandlers({ ...base, getQuota: vi.fn(async () => value) })
expect(await ok['codeburn:getQuota']!()).toEqual({ ok: true, value })
const failed = createBridgeHandlers({ ...base, getQuota: vi.fn(async () => { throw new Error('Bearer secret sk-ant-leak') }) })
const result = await failed['codeburn:getQuota']!()
expect(result).toMatchObject({ ok: false, error: { kind: 'nonzero' } })
expect(JSON.stringify(result)).not.toMatch(/secret|sk-ant-leak/)
})
it('getOverview spawns menubar-json for the period, omitting --provider for "all"', async () => {
const { spawnCli, spawnCliAction, calls } = fakeSpawn()
const handlers = createBridgeHandlers(withQuota({ spawnCli, spawnCliAction, resolveCodeburnPath: () => '/bin/codeburn' }))
const res = await handlers['codeburn:getOverview']!('30days', 'all')
expect(calls[0]).toEqual(['status', '--format', 'menubar-json', '--period', '30days'])
expect(res).toEqual({ ok: true, value: { current: { cost: 12.34 } } })
})
it('adds --provider and --by-task when requested', async () => {
const { spawnCli, spawnCliAction, calls } = fakeSpawn([])
const handlers = createBridgeHandlers(withQuota({ spawnCli, spawnCliAction, resolveCodeburnPath: () => null }))
await handlers['codeburn:getModels']!('week', 'claude', true)
expect(calls[0]).toEqual(['models', '--format', 'json', '--period', 'week', '--provider', 'claude', '--by-task'])
})
it('returns an error envelope carrying the CliError kind', async () => {
const spawnCli = vi.fn(async () => {
throw new CliError('nonzero', 'boom')
})
const handlers = createBridgeHandlers(withQuota({ spawnCli, spawnCliAction: vi.fn(), resolveCodeburnPath: () => '/bin/codeburn' }))
const res = await handlers['codeburn:getYield']!('today', 'all')
expect(res).toEqual({ ok: false, error: { kind: 'nonzero', message: 'boom' } })
})
it('cliStatus reports the resolved binary path', async () => {
const handlers = createBridgeHandlers(withQuota({
spawnCli: vi.fn(),
spawnCliAction: vi.fn(),
resolveCodeburnPath: () => '/opt/homebrew/bin/codeburn',
}))
const res = await handlers['codeburn:cliStatus']!()
expect(res).toEqual({ ok: true, value: { found: true, path: '/opt/homebrew/bin/codeburn' } })
})
})
describe('createBridgeHandlers (IPC input validation)', () => {
const withQuota = <T extends object>(value: T) => ({ ...value, getQuota: vi.fn(async () => []) })
const REJECTIONS: Array<{ name: string; channel: string; args: unknown[] }> = [
{ name: 'unknown period', channel: 'codeburn:getOverview', args: ['yesterday', 'all'] },
{ name: 'provider with shell metacharacters', channel: 'codeburn:getOverview', args: ['30days', 'claude; rm -rf'] },
{ name: 'uppercase provider', channel: 'codeburn:getModels', args: ['week', 'Claude', false] },
{ name: 'malformed date range', channel: 'codeburn:getYield', args: ['today', 'all', { from: '2026/07/01', to: '2026-07-11' }] },
{ name: 'lowercase currency code', channel: 'codeburn:setCurrency', args: ['eur'] },
{ name: 'alias token that looks like a flag', channel: 'codeburn:addAlias', args: ['--evil', 'safe'] },
{ name: 'device name that looks like a flag', channel: 'codeburn:removeDevice', args: ['-rf'] },
{ name: 'relative export path', channel: 'codeburn:exportData', args: ['json', 'all', 'relative/out'] },
{ name: 'compare model that looks like a flag', channel: 'codeburn:getCompare', args: ['month', 'all', '-a', 'model-b'] },
{ name: 'price override model that looks like a flag', channel: 'codeburn:setPriceOverride', args: ['-x', { input: 1, output: 2 }] },
{ name: 'non-positive price override rate', channel: 'codeburn:setPriceOverride', args: ['my-model', { input: 0, output: 2 }] },
{ name: 'non-finite price override rate', channel: 'codeburn:setPriceOverride', args: ['my-model', { input: 1, output: Number.POSITIVE_INFINITY }] },
{ 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'] },
]
it.each(REJECTIONS)('rejects $name with a bad-args envelope and never spawns', async ({ channel, args }) => {
const { spawnCli, spawnCliAction } = fakeSpawn()
const handlers = createBridgeHandlers(withQuota({ spawnCli, spawnCliAction, resolveCodeburnPath: () => '/bin/codeburn' }))
const res = await handlers[channel]!(...args)
expect(res).toMatchObject({ ok: false, error: { kind: 'bad-args' } })
expect(spawnCli).not.toHaveBeenCalled()
expect(spawnCliAction).not.toHaveBeenCalled()
})
it('still accepts the valid values those cases mutate', async () => {
const { spawnCli, spawnCliAction, calls } = fakeSpawn()
const handlers = createBridgeHandlers(withQuota({ spawnCli, spawnCliAction, resolveCodeburnPath: () => '/bin/codeburn' }))
await handlers['codeburn:exportData']!('json', 'all', '/tmp/out')
expect(calls[0]).toEqual(['export', '-f', 'json', '-o', '/tmp/out', '--provider', 'all'])
})
})
describe('createBridgeHandlers (quota force + redaction)', () => {
it('threads the renderer force flag into getQuota', async () => {
const base = { spawnCli: vi.fn(), spawnCliAction: vi.fn(), resolveCodeburnPath: () => null }
const getQuota = vi.fn(async () => [])
const handlers = createBridgeHandlers({ ...base, getQuota })
await handlers['codeburn:getQuota']!(true)
expect(getQuota).toHaveBeenLastCalledWith({ force: true })
await handlers['codeburn:getQuota']!()
expect(getQuota).toHaveBeenLastCalledWith({ force: false })
})
it('redacts secrets in ActionResult.stderr before it crosses IPC', async () => {
const spawnCliAction = vi.fn(async () => ({ ok: false, stdout: '', stderr: 'auth failed: Bearer sk-ant-leak12345', code: 1 }))
const handlers = createBridgeHandlers({ spawnCli: vi.fn(), spawnCliAction, resolveCodeburnPath: () => '/bin/codeburn', getQuota: vi.fn(async () => []) })
const res = await handlers['codeburn:setCurrency']!('EUR') as { ok: true; value: { stderr: string } }
expect(res.ok).toBe(true)
expect(res.value.stderr).not.toMatch(/sk-ant-leak|Bearer sk-ant/)
expect(res.value.stderr).toContain('[REDACTED]')
})
})
describe('createApplicationMenuTemplate', () => {
it('keeps normal app roles while leaving CmdOrCtrl+R for renderer refresh', () => {
const items = flattenMenuItems(createApplicationMenuTemplate(false))
const roles = items.map(item => item.role).filter(Boolean)
const accelerators = items.map(item => item.accelerator).filter(Boolean)
expect(roles).toContain('copy')
expect(roles).toContain('paste')
expect(roles).toContain('quit')
expect(roles).toContain('minimize')
expect(roles).toContain('close')
expect(roles).not.toContain('reload')
expect(roles).not.toContain('forceReload')
expect(accelerators).not.toContain('CmdOrCtrl+R')
expect(accelerators).not.toContain('CommandOrControl+R')
})
it('keeps DevTools available in dev without adding reload menu items', () => {
const roles = flattenMenuItems(createApplicationMenuTemplate(true)).map(item => item.role).filter(Boolean)
expect(roles).toContain('toggleDevTools')
expect(roles).not.toContain('reload')
expect(roles).not.toContain('forceReload')
})
})

344
app/electron/main.ts Normal file
View file

@ -0,0 +1,344 @@
import { app, BrowserWindow, dialog, ipcMain, Menu, nativeTheme, shell, type MenuItemConstructorOptions } from 'electron'
import path from 'node:path'
import { CliError, killAll, resolveCodeburnPath, spawnCli, spawnCliAction, type ActionResult } from './cli'
import { getQuota, sanitizeError } from './quota'
// Result envelope: handlers never throw across IPC so the structured error
// `kind` survives contextBridge serialization. preload.ts unwraps it.
export type Envelope<T = unknown> = { ok: true; value: T } | { ok: false; error: { kind: string; message: string } }
function providerArgs(provider: string | undefined): string[] {
return provider && provider !== 'all' ? ['--provider', provider] : []
}
type DateRange = { from: string; to: string }
function rangeArgs(range: DateRange | undefined): string[] {
return range ? ['--from', range.from, '--to', range.to] : []
}
function configSourceArgs(source: string | null): string[] {
return source ? ['--claude-config-source', source] : []
}
// Renderer-supplied strings become argv, so reject anything that could smuggle a
// flag or shell metacharacter before it reaches the CLI. Thrown from the argv
// builders, these surface through the same error envelope as any CliError.
const PERIODS = new Set(['today', 'week', '30days', 'month', 'all'])
function vPeriod(period: string): string {
if (!PERIODS.has(period)) throw new CliError('bad-args', 'invalid period')
return period
}
function vProvider(provider: string): string {
if (!/^[a-z0-9-]+$/.test(provider)) throw new CliError('bad-args', 'invalid provider')
return provider
}
function vRange(range: DateRange | undefined): DateRange | undefined {
if (range && (!/^\d{4}-\d{2}-\d{2}$/.test(range.from) || !/^\d{4}-\d{2}-\d{2}$/.test(range.to))) {
throw new CliError('bad-args', 'invalid date range')
}
return range
}
function vCurrency(code: string): string {
if (!/^[A-Z]{3}$/.test(code)) throw new CliError('bad-args', 'invalid currency code')
return code
}
/** model/alias/device/plan tokens: must not be read as a CLI flag. */
function vToken(value: string): string {
if (value.startsWith('-')) throw new CliError('bad-args', 'argument must not start with "-"')
return value
}
// Claude config source ids are `<kind>:<hex>` (src/providers/claude.ts) — the
// colon is part of the real value, so the token class allows it while anchoring
// the first char to alphanumeric so a leading "-" can never smuggle a flag.
function vConfigSource(source: string | null | undefined): string | null {
if (source == null) return null
if (!/^[A-Za-z0-9][A-Za-z0-9:_-]*$/.test(source)) throw new CliError('bad-args', 'invalid claude config source')
return source
}
function vOutPath(outPath: string): string {
if (outPath.startsWith('-') || !path.isAbsolute(outPath)) throw new CliError('bad-args', 'export path must be absolute')
return outPath
}
// Price-override rates are USD per 1M tokens: every provided rate must be a
// finite, strictly positive number before it becomes a CLI value.
type PriceRates = { input?: number; output?: number; cacheRead?: number; cacheCreation?: number }
function rateArg(flag: string, value: number | undefined): string[] {
if (value === undefined) return []
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) throw new CliError('bad-args', 'rate must be a positive number')
return [flag, String(value)]
}
function priceOverrideArgs(model: string, rates: PriceRates | undefined): string[] {
const r = rates ?? {}
return [
'price-override', vToken(model),
...rateArg('--input', r.input),
...rateArg('--output', r.output),
...rateArg('--cache-read', r.cacheRead),
...rateArg('--cache-creation', r.cacheCreation),
]
}
function toEnvelopeError(err: unknown): { kind: string; message: string } {
if (err instanceof CliError) return { kind: err.kind, message: sanitizeError(err.message) }
return { kind: 'nonzero', message: sanitizeError(err instanceof Error ? err.message : String(err)) }
}
type Deps = {
spawnCli: (args: string[], opts?: { timeoutMs?: number }) => Promise<unknown>
spawnCliAction: (args: string[], opts?: { timeoutMs?: number }) => Promise<ActionResult>
resolveCodeburnPath: () => string | null
getQuota: typeof getQuota
}
type Handler = (...args: any[]) => Promise<Envelope>
/**
* Maps every CodeburnBridge channel to its `codeburn` argv (plain args, no
* shell) and returns a result envelope. Pure + injectable so the wiring is
* unit-testable without launching Electron.
*/
export function createBridgeHandlers(deps: Deps = { spawnCli, spawnCliAction, resolveCodeburnPath, getQuota }): Record<string, Handler> {
const run = (build: (...args: any[]) => string[]): Handler => async (...args: any[]) => {
try {
return { ok: true, value: await deps.spawnCli(build(...args)) }
} catch (err) {
return { ok: false, error: toEnvelopeError(err) }
}
}
const runAction = (build: (...args: any[]) => string[]): Handler => async (...args: any[]) => {
try {
const result = await deps.spawnCliAction(build(...args))
return { ok: true, value: { ...result, stderr: sanitizeError(result.stderr) } }
} catch (err) {
return { ok: false, error: toEnvelopeError(err) }
}
}
return {
'codeburn:getQuota': async (force?: boolean) => {
try { return { ok: true, value: await deps.getQuota({ force: Boolean(force) }) } }
catch (error) { return { ok: false, error: { kind: 'nonzero', message: sanitizeError(error) } } }
},
'codeburn:getOverview': run((period: string, provider: string, range?: DateRange, configSource?: string | null) => [
'status', '--format', 'menubar-json', '--period', vPeriod(period), ...providerArgs(vProvider(provider)), ...rangeArgs(vRange(range)), ...configSourceArgs(vConfigSource(configSource)),
]),
'codeburn:getPlans': run((period: string) => ['status', '--format', 'json', '--period', vPeriod(period)]),
'codeburn:getActReport': run(() => ['act', 'report', '--json']),
'codeburn:getModels': run((period: string, provider: string, byTask: boolean, range?: DateRange) => [
'models', '--format', 'json', '--period', vPeriod(period), ...providerArgs(vProvider(provider)), ...(byTask ? ['--by-task'] : []), ...rangeArgs(vRange(range)),
]),
'codeburn:getSessions': run((period: string, provider: string, range?: DateRange) => [
'sessions', '--format', 'json', '--period', vPeriod(period), ...providerArgs(vProvider(provider)), ...rangeArgs(vRange(range)),
]),
'codeburn:getCompareModels': run((period: string, provider: string) => [
'compare', '--format', 'json', '--period', vPeriod(period), ...providerArgs(vProvider(provider)),
]),
'codeburn:getCompare': run((period: string, provider: string, modelA: string, modelB: string) => [
'compare', '--format', 'json', '--period', vPeriod(period), ...providerArgs(vProvider(provider)), '--model-a', vToken(modelA), '--model-b', vToken(modelB),
]),
'codeburn:getYield': run((period: string, provider: string, range?: DateRange) => [
'yield', '--format', 'json', '--period', vPeriod(period), ...providerArgs(vProvider(provider)), ...rangeArgs(vRange(range)),
]),
'codeburn:getSpendFlow': run((period: string, provider: string, range?: DateRange) => [
'spend', '--format', 'flow-json', '--period', vPeriod(period), ...providerArgs(vProvider(provider)), ...rangeArgs(vRange(range)),
]),
'codeburn:getOptimizeReport': run((period: string, provider: string, range?: DateRange) => [
'optimize', '--format', 'json', '--period', vPeriod(period), ...providerArgs(vProvider(provider)), ...rangeArgs(vRange(range)),
]),
'codeburn:getDevices': run((period: string) => ['devices', '--format', 'json', '--period', vPeriod(period)]),
'codeburn:getDevicesScan': run(() => ['devices', 'scan', '--format', 'json']),
'codeburn:getShareStatus': run(() => ['share', 'status', '--format', 'json']),
'codeburn:getIdentity': run(() => ['identity', '--format', 'json']),
'codeburn:getAliases': run(() => ['model-alias', '--list', '--format', 'json']),
'codeburn:getProxyPaths': run(() => ['proxy-path', '--list', '--format', 'json']),
'codeburn:getAudit': run((period: string, provider: string, range?: DateRange) => [
'audit', '--format', 'json', '--period', vPeriod(period), ...providerArgs(vProvider(provider)), ...rangeArgs(vRange(range)),
]),
'codeburn:getPriceOverrides': run(() => ['price-override', '--list', '--format', 'json']),
'codeburn:setCurrency': runAction((code: string) => ['currency', vCurrency(code)]),
'codeburn:resetCurrency': runAction(() => ['currency', '--reset']),
'codeburn:addAlias': runAction((from: string, to: string) => ['model-alias', vToken(from), vToken(to)]),
'codeburn:removeAlias': runAction((from: string) => ['model-alias', '--remove', vToken(from)]),
'codeburn:setPriceOverride': runAction((model: string, rates: PriceRates) => priceOverrideArgs(model, rates)),
'codeburn:removePriceOverride': runAction((model: string) => ['price-override', '--remove', vToken(model)]),
'codeburn:removeDevice': runAction((name: string) => ['devices', 'rm', vToken(name)]),
'codeburn:setPlan': runAction((id: string, provider: string) => ['plan', 'set', vToken(id), '--provider', vProvider(provider)]),
'codeburn:resetPlan': runAction((provider: string) => ['plan', 'reset', '--provider', vProvider(provider)]),
'codeburn:exportData': runAction((format: string, provider: string, outPath: string) => [
'export', '-f', vToken(format), '-o', vOutPath(outPath), '--provider', vProvider(provider),
]),
'codeburn:cliStatus': async () => {
const p = deps.resolveCodeburnPath()
return { ok: true, value: { found: p !== null, path: p } }
},
}
}
function registerHandlers(): void {
const handlers = createBridgeHandlers()
for (const [channel, handler] of Object.entries(handlers)) {
ipcMain.handle(channel, (_event, ...args) => handler(...args))
}
ipcMain.handle('codeburn:chooseDirectory', async () => {
const res = await dialog.showOpenDialog({ properties: ['openDirectory', 'createDirectory'] })
return { ok: true, value: res.canceled ? null : (res.filePaths[0] ?? null) }
})
ipcMain.handle('open-external', (_event, url: string) => {
try {
const { protocol } = new URL(url)
if (protocol === 'https:' || protocol === 'http:') return shell.openExternal(url)
} catch { /* malformed URL — refuse to open */ }
return
})
}
export function createApplicationMenuTemplate(isDev = Boolean(process.env.VITE_DEV_SERVER_URL)): MenuItemConstructorOptions[] {
const template: MenuItemConstructorOptions[] = []
if (process.platform === 'darwin') {
template.push({
label: app.name,
submenu: [
{ role: 'about' },
{ type: 'separator' },
{ role: 'services' },
{ type: 'separator' },
{ role: 'hide' },
{ role: 'hideOthers' },
{ role: 'unhide' },
{ type: 'separator' },
{ role: 'quit' },
],
})
} else {
template.push({
label: 'File',
submenu: [{ role: 'quit' }],
})
}
template.push(
{
label: 'Edit',
submenu: [
{ role: 'undo' },
{ role: 'redo' },
{ type: 'separator' },
{ role: 'cut' },
{ role: 'copy' },
{ role: 'paste' },
{ role: 'pasteAndMatchStyle' },
{ role: 'delete' },
{ role: 'selectAll' },
],
},
{
label: 'View',
submenu: [
{ role: 'resetZoom' },
{ role: 'zoomIn' },
{ role: 'zoomOut' },
{ type: 'separator' },
{ role: 'togglefullscreen' },
...(isDev ? [{ type: 'separator' as const }, { role: 'toggleDevTools' as const }] : []),
],
},
{
label: 'Window',
submenu: [
{ role: 'minimize' },
{ role: 'close' },
...(process.platform === 'darwin'
? [
{ type: 'separator' as const },
{ role: 'front' as const },
{ type: 'separator' as const },
{ role: 'window' as const },
]
: []),
],
},
)
return template
}
function installApplicationMenu(): void {
Menu.setApplicationMenu(Menu.buildFromTemplate(createApplicationMenuTemplate()))
}
function createWindow(): BrowserWindow {
const win = new BrowserWindow({
width: 1200,
height: 820,
minWidth: 900,
minHeight: 600,
backgroundColor: nativeTheme.shouldUseDarkColors ? '#0e1013' : '#f5f6f8',
// macOS: integrated title bar (traffic lights float over the sidebar), like
// Linear/Hermes. Windows/Linux keep their native frame + window controls.
titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',
show: false,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
},
})
win.once('ready-to-show', () => win.show())
// This window only ever renders the bundled renderer; block in-page navigation
// and popups so a hijacked link can't turn it into a browser.
win.webContents.on('will-navigate', event => event.preventDefault())
win.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
win.webContents.on('did-fail-load', (_event, errorCode, errorDescription) => {
console.error(`Renderer failed to load (${errorCode}): ${errorDescription}`)
})
const devUrl = process.env.VITE_DEV_SERVER_URL
if (devUrl) {
win.loadURL(devUrl).catch(err => console.error('Failed to load dev server URL:', err))
} else {
win.loadFile(path.join(__dirname, '..', 'renderer', 'index.html')).catch(err => console.error('Failed to load renderer:', err))
}
return win
}
function bootstrap(): void {
process.on('unhandledRejection', reason => {
console.error('Unhandled promise rejection in main process:', reason)
})
// A second launch focuses the running window instead of opening a rival one.
if (!app.requestSingleInstanceLock()) {
app.quit()
return
}
app.on('second-instance', () => {
const [win] = BrowserWindow.getAllWindows()
if (!win) return
if (win.isMinimized()) win.restore()
win.focus()
})
app.on('before-quit', () => killAll())
void app.whenReady().then(() => {
registerHandlers()
installApplicationMenu()
createWindow()
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit()
})
}
if (!process.env.VITEST) bootstrap()

57
app/electron/preload.ts Normal file
View file

@ -0,0 +1,57 @@
import { contextBridge, ipcRenderer } from 'electron'
// Handlers resolve with { ok, value } | { ok, error } so the structured error
// `kind` survives the contextBridge boundary. `import type` is erased at build,
// so this shares main.ts's declaration without pulling its runtime in.
import type { Envelope } from './main'
type DateRange = { from: string; to: string }
type PriceRates = { input?: number; output?: number; cacheRead?: number; cacheCreation?: number }
async function invoke<T>(channel: string, ...args: unknown[]): Promise<T> {
const res = (await ipcRenderer.invoke(channel, ...args)) as Envelope<T>
if (res.ok) return res.value
// Reject with a plain object so `kind` is preserved (Error subclasses lose
// custom fields when cloned across worlds).
return Promise.reject(res.error)
}
// Shape matches CodeburnBridge (app/renderer/lib/types.ts); typing is enforced
// 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) => invoke('codeburn:getOverview', period, provider, range, configSource),
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),
getSessions: (period: string, provider: string, range?: DateRange) => invoke('codeburn:getSessions', period, provider, range),
getCompareModels: (period: string, provider: string) => invoke('codeburn:getCompareModels', period, provider),
getCompare: (period: string, provider: string, modelA: string, modelB: string) => invoke('codeburn:getCompare', period, provider, modelA, modelB),
getYield: (period: string, provider: string, range?: DateRange) => invoke('codeburn:getYield', period, provider, range),
getSpendFlow: (period: string, provider: string, range?: DateRange) => invoke('codeburn:getSpendFlow', period, provider, range),
getOptimizeReport: (period: string, provider: string, range?: DateRange) => invoke('codeburn:getOptimizeReport', period, provider, range),
getDevices: (period: string) => invoke('codeburn:getDevices', period),
getDevicesScan: () => invoke('codeburn:getDevicesScan'),
getShareStatus: () => invoke('codeburn:getShareStatus'),
getIdentity: () => invoke('codeburn:getIdentity'),
getAliases: () => invoke('codeburn:getAliases'),
getProxyPaths: () => invoke('codeburn:getProxyPaths'),
getAudit: (period: string, provider: string, range?: DateRange) => invoke('codeburn:getAudit', period, provider, range),
getPriceOverrides: () => invoke('codeburn:getPriceOverrides'),
setPriceOverride: (model: string, rates: PriceRates) => invoke('codeburn:setPriceOverride', model, rates),
removePriceOverride: (model: string) => invoke('codeburn:removePriceOverride', model),
setCurrency: (code: string) => invoke('codeburn:setCurrency', code),
resetCurrency: () => invoke('codeburn:resetCurrency'),
addAlias: (from: string, to: string) => invoke('codeburn:addAlias', from, to),
removeAlias: (from: string) => invoke('codeburn:removeAlias', from),
removeDevice: (name: string) => invoke('codeburn:removeDevice', name),
setPlan: (id: string, provider: string) => invoke('codeburn:setPlan', id, provider),
resetPlan: (provider: string) => invoke('codeburn:resetPlan', provider),
exportData: (format: string, provider: string, outPath: string) => invoke('codeburn:exportData', format, provider, outPath),
chooseDirectory: () => invoke('codeburn:chooseDirectory'),
cliStatus: () => invoke('codeburn:cliStatus'),
openExternal: (url: string) => ipcRenderer.invoke('open-external', url),
platform: process.platform,
}
contextBridge.exposeInMainWorld('codeburn', bridge)

View file

@ -0,0 +1,115 @@
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import { decodeClaudeUsage, fetchClaudeQuota } from './claude'
const credential = JSON.stringify({
claudeAiOauth: {
accessToken: 'sk-ant-test-secret',
refreshToken: 'unused',
expiresAt: Date.now() + 86_400_000,
rateLimitTier: 'max_20x',
},
})
afterEach(() => vi.restoreAllMocks())
describe('Claude quota', () => {
it('decodes ordered five-hour, weekly, model, and scoped windows with credential tier', () => {
const quota = decodeClaudeUsage({
five_hour: { utilization: 25, resets_at: '2026-07-12T12:00:00Z' },
seven_day: { utilization: 50, resets_at: '2026-07-19T12:00:00.123Z' },
seven_day_opus: { utilization: 75, resets_at: '2026-07-19T12:00:00Z' },
seven_day_sonnet: { utilization: 90, resets_at: '2026-07-19T12:00:00Z' },
limits: [
{ kind: 'weekly_all', percent: 88, scope: { model: { display_name: 'Duplicate' } } },
{ kind: 'weekly_scoped', percent: 10, resets_at: '2026-07-20T00:00:00Z', scope: { model: { display_name: 'Haiku' } } },
],
}, { accessToken: 'hidden', rateLimitTier: 'max_20x' })
expect(quota.connection).toBe('connected')
expect(quota.planLabel).toBe('Max 20x')
expect(quota.primary?.label).toBe('Weekly')
expect(quota.details.map(row => row.label)).toEqual(['5-hour', 'Weekly', 'Weekly · Opus', 'Weekly · Sonnet', 'Weekly · Haiku'])
expect(quota.details.map(row => row.percent)).toEqual([0.25, 0.5, 0.75, 0.9, 0.1])
})
it('returns disconnected without credentials and never fetches', async () => {
const fetchMock = vi.fn()
const result = await fetchClaudeQuota({ fetch: fetchMock, readFile: vi.fn(async () => null) })
expect(result.quota.connection).toBe('disconnected')
expect(fetchMock).not.toHaveBeenCalled()
})
it('sanitizes newline-corrupted credential JSON and uses exact request headers', async () => {
const broken = credential.replace('sk-ant-test-secret', 'sk-ant-test-\n secret')
const fetchMock = vi.fn(async () => new Response(JSON.stringify({ seven_day: { utilization: 4, resets_at: '2026-07-19T00:00:00Z' } }), { status: 200 }))
const result = await fetchClaudeQuota({ fetch: fetchMock, readFile: vi.fn(async () => broken) })
expect(result.quota.connection).toBe('connected')
const [url, init] = fetchMock.mock.calls[0]! as unknown as [string, RequestInit]
expect(url).toBe('https://api.anthropic.com/api/oauth/usage')
expect(init.method).toBe('GET')
expect(init.headers).toEqual({
Authorization: 'Bearer sk-ant-test-secret', Accept: 'application/json',
'anthropic-beta': 'oauth-2025-04-20', 'User-Agent': 'claude-code/2.1.0',
})
expect(init.headers).not.toHaveProperty('anthropic-version')
})
it('uses the body retry_after for 429 backoff', async () => {
const fetchMock = vi.fn(async () => new Response(JSON.stringify({ retry_after: '42' }), { status: 429 }))
const result = await fetchClaudeQuota({ fetch: fetchMock, readFile: vi.fn(async () => credential) })
expect(result).toMatchObject({ quota: { connection: 'transientFailure' }, retryAfterSeconds: 60 })
})
it('never calls an Anthropic refresh endpoint when the token is unchanged after 401', async () => {
const fetchMock = vi.fn(async () => new Response('', { status: 401 }))
const result = await fetchClaudeQuota({ fetch: fetchMock, readFile: vi.fn(async () => credential) })
expect(result.quota.connection).toBe('transientFailure')
expect(fetchMock).toHaveBeenCalledTimes(1)
expect((fetchMock.mock.calls as unknown as Array<[string]>).every(call => String(call[0]) === 'https://api.anthropic.com/api/oauth/usage')).toBe(true)
})
it('redacts tokens and NUL from diagnostics without surfacing them', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
const fetchMock = vi.fn(async () => { throw new Error('Bearer rawbearer sk-ant-leak sk-other eyJabc.def.ghi\0tail') })
const result = await fetchClaudeQuota({ fetch: fetchMock, readFile: vi.fn(async () => credential) })
const logged = warn.mock.calls.flat().join(' ')
expect(result.quota).not.toHaveProperty('error')
expect(logged).not.toMatch(/rawbearer|sk-ant-leak|sk-other|eyJabc|\0/)
expect(logged).toContain('[REDACTED]')
})
})
describe('Claude keychain fallback', () => {
const originalPlatform = process.platform
beforeAll(() => Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }))
afterAll(() => Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }))
it('connects from the keychain when the credential file is absent', async () => {
const fetchMock = vi.fn(async () => new Response(JSON.stringify({ seven_day: { utilization: 4, resets_at: '2026-07-19T00:00:00Z' } }), { status: 200 }))
const result = await fetchClaudeQuota({ fetch: fetchMock, readFile: vi.fn(async () => null), allowKeychain: true, keychain: vi.fn(async () => ({ status: 'found' as const, value: credential })) })
expect(result.quota.connection).toBe('connected')
expect(result.quota.planLabel).toBe('Max 20x')
})
it('surfaces accessDenied when macOS blocks the keychain read', async () => {
const fetchMock = vi.fn()
const result = await fetchClaudeQuota({ fetch: fetchMock, readFile: vi.fn(async () => null), allowKeychain: true, keychain: vi.fn(async () => ({ status: 'accessDenied' as const })) })
expect(result.quota.connection).toBe('accessDenied')
expect(fetchMock).not.toHaveBeenCalled()
})
it('stays disconnected when the keychain has no matching item', async () => {
const fetchMock = vi.fn()
const result = await fetchClaudeQuota({ fetch: fetchMock, readFile: vi.fn(async () => null), allowKeychain: true, keychain: vi.fn(async () => ({ status: 'notFound' as const })) })
expect(result.quota.connection).toBe('disconnected')
expect(fetchMock).not.toHaveBeenCalled()
})
it('never reads the keychain unless allowKeychain is set', async () => {
const keychain = vi.fn(async () => ({ status: 'found' as const, value: credential }))
const result = await fetchClaudeQuota({ fetch: vi.fn(), readFile: vi.fn(async () => null), keychain })
expect(result.quota.connection).toBe('disconnected')
expect(keychain).not.toHaveBeenCalled()
})
})

View file

@ -0,0 +1,152 @@
import os from 'node:os'
import path from 'node:path'
import { fraction, quotaRequestSignal, readKeychainPassword, readSecureFile, sanitizeError } from './security'
import type { KeychainOutcome } from './security'
import type { QuotaProvider, QuotaWindow } from './types'
const ENDPOINT = 'https://api.anthropic.com/api/oauth/usage'
const KEYCHAIN_SERVICE = 'Claude Code-credentials'
type ClaudeCredential = { accessToken: string; expiresAt?: number; rateLimitTier?: string }
export type ClaudeDeps = {
fetch: typeof fetch
credentialPath: string
readFile: typeof readSecureFile
now: () => number
keychain?: () => Promise<KeychainOutcome>
}
const defaults: ClaudeDeps = {
fetch: globalThis.fetch,
credentialPath: path.join(os.homedir(), '.claude', '.credentials.json'),
readFile: readSecureFile,
now: Date.now,
}
function empty(connection: QuotaProvider['connection']): QuotaProvider {
return { provider: 'claude', connection, primary: null, details: [], planLabel: null, footerLines: [] }
}
function parseCredential(raw: string): ClaudeCredential | null {
const clean = raw.replace(/\r/g, '').replace(/\n[ \t]*/g, '')
const oauth = (JSON.parse(clean) as { claudeAiOauth?: Record<string, unknown> }).claudeAiOauth
if (!oauth || typeof oauth.accessToken !== 'string' || oauth.accessToken.length === 0) return null
return {
accessToken: oauth.accessToken,
expiresAt: typeof oauth.expiresAt === 'number' ? oauth.expiresAt : undefined,
rateLimitTier: typeof oauth.rateLimitTier === 'string' ? oauth.rateLimitTier : undefined,
}
}
async function credentialFromFile(deps: ClaudeDeps): Promise<ClaudeCredential | null> {
const raw = await deps.readFile(deps.credentialPath, 64 * 1024)
return raw ? parseCredential(raw) : null
}
export async function readClaudeKeychain(): Promise<KeychainOutcome> {
// Claude Code has written the item under both `$USER` (2.1.x) and the older
// hardcoded "agentseal" account; a user-scoped miss must fall through to the
// service-only lookup rather than reporting disconnected.
const user = process.env.USER
return readKeychainPassword(KEYCHAIN_SERVICE, user ? [user, null] : [null])
}
function windowOf(label: string, value: unknown): QuotaWindow | null {
if (!value || typeof value !== 'object') return null
const row = value as Record<string, unknown>
const percent = fraction(row.utilization)
if (percent === null) return null
const resetsAt = typeof row.resets_at === 'string' && !Number.isNaN(Date.parse(row.resets_at))
? new Date(row.resets_at).toISOString() : null
return { label, percent, resetsAt }
}
function tierLabel(raw: string | undefined): string {
const value = raw?.toLowerCase() ?? ''
if (value.includes('max_20x') || value.includes('max20x') || value.includes('max-20x')) return 'Max 20x'
if (value.includes('max_5x') || value.includes('max5x') || value.includes('max-5x') || value.includes('max')) return 'Max 5x'
if (value.includes('pro')) return 'Pro'
if (value.includes('team')) return 'Team'
if (value.includes('enterprise')) return 'Enterprise'
return 'Subscription'
}
export function decodeClaudeUsage(body: unknown, credential: ClaudeCredential): QuotaProvider {
const data = body && typeof body === 'object' ? body as Record<string, unknown> : {}
const five = windowOf('5-hour', data.five_hour)
const weekly = windowOf('Weekly', data.seven_day)
const opus = windowOf('Weekly · Opus', data.seven_day_opus)
const sonnet = windowOf('Weekly · Sonnet', data.seven_day_sonnet)
const scoped: QuotaWindow[] = []
if (Array.isArray(data.limits)) {
for (const item of data.limits) {
if (!item || typeof item !== 'object') continue
const row = item as Record<string, any>
const display = row.scope?.model?.display_name
const percent = fraction(row.percent)
if (row.kind !== 'weekly_scoped' || typeof display !== 'string' || percent === null) continue
const resetsAt = typeof row.resets_at === 'string' && !Number.isNaN(Date.parse(row.resets_at))
? new Date(row.resets_at).toISOString() : null
scoped.push({ label: `Weekly · ${display}`, percent, resetsAt })
}
}
return {
provider: 'claude', connection: 'connected', primary: weekly,
details: [five, weekly, opus, sonnet].filter((row): row is QuotaWindow => row !== null).concat(scoped),
planLabel: tierLabel(credential.rateLimitTier), footerLines: [],
}
}
async function request(token: string, deps: ClaudeDeps, parent?: AbortSignal): Promise<Response> {
return deps.fetch(ENDPOINT, {
method: 'GET', signal: quotaRequestSignal(parent),
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/json',
'anthropic-beta': 'oauth-2025-04-20',
'User-Agent': 'claude-code/2.1.0',
},
})
}
export type ClaudeResult = { quota: QuotaProvider; retryAfterSeconds?: number }
export async function fetchClaudeQuota(options: Partial<ClaudeDeps> & { signal?: AbortSignal; allowKeychain?: boolean } = {}): Promise<ClaudeResult> {
const deps = { ...defaults, ...options }
try {
let credential = await credentialFromFile(deps)
if (!credential && options.allowKeychain && process.platform === 'darwin') {
const outcome = await (deps.keychain ?? readClaudeKeychain)()
if (outcome.status === 'accessDenied') return { quota: empty('accessDenied') }
credential = outcome.status === 'found' ? parseCredential(outcome.value) : null
}
if (!credential) return { quota: empty('disconnected') }
let response: Response
if (credential.expiresAt !== undefined && credential.expiresAt - deps.now() <= 5 * 60_000) {
const reread = await credentialFromFile(deps)
if (!reread || reread.accessToken === credential.accessToken) return { quota: empty('transientFailure') }
credential = reread
}
response = await request(credential.accessToken, deps, options.signal)
if (response.status === 401) {
const reread = await credentialFromFile(deps)
if (!reread || reread.accessToken === credential.accessToken) return { quota: empty('transientFailure') }
credential = reread
response = await request(credential.accessToken, deps, options.signal)
}
if (response.status === 429) {
let hint: unknown
try { hint = (await response.json() as Record<string, unknown>).retry_after } catch { hint = undefined }
const parsed = typeof hint === 'number' ? hint : typeof hint === 'string' ? Number(hint) : NaN
return { quota: empty('transientFailure'), retryAfterSeconds: Math.max(Number.isFinite(parsed) ? parsed : 300, 60) }
}
if (!response.ok) return { quota: empty(response.status >= 400 && response.status < 500 ? 'terminalFailure' : 'transientFailure') }
return { quota: decodeClaudeUsage(await response.json(), credential) }
} catch (error) {
// Deliberately sanitize before the only diagnostic sink. Tokens are never returned.
console.warn(`Claude quota unavailable: ${sanitizeError(error)}`)
return { quota: empty('transientFailure') }
}
}

View file

@ -0,0 +1,145 @@
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import { decodeCodexUsage, fetchCodexQuota } from './codex'
const now = Date.parse('2026-07-12T00:00:00Z')
const auth = {
auth_mode: 'chatgpt', OPENAI_API_KEY: 'preserve-me', last_refresh: '2026-07-11T00:00:00Z',
tokens: { access_token: 'eyJaccess.token.sig', refresh_token: 'refresh-secret', id_token: 'old-id', account_id: 'acct_1' },
}
afterEach(() => vi.restoreAllMocks())
describe('Codex quota', () => {
it('decodes primary/secondary/additional windows, plan and numeric-string credits', () => {
const quota = decodeCodexUsage({
plan_type: 'pLuS',
rate_limit: {
primary_window: { used_percent: 20, reset_at: 1_800_000_000, limit_window_seconds: 18_000 },
secondary_window: { used_percent: 80, reset_at: 1_800_100_000, limit_window_seconds: 604_800 },
},
additional_rate_limits: [{
limit_name: 'GPT-5', rate_limit: {
primary_window: { used_percent: 12, reset_at: 1_800_000_000, limit_window_seconds: 3600 },
secondary_window: { used_percent: 0, reset_at: 1_800_000_000, limit_window_seconds: 86_400 },
},
}],
credits: { balance: '3.5' },
})
expect(quota.planLabel).toBe('Plus')
expect(quota.primary?.label).toBe('5-hour')
expect(quota.details.map(row => row.label)).toEqual(['5-hour', 'Weekly', 'GPT-5 · Hour'])
expect(quota.footerLines).toEqual(['Credits remaining · $3.50'])
})
it('promotes secondary when primary is absent', () => {
const quota = decodeCodexUsage({ rate_limit: { secondary_window: { used_percent: 9, reset_at: 1_800_000_000, limit_window_seconds: 604_800 } } })
expect(quota.primary?.label).toBe('Weekly')
expect(quota.details).toHaveLength(1)
})
it('returns disconnected without credentials', async () => {
const fetchMock = vi.fn()
const result = await fetchCodexQuota({ fetch: fetchMock, readFile: vi.fn(async () => null) })
expect(result.quota.connection).toBe('disconnected')
expect(fetchMock).not.toHaveBeenCalled()
})
it('sends account id and uses Retry-After header for 429', async () => {
const fetchMock = vi.fn(async () => new Response('', { status: 429, headers: { 'Retry-After': '120' } }))
const result = await fetchCodexQuota({ fetch: fetchMock, readFile: vi.fn(async () => JSON.stringify(auth)), now: () => now })
expect(result.retryAfterSeconds).toBe(120)
const usageInit = (fetchMock.mock.calls[0]! as unknown as [string, RequestInit])[1]
expect(usageInit.headers).toMatchObject({ 'ChatGPT-Account-Id': 'acct_1', 'User-Agent': 'CodeBurn' })
})
it('refreshes after eight days and preserves unrelated auth keys on write-back', async () => {
const stale = { ...auth, last_refresh: '2026-07-01T00:00:00Z' }
const fetchMock = vi.fn(async (url: string) => url.includes('/oauth/token')
? new Response(JSON.stringify({ access_token: 'new-access', refresh_token: 'new-refresh', id_token: 'new-id' }), { status: 200 })
: new Response(JSON.stringify({ plan_type: 'pro', rate_limit: {} }), { status: 200 }))
const writeFile = vi.fn(async () => undefined)
await fetchCodexQuota({ fetch: fetchMock as typeof fetch, readFile: vi.fn(async () => JSON.stringify(stale)), writeFile, now: () => now })
const saved = JSON.parse((writeFile.mock.calls[0]! as unknown as [string, string])[1])
expect(saved.OPENAI_API_KEY).toBe('preserve-me')
expect(saved.tokens).toMatchObject({ access_token: 'new-access', refresh_token: 'new-refresh', id_token: 'new-id', account_id: 'acct_1' })
expect((fetchMock.mock.calls[0]! as unknown as [string, RequestInit])[1].method).toBe('POST')
})
})
// The CodeBurn menubar caches its Codex OAuth as a Swift CredentialRecord blob.
const menubarRecord = JSON.stringify({
accessToken: 'eyJmenubar.token.sig', refreshToken: 'mb-refresh', idToken: 'mb-id', accountId: 'acct_mb', lastRefresh: 1_234_567,
})
describe('Codex menubar keychain source', () => {
const originalPlatform = process.platform
beforeAll(() => Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }))
afterAll(() => Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }))
it('resolves quota from the menubar keychain when auth.json is absent, read-only', async () => {
const fetchMock = vi.fn(async () => new Response(JSON.stringify({ plan_type: 'pro', rate_limit: { primary_window: { used_percent: 10, reset_at: 1_800_000_000, limit_window_seconds: 18_000 } } }), { status: 200 }))
const keychain = vi.fn(async () => ({ status: 'found' as const, value: menubarRecord }))
const writeFile = vi.fn(async () => undefined)
const result = await fetchCodexQuota({ fetch: fetchMock as unknown as typeof fetch, readFile: vi.fn(async () => null), writeFile, keychain, allowKeychain: true })
expect(result.quota.connection).toBe('connected')
expect(result.quota.planLabel).toBe('Pro')
const init = (fetchMock.mock.calls[0]! as unknown as [string, RequestInit])[1]
expect(init.headers).toMatchObject({ Authorization: 'Bearer eyJmenubar.token.sig', 'ChatGPT-Account-Id': 'acct_mb' })
expect(writeFile).not.toHaveBeenCalled()
expect(keychain).toHaveBeenCalledWith('org.agentseal.codeburn.menubar.codex.oauth.v1')
})
it('re-reads the keychain once on a 401 and adopts a rotated token, never a refresh POST', async () => {
const rotated = JSON.stringify({ accessToken: 'eyJrotated.token.sig', refreshToken: 'mb-refresh2', idToken: 'mb-id', accountId: 'acct_mb' })
let reads = 0
const keychain = vi.fn(async () => ({ status: 'found' as const, value: reads++ === 0 ? menubarRecord : rotated }))
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => (init?.headers as Record<string, string>).Authorization === 'Bearer eyJrotated.token.sig'
? new Response(JSON.stringify({ plan_type: 'pro', rate_limit: {} }), { status: 200 })
: new Response('', { status: 401 }))
const writeFile = vi.fn(async () => undefined)
const result = await fetchCodexQuota({ fetch: fetchMock as unknown as typeof fetch, readFile: vi.fn(async () => null), writeFile, keychain, allowKeychain: true })
expect(result.quota.connection).toBe('connected')
expect(fetchMock.mock.calls.every(call => String(call[0]).includes('/wham/usage'))).toBe(true)
expect(keychain).toHaveBeenCalledTimes(2)
expect(writeFile).not.toHaveBeenCalled()
})
it('returns transientFailure on a keychain 401 with no rotation, never writing back', async () => {
const keychain = vi.fn(async () => ({ status: 'found' as const, value: menubarRecord }))
const fetchMock = vi.fn(async (_url: string) => new Response('', { status: 401 }))
const writeFile = vi.fn(async () => undefined)
const result = await fetchCodexQuota({ fetch: fetchMock as unknown as typeof fetch, readFile: vi.fn(async () => null), writeFile, keychain, allowKeychain: true })
expect(result.quota.connection).toBe('transientFailure')
expect(fetchMock.mock.calls.every(call => String(call[0]).includes('/wham/usage'))).toBe(true)
expect(writeFile).not.toHaveBeenCalled()
})
it('surfaces accessDenied when the menubar keychain is blocked and no file exists', async () => {
const keychain = vi.fn(async () => ({ status: 'accessDenied' as const }))
const fetchMock = vi.fn()
const result = await fetchCodexQuota({ fetch: fetchMock as unknown as typeof fetch, readFile: vi.fn(async () => null), keychain, allowKeychain: true })
expect(result.quota.connection).toBe('accessDenied')
expect(fetchMock).not.toHaveBeenCalled()
})
it('prefers the menubar keychain over ~/.codex/auth.json and keeps it read-only', async () => {
const keychain = vi.fn(async () => ({ status: 'found' as const, value: menubarRecord }))
const fetchMock = vi.fn(async () => new Response(JSON.stringify({ plan_type: 'plus', rate_limit: {} }), { status: 200 }))
const writeFile = vi.fn(async () => undefined)
const result = await fetchCodexQuota({ fetch: fetchMock as unknown as typeof fetch, readFile: vi.fn(async () => JSON.stringify(auth)), writeFile, keychain, allowKeychain: true, now: () => now })
expect(result.quota.connection).toBe('connected')
const init = (fetchMock.mock.calls[0]! as unknown as [string, RequestInit])[1]
expect(init.headers).toMatchObject({ Authorization: 'Bearer eyJmenubar.token.sig' })
expect(writeFile).not.toHaveBeenCalled()
})
it('falls through to ~/.codex/auth.json when the keychain has no menubar item', async () => {
const keychain = vi.fn(async () => ({ status: 'notFound' as const }))
const fetchMock = vi.fn(async () => new Response(JSON.stringify({ plan_type: 'plus', rate_limit: {} }), { status: 200 }))
const result = await fetchCodexQuota({ fetch: fetchMock as unknown as typeof fetch, readFile: vi.fn(async () => JSON.stringify(auth)), keychain, allowKeychain: true, now: () => now })
expect(result.quota.connection).toBe('connected')
const init = (fetchMock.mock.calls[0]! as unknown as [string, RequestInit])[1]
expect(init.headers).toMatchObject({ Authorization: 'Bearer eyJaccess.token.sig' })
})
})

260
app/electron/quota/codex.ts Normal file
View file

@ -0,0 +1,260 @@
import os from 'node:os'
import path from 'node:path'
import { atomicWriteSecureFile, fraction, quotaRequestSignal, readKeychainPassword, readSecureFile, sanitizeError } from './security'
import type { KeychainOutcome } from './security'
import type { QuotaProvider, QuotaWindow } from './types'
const USAGE_ENDPOINT = 'https://chatgpt.com/backend-api/wham/usage'
const TOKEN_ENDPOINT = 'https://auth.openai.com/oauth/token'
const CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann'
const EIGHT_DAYS = 8 * 24 * 60 * 60_000
// The CodeBurn menubar caches its ChatGPT-mode Codex OAuth here as a
// `CredentialRecord` JSON blob (accessToken/refreshToken/idToken/accountId/…),
// account "default". Same brand, same machine, already consented — preferred
// over any OpenAI-owned storage.
const MENUBAR_KEYCHAIN_SERVICE = 'org.agentseal.codeburn.menubar.codex.oauth.v1'
type AuthDoc = Record<string, any> & {
auth_mode?: string
tokens?: { access_token?: string; refresh_token?: string; id_token?: string; account_id?: string; [key: string]: unknown }
last_refresh?: string
}
export type CodexDeps = {
fetch: typeof fetch
authPath: string
openaiAuthPath: string
readFile: typeof readSecureFile
writeFile: typeof atomicWriteSecureFile
keychain: (service: string) => Promise<KeychainOutcome>
now: () => number
}
const defaults: CodexDeps = {
fetch: globalThis.fetch,
authPath: path.join(os.homedir(), '.codex', 'auth.json'),
openaiAuthPath: path.join(os.homedir(), 'Library', 'Application Support', 'com.openai.codex', 'auth.json'),
readFile: readSecureFile,
writeFile: atomicWriteSecureFile,
keychain: service => readKeychainPassword(service, ['default', null]),
now: Date.now,
}
/** A resolved Codex credential plus how much of its lifecycle we own. Only the
* Codex CLI's own auth.json is `writable` (we may rotate + write it back); the
* menubar keychain and OpenAI app-support copies are read-only. */
type CodexSource = {
name: 'menubarKeychain' | 'authFile' | 'openaiAppSupport'
auth: AuthDoc
writable: boolean
reread: () => Promise<AuthDoc | null>
}
function empty(connection: QuotaProvider['connection']): QuotaProvider {
return { provider: 'codex', connection, primary: null, details: [], planLabel: null, footerLines: [] }
}
async function readAuth(deps: CodexDeps, filePath: string = deps.authPath): Promise<AuthDoc | null> {
const raw = await deps.readFile(filePath, 64 * 1024)
return raw ? JSON.parse(raw) as AuthDoc : null
}
// The menubar stores a Swift `CredentialRecord` (camelCase, Date fields as
// numbers) rather than the CLI's snake_case auth.json. Normalize to AuthDoc and
// mark it chatgpt-mode (the menubar only ever caches ChatGPT subscriptions).
function authFromMenubarRecord(raw: string): AuthDoc | null {
let record: Record<string, unknown>
try { record = JSON.parse(raw) as Record<string, unknown> } catch { return null }
const access = typeof record.accessToken === 'string' ? record.accessToken : ''
if (!access) return null
return {
auth_mode: 'chatgpt',
tokens: {
access_token: access,
refresh_token: typeof record.refreshToken === 'string' ? record.refreshToken : undefined,
id_token: typeof record.idToken === 'string' ? record.idToken : undefined,
account_id: typeof record.accountId === 'string' ? record.accountId : undefined,
},
}
}
async function discoverSource(deps: CodexDeps, allowKeychain: boolean): Promise<CodexSource | 'accessDenied' | null> {
let denied = false
// (a) CodeBurn menubar's own cached Codex OAuth. Read-only: the menubar owns
// rotation, so we never write it back and never proactively refresh it.
if (allowKeychain && process.platform === 'darwin') {
const outcome = await deps.keychain(MENUBAR_KEYCHAIN_SERVICE)
if (outcome.status === 'accessDenied') denied = true
else if (outcome.status === 'found') {
const auth = authFromMenubarRecord(outcome.value)
if (auth) {
return {
name: 'menubarKeychain', auth, writable: false,
reread: async () => {
const next = await deps.keychain(MENUBAR_KEYCHAIN_SERVICE)
return next.status === 'found' ? authFromMenubarRecord(next.value) : null
},
}
}
}
}
// (b) The Codex CLI's own ~/.codex/auth.json. We own rotation + write-back.
const fileAuth = await readAuth(deps)
if (fileAuth) return { name: 'authFile', auth: fileAuth, writable: true, reread: () => readAuth(deps) }
// (c) com.openai.codex App Support, only if it holds a plaintext auth JSON
// with a usable token. Tokens encrypted via "Codex Safe Storage" have no
// plaintext access_token here, so they fall through — we never decrypt.
const openaiAuth = await readAuth(deps, deps.openaiAuthPath).catch(() => null)
if (openaiAuth?.tokens?.access_token) {
return { name: 'openaiAppSupport', auth: openaiAuth, writable: false, reread: () => readAuth(deps, deps.openaiAuthPath).catch(() => null) }
}
return denied ? 'accessDenied' : null
}
function labelForSeconds(value: unknown): string {
const seconds = typeof value === 'number' ? Math.max(0, Math.trunc(value)) : 0
if (seconds < 3600) return 'Hourly'
if (seconds < 7200) return 'Hour'
if (seconds >= 18_000 && seconds < 19_000) return '5-hour'
if (seconds >= 86_400 && seconds < 87_000) return 'Daily'
if (seconds >= 604_800 && seconds < 605_000) return 'Weekly'
const hours = Math.floor(seconds / 3600)
return hours < 24 ? `${hours}-hour` : `${Math.floor(hours / 24)}-day`
}
function windowOf(value: unknown, override?: string): QuotaWindow | null {
if (!value || typeof value !== 'object') return null
const row = value as Record<string, unknown>
const percent = fraction(row.used_percent)
if (percent === null) return null
const reset = typeof row.reset_at === 'number' && Number.isFinite(row.reset_at)
? new Date(row.reset_at * 1000).toISOString() : null
return { label: override ?? labelForSeconds(row.limit_window_seconds), percent, resetsAt: reset }
}
function planLabel(value: unknown): string | null {
if (typeof value !== 'string' || !value.trim()) return null
const raw = value.trim()
const lower = 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',
free_workspace: 'Free Workspace', team: 'Team', business: 'Business',
education: 'Education', quorum: 'Quorum', k12: 'K-12', enterprise: 'Enterprise', edu: 'Edu',
}
return known[lower] ?? lower.replace(/(^|[_-])\w/g, match => match.replace(/[_-]/, ' ').toUpperCase())
}
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)
const secondaryRaw = windowOf(data.rate_limit?.secondary_window)
const primary = primaryRaw ?? secondaryRaw
const details: QuotaWindow[] = []
if (primaryRaw) details.push(primaryRaw)
if (secondaryRaw && secondaryRaw !== primary) details.push(secondaryRaw)
else if (!primaryRaw && secondaryRaw) details.push(secondaryRaw)
if (Array.isArray(data.additional_rate_limits)) {
for (const additional of data.additional_rate_limits) {
if (!additional || typeof additional !== 'object' || typeof additional.limit_name !== 'string') continue
for (const key of ['primary_window', 'secondary_window'] as const) {
const raw = additional.rate_limit?.[key]
const base = windowOf(raw)
if (base && base.percent > 0) details.push({ ...base, label: `${additional.limit_name} · ${base.label}` })
}
}
}
const rawBalance = data.credits?.balance
const balance = typeof rawBalance === 'number' ? rawBalance : typeof rawBalance === 'string' ? Number(rawBalance) : NaN
return {
provider: 'codex', connection: 'connected', primary, details,
planLabel: planLabel(data.plan_type),
footerLines: Number.isFinite(balance) && balance > 0 ? [`Credits remaining · $${balance.toFixed(2)}`] : [],
}
}
async function refresh(auth: AuthDoc, deps: CodexDeps, signal?: AbortSignal): Promise<AuthDoc | null> {
const refreshToken = auth.tokens?.refresh_token
if (!refreshToken) return null
const response = await deps.fetch(TOKEN_ENDPOINT, {
method: 'POST', signal: quotaRequestSignal(signal),
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ client_id: CLIENT_ID, grant_type: 'refresh_token', refresh_token: refreshToken, scope: 'openid profile email' }),
})
if (!response.ok) return null
const next = await response.json() as Record<string, unknown>
if (typeof next.access_token !== 'string' || !next.access_token) return null
const latest = await readAuth(deps)
if (!latest || latest.auth_mode !== 'chatgpt') return null
latest.tokens = {
...latest.tokens,
access_token: next.access_token,
...(typeof next.refresh_token === 'string' ? { refresh_token: next.refresh_token } : {}),
...(typeof next.id_token === 'string' ? { id_token: next.id_token } : {}),
}
latest.last_refresh = new Date(deps.now()).toISOString()
await deps.writeFile(deps.authPath, `${JSON.stringify(latest, null, 2)}\n`)
return latest
}
async function usage(auth: AuthDoc, deps: CodexDeps, signal?: AbortSignal): Promise<Response | null> {
const token = auth.tokens?.access_token
if (!token) return null
const headers: Record<string, string> = { Authorization: `Bearer ${token}`, Accept: 'application/json', 'User-Agent': 'CodeBurn' }
if (auth.tokens?.account_id) headers['ChatGPT-Account-Id'] = auth.tokens.account_id
return deps.fetch(USAGE_ENDPOINT, { method: 'GET', headers, signal: quotaRequestSignal(signal) })
}
export type CodexResult = { quota: QuotaProvider; retryAfterSeconds?: number }
export async function fetchCodexQuota(options: Partial<CodexDeps> & { signal?: AbortSignal; allowKeychain?: boolean } = {}): Promise<CodexResult> {
const deps = { ...defaults, ...options }
try {
const discovered = await discoverSource(deps, Boolean(options.allowKeychain))
if (discovered === 'accessDenied') return { quota: empty('accessDenied') }
if (!discovered) return { quota: empty('disconnected') }
const source = discovered
let auth = source.auth
if (auth.auth_mode !== 'chatgpt') return { quota: empty('terminalFailure') }
if (!auth.tokens?.access_token) return { quota: empty('disconnected') }
// Proactive staleness refresh only for the source whose rotation we own.
if (source.writable) {
const refreshedAt = typeof auth.last_refresh === 'string' ? Date.parse(auth.last_refresh) : NaN
if (!Number.isFinite(refreshedAt) || deps.now() - refreshedAt > EIGHT_DAYS) {
const next = await refresh(auth, deps, options.signal)
if (next) auth = next
}
}
let response = await usage(auth, deps, options.signal)
if (!response) return { quota: empty('disconnected') }
if (response.status === 401) {
const reread = await source.reread()
if (reread?.tokens?.access_token && reread.tokens.access_token !== auth.tokens?.access_token) {
auth = reread
} else if (source.writable) {
const next = await refresh(reread ?? auth, deps, options.signal)
if (!next) return { quota: empty('transientFailure') }
auth = next
} else {
// Read-only source: the owner (menubar) rotates tokens on its own
// cadence, so re-read once and otherwise wait for the next poll.
return { quota: empty('transientFailure') }
}
response = await usage(auth, deps, options.signal)
if (!response) return { quota: empty('transientFailure') }
}
if (response.status === 429) {
const raw = response.headers.get('Retry-After')
let seconds = raw === null ? NaN : Number(raw)
if (!Number.isFinite(seconds) && raw) seconds = (Date.parse(raw) - deps.now()) / 1000
return { quota: empty('transientFailure'), retryAfterSeconds: Math.max(Number.isFinite(seconds) ? Math.ceil(seconds) : 300, 60) }
}
if (!response.ok) return { quota: empty(response.status >= 400 && response.status < 500 ? 'terminalFailure' : 'transientFailure') }
return { quota: decodeCodexUsage(await response.json()) }
} catch (error) {
console.warn(`Codex quota unavailable: ${sanitizeError(error)}`)
return { quota: empty('transientFailure') }
}
}

View file

@ -0,0 +1,58 @@
import { describe, expect, it, vi } from 'vitest'
import { QuotaService } from './index'
import type { QuotaProvider } from './types'
const quota = (provider: 'claude' | 'codex'): QuotaProvider => ({
provider, connection: 'connected', primary: null, details: [], planLabel: null, footerLines: [],
})
describe('QuotaService', () => {
it('persists provider 429 blocked-until and gates the next forced fetch', async () => {
const writes: string[] = []
const claude = vi.fn(async () => ({ quota: quota('claude'), retryAfterSeconds: 60 }))
const codex = vi.fn(async () => ({ quota: quota('codex') }))
const service = new QuotaService({
claude, codex, now: () => Date.parse('2026-07-12T00:00:00Z'),
readFile: vi.fn(async () => writes.at(-1) ?? null),
writeFile: vi.fn(async (_path, value) => { writes.push(value) }),
statePath: '/mock/backoff.json',
})
await service.getQuota({ force: true })
const saved = JSON.parse(writes[0]!)
expect(saved.claude).toBe('2026-07-12T00:01:00.000Z')
await service.getQuota({ force: true })
expect(claude).toHaveBeenCalledTimes(1)
expect(codex).toHaveBeenCalledTimes(2)
})
it('force re-fetches within the cache window by invalidating first', async () => {
const claude = vi.fn(async () => ({ quota: quota('claude') }))
const codex = vi.fn(async () => ({ quota: quota('codex') }))
const service = new QuotaService({
claude, codex, now: () => 1000, refreshMs: 120_000,
readFile: vi.fn(async () => null), writeFile: vi.fn(async () => undefined),
})
await service.getQuota()
await service.getQuota() // fresh cache, no re-fetch
expect(claude).toHaveBeenCalledTimes(1)
await service.getQuota({ force: true }) // force clears the still-fresh cache
expect(claude).toHaveBeenCalledTimes(2)
})
it('single-flights simultaneous callers', async () => {
let release!: () => void
const pending = new Promise<void>(resolve => { release = resolve })
const claude = vi.fn(async () => { await pending; return { quota: quota('claude') } })
const service = new QuotaService({
claude, codex: vi.fn(async () => ({ quota: quota('codex') })),
readFile: vi.fn(async () => null), writeFile: vi.fn(async () => undefined),
})
const first = service.getQuota({ force: true })
const second = service.getQuota({ force: true })
release()
expect(await first).toEqual(await second)
expect(claude).toHaveBeenCalledTimes(1)
})
})

128
app/electron/quota/index.ts Normal file
View file

@ -0,0 +1,128 @@
import os from 'node:os'
import path from 'node:path'
import { fetchClaudeQuota } from './claude'
import { fetchCodexQuota } from './codex'
import { atomicWriteSecureFile, readSecureFile, sanitizeError } from './security'
import type { ProviderName, QuotaProvider } from './types'
export type { QuotaProvider, QuotaWindow } from './types'
export { sanitizeError } from './security'
type Blocked = Partial<Record<ProviderName, string>>
type FetchResult = { quota: QuotaProvider; retryAfterSeconds?: number }
type QuotaDeps = {
claude: (options: { signal: AbortSignal; allowKeychain: boolean }) => Promise<FetchResult>
codex: (options: { signal: AbortSignal; allowKeychain: boolean }) => Promise<FetchResult>
statePath: string
readFile: typeof readSecureFile
writeFile: typeof atomicWriteSecureFile
now: () => number
refreshMs: number
}
const defaultDeps: QuotaDeps = {
claude: fetchClaudeQuota,
codex: fetchCodexQuota,
statePath: path.join(os.homedir(), '.codeburn', 'quota-backoff.json'),
readFile: readSecureFile,
writeFile: atomicWriteSecureFile,
now: Date.now,
refreshMs: 2 * 60_000,
}
function unavailable(provider: ProviderName, connection: QuotaProvider['connection']): QuotaProvider {
return { provider, connection, primary: null, details: [], planLabel: null, footerLines: [] }
}
export class QuotaService {
private readonly deps: QuotaDeps
private cache: { at: number; value: QuotaProvider[] } | null = null
private flight: Promise<QuotaProvider[]> | null = null
private generations: Record<ProviderName, number> = { claude: 0, codex: 0 }
private controllers: Partial<Record<ProviderName, AbortController>> = {}
constructor(deps: Partial<QuotaDeps> = {}) { this.deps = { ...defaultDeps, ...deps } }
invalidate(provider?: ProviderName): void {
const providers: ProviderName[] = provider ? [provider] : ['claude', 'codex']
for (const p of providers) {
this.generations[p] += 1
this.controllers[p]?.abort()
this.controllers[p] = undefined
}
this.cache = null
}
async getQuota(options: { force?: boolean; allowKeychain?: boolean } = {}): Promise<QuotaProvider[]> {
if (options.force) this.invalidate()
if (!options.force && this.cache && this.deps.now() - this.cache.at < this.deps.refreshMs) return this.cache.value
if (this.flight) return this.flight
this.flight = this.fetchAll(Boolean(options.allowKeychain)).finally(() => { this.flight = null })
return this.flight
}
private async readBlocked(): Promise<Blocked> {
try {
const raw = await this.deps.readFile(this.deps.statePath, 16 * 1024)
return raw ? JSON.parse(raw) as Blocked : {}
} catch (error) {
console.warn(`Quota backoff state unavailable: ${sanitizeError(error)}`)
return {}
}
}
private async writeBlocked(blocked: Blocked): Promise<void> {
try { await this.deps.writeFile(this.deps.statePath, `${JSON.stringify(blocked, null, 2)}\n`) }
catch (error) { console.warn(`Quota backoff state not saved: ${sanitizeError(error)}`) }
}
private async fetchAll(allowKeychain: boolean): Promise<QuotaProvider[]> {
const startingGenerations = { ...this.generations }
const prior = this.cache?.value ?? []
const blocked = await this.readBlocked()
const run = async (provider: ProviderName): Promise<QuotaProvider> => {
const retainOnFailure = (next: QuotaProvider): QuotaProvider => {
const previous = prior.find(item => item.provider === provider)
if (previous?.connection !== 'connected') return next
// Keychain-only credentials are invisible to a background (keychain-less)
// poll; keep showing the live connection rather than flapping to
// disconnected. A forced refresh re-reads the keychain and reveals truth.
if (!allowKeychain && (next.connection === 'disconnected' || next.connection === 'accessDenied')) return previous
if (next.connection === 'transientFailure') return { ...previous, connection: 'transientFailure' }
return next
}
const until = blocked[provider] ? Date.parse(blocked[provider]!) : NaN
if (Number.isFinite(until) && until > this.deps.now()) return retainOnFailure(unavailable(provider, 'transientFailure'))
const generation = this.generations[provider]
const controller = new AbortController()
this.controllers[provider] = controller
const result = provider === 'claude'
? await this.deps.claude({ signal: controller.signal, allowKeychain })
: await this.deps.codex({ signal: controller.signal, allowKeychain })
if (generation !== this.generations[provider] || controller.signal.aborted) return unavailable(provider, 'disconnected')
if (result.retryAfterSeconds !== undefined) {
blocked[provider] = new Date(this.deps.now() + result.retryAfterSeconds * 1000).toISOString()
await this.writeBlocked(blocked)
} else if (blocked[provider]) {
delete blocked[provider]
await this.writeBlocked(blocked)
}
if (this.controllers[provider] === controller) this.controllers[provider] = undefined
return retainOnFailure(result.quota)
}
const value = await Promise.all([run('claude'), run('codex')])
if (startingGenerations.claude === this.generations.claude && startingGenerations.codex === this.generations.codex) {
this.cache = { at: this.deps.now(), value }
}
return value
}
}
export const quotaService = new QuotaService()
// Keychain reads can raise a one-time macOS permission dialog, so only attempt
// them on a user-initiated forced refresh (the Connect / Refresh affordance).
// Background polls skip the keychain and lean on retainOnFailure to hold a
// live connection steady between forced refreshes.
export const getQuota = (options: { force?: boolean } = {}): Promise<QuotaProvider[]> =>
quotaService.getQuota({ force: options.force, allowKeychain: Boolean(options.force) })

View file

@ -0,0 +1,57 @@
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
import { readKeychainPassword } from './security'
// readKeychainPassword short-circuits off darwin; pin the platform so the
// classification logic is exercised on any CI host.
const originalPlatform = process.platform
beforeAll(() => Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }))
afterAll(() => Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }))
const denied = (message: string) => () => { throw new Error(message) }
const notFound = () => { const error = new Error('The specified item could not be found in the keychain.') as Error & { code: number }; error.code = 44; throw error }
describe('readKeychainPassword', () => {
it('decodes a hex-dumped security(1) payload back to its JSON text', async () => {
const json = JSON.stringify({ claudeAiOauth: { accessToken: 'x' } })
const hex = Buffer.from(json, 'utf8').toString('hex')
const exec = vi.fn(async () => ({ stdout: `${hex}\n` }))
expect(await readKeychainPassword('svc', [null], exec)).toEqual({ status: 'found', value: json })
})
it('passes plain (non-hex) JSON through unchanged', async () => {
const exec = vi.fn(async () => ({ stdout: '{"a":1}' }))
expect(await readKeychainPassword('svc', [null], exec)).toEqual({ status: 'found', value: '{"a":1}' })
})
it('classifies exit 44 / "could not be found" as notFound', async () => {
expect(await readKeychainPassword('svc', [null], vi.fn(notFound))).toEqual({ status: 'notFound' })
})
it('classifies a timeout kill left open on the dialog as accessDenied', async () => {
const exec = vi.fn(async () => { const error = new Error('Command failed') as Error & { killed: boolean; signal: string }; error.killed = true; error.signal = 'SIGTERM'; throw error })
expect(await readKeychainPassword('svc', [null], exec)).toEqual({ status: 'accessDenied' })
})
it('classifies "User interaction is not allowed" and user cancel as accessDenied', async () => {
expect(await readKeychainPassword('svc', [null], vi.fn(denied('SecKeychainItemCopyContent: User interaction is not allowed.')))).toEqual({ status: 'accessDenied' })
expect(await readKeychainPassword('svc', [null], vi.fn(denied('User canceled the operation.')))).toEqual({ status: 'accessDenied' })
})
it('falls through a user-scoped miss to the service-only lookup', async () => {
const exec = vi.fn(async (_file: string, args: string[]) => {
if (args.includes('-a')) return notFound()
return { stdout: '{"ok":true}' }
})
expect(await readKeychainPassword('svc', ['alice', null], exec)).toEqual({ status: 'found', value: '{"ok":true}' })
expect(exec).toHaveBeenCalledTimes(2)
})
it('reports accessDenied when a later candidate is denied after an earlier miss', async () => {
const exec = vi.fn(async (_file: string, args: string[]) => {
if (args.includes('-a')) return notFound()
throw new Error('User interaction is not allowed.')
})
expect(await readKeychainPassword('svc', ['alice', null], exec)).toEqual({ status: 'accessDenied' })
})
})

View file

@ -0,0 +1,152 @@
import { execFile } from 'node:child_process'
import { constants, type Stats } from 'node:fs'
import { chmod, lstat, mkdir, open, rename, unlink } from 'node:fs/promises'
import path from 'node:path'
import { promisify } from 'node:util'
const NOFOLLOW = constants.O_NOFOLLOW ?? 0
const execFileAsync = promisify(execFile)
// The macOS keychain prompt (first read of an item `security` is not yet trusted
// for) blocks until the user answers. Give them time to click Allow before we
// give up — the old 10s kill fired while the dialog was still open and got
// misread as "disconnected".
const KEYCHAIN_TIMEOUT_MS = 90_000
/** Outcome of a keychain lookup. `accessDenied` means the item exists but macOS
* needs the user to grant access (dialog dismissed, denied, or not answered). */
export type KeychainOutcome =
| { status: 'found'; value: string }
| { status: 'notFound' }
| { status: 'accessDenied' }
export type KeychainExec = (
file: string,
args: string[],
options: { timeout: number; maxBuffer: number },
) => Promise<{ stdout: string }>
// `security -w` hex-encodes password data it can't hand back as a clean C-string
// (Claude Code line-wraps its JSON blob, which triggers this). Real credential
// JSON always contains non-hex characters like '{' and '"', so an all-hex,
// even-length payload is unambiguously a hex dump we must decode.
function decodeKeychainValue(raw: string): string {
const trimmed = raw.trim()
if (trimmed.length >= 2 && trimmed.length % 2 === 0 && /^[0-9a-fA-F]+$/.test(trimmed)) {
return Buffer.from(trimmed, 'hex').toString('utf8')
}
return raw
}
// exit 44 / "could not be found" is a genuine miss; anything else non-zero
// (interaction not allowed, user canceled/denied, or a timeout kill while the
// dialog waited) means the item is there but access wasn't granted.
function classifyKeychainError(error: unknown): 'notFound' | 'accessDenied' {
const err = error as { code?: number | string; stderr?: string; message?: string }
const code = typeof err.code === 'number' ? err.code : undefined
const text = `${err.stderr ?? ''} ${err.message ?? ''}`.toLowerCase()
if (code === 44 || text.includes('could not be found') || text.includes('specified item could not be found')) {
return 'notFound'
}
return 'accessDenied'
}
/**
* Reads a generic-password keychain item via the Apple-signed `/usr/bin/security`
* (which the item's `apple-tool:` partition trusts, avoiding the partition-list
* prompt on already-consented items). Tries each account candidate in order
* (`null` = service-only). Never returns or logs the secret to any caller but
* the direct one.
*/
export async function readKeychainPassword(
service: string,
accounts: (string | null)[] = [null],
exec: KeychainExec = execFileAsync,
): Promise<KeychainOutcome> {
if (process.platform !== 'darwin') return { status: 'notFound' }
let denied = false
for (const account of accounts) {
const args = ['find-generic-password', '-s', service, ...(account ? ['-a', account] : []), '-w']
try {
const { stdout } = await exec('/usr/bin/security', args, { timeout: KEYCHAIN_TIMEOUT_MS, maxBuffer: 64 * 1024 })
if (stdout && stdout.trim()) return { status: 'found', value: decodeKeychainValue(stdout) }
} catch (error) {
if (classifyKeychainError(error) === 'accessDenied') denied = true
}
}
return denied ? { status: 'accessDenied' } : { status: 'notFound' }
}
export function sanitizeError(error: unknown): string {
const raw = error instanceof Error ? error.message : String(error)
return raw
.replace(/\0/g, '')
.replace(/Bearer\s+[^\s,;"']+/gi, 'Bearer [REDACTED]')
.replace(/sk-ant-[A-Za-z0-9_-]+/gi, '[REDACTED]')
.replace(/sk-[A-Za-z0-9_-]+/gi, '[REDACTED]')
.replace(/eyJ[A-Za-z0-9._-]+/g, '[REDACTED]')
.slice(0, 240)
}
function assertSafeMode(stats: Stats, filePath: string): void {
if (!stats.isFile()) throw new Error(`Credential path is not a regular file: ${filePath}`)
// POSIX mode bits are meaningless for Windows ACLs, where stats.mode would
// otherwise reject every credential file.
if (process.platform !== 'win32' && (stats.mode & 0o077) !== 0) throw new Error(`Credential file permissions are too broad: ${filePath}`)
}
export async function readSecureFile(filePath: string, maxBytes = 64 * 1024): Promise<string | null> {
let before: Stats
try {
before = await lstat(filePath)
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null
throw error
}
if (before.isSymbolicLink()) throw new Error(`Refusing symbolic link: ${filePath}`)
assertSafeMode(before, filePath)
if (before.size > maxBytes) throw new Error(`Credential file exceeds ${maxBytes} bytes: ${filePath}`)
const handle = await open(filePath, constants.O_RDONLY | NOFOLLOW)
try {
const after = await handle.stat()
assertSafeMode(after, filePath)
if (after.dev !== before.dev || after.ino !== before.ino) throw new Error(`Credential file changed while opening: ${filePath}`)
if (after.size > maxBytes) throw new Error(`Credential file exceeds ${maxBytes} bytes: ${filePath}`)
return await handle.readFile({ encoding: 'utf8' })
} finally {
await handle.close()
}
}
export async function atomicWriteSecureFile(filePath: string, contents: string): Promise<void> {
await mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 })
const tempPath = path.join(path.dirname(filePath), `.${path.basename(filePath)}.${process.pid}.${Date.now()}.tmp`)
const handle = await open(tempPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600)
try {
await handle.writeFile(contents, 'utf8')
await handle.sync()
} catch (error) {
await handle.close()
await unlink(tempPath).catch(() => undefined)
throw error
}
await handle.close()
await chmod(tempPath, 0o600)
try {
await rename(tempPath, filePath)
} catch (error) {
await unlink(tempPath).catch(() => undefined)
throw error
}
}
export function quotaRequestSignal(parent?: AbortSignal): AbortSignal {
const timeout = AbortSignal.timeout(30_000)
return parent ? AbortSignal.any([parent, timeout]) : timeout
}
export function fraction(value: unknown): number | null {
if (typeof value !== 'number' || !Number.isFinite(value)) return null
return Math.min(1, Math.max(0, value / 100))
}

View file

@ -0,0 +1,17 @@
export type QuotaWindow = {
label: string
percent: number
resetsAt: string | null
}
export type QuotaProvider = {
provider: 'claude' | 'codex'
connection: 'connected' | 'disconnected' | 'accessDenied' | 'loading' | 'stale' | 'transientFailure' | 'terminalFailure'
primary: QuotaWindow | null
details: QuotaWindow[]
planLabel: string | null
footerLines: string[]
}
export type ProviderName = QuotaProvider['provider']

7664
app/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

67
app/package.json Normal file
View file

@ -0,0 +1,67 @@
{
"name": "codeburn-desktop",
"private": true,
"version": "0.9.15",
"description": "CodeBurn Desktop — Electron app fed by the codeburn CLI",
"main": "dist/electron/main.js",
"scripts": {
"build:electron": "tsc -p tsconfig.electron.json",
"build:renderer": "vite build",
"build": "npm run build:electron && npm run build:renderer",
"dev": "npm run build:electron && concurrently -k -n vite,electron -c cyan,magenta \"vite\" \"wait-on tcp:127.0.0.1:5173 && cross-env VITE_DEV_SERVER_URL=http://127.0.0.1:5173 electron .\"",
"test": "vitest run",
"typecheck": "tsc --noEmit -p tsconfig.json",
"package": "npm run build && electron-builder --mac",
"package:arm64": "npm run build && electron-builder --mac --arm64",
"package:x64": "npm run build && electron-builder --mac --x64"
},
"dependencies": {
"@gsap/react": "^2.1.2",
"gsap": "^3.15.0",
"react": "^19.2.7",
"react-dom": "^19.2.7"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^26.1.1",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^4.7.0",
"@vitest/ui": "^3.2.7",
"concurrently": "^10.0.3",
"cross-env": "^10.1.0",
"electron": "^43.1.0",
"electron-builder": "^26.15.3",
"jsdom": "^29.1.1",
"typescript": "^7.0.2",
"vite": "^6.4.3",
"vitest": "^3.2.7",
"wait-on": "^9.0.10"
},
"build": {
"appId": "org.agentseal.codeburn-desktop",
"productName": "CodeBurn",
"directories": {
"output": "release"
},
"files": [
"dist/electron/**/*",
"dist/renderer/**/*",
"package.json"
],
"mac": {
"target": [
{ "target": "dmg", "arch": ["arm64", "x64"] },
{ "target": "zip", "arch": ["arm64", "x64"] }
],
"category": "public.app-category.developer-tools",
"icon": "build/icon.png",
"identity": "-",
"hardenedRuntime": false,
"gatekeeperAssess": false
},
"dmg": {}
}
}

406
app/renderer/App.test.tsx Normal file
View file

@ -0,0 +1,406 @@
// @vitest-environment jsdom
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { App } from './App'
import type { DateRange, MenubarPayload, OptimizeJsonReport, SpendFlow } from './lib/types'
const stored = new Map<string, string>()
vi.stubGlobal('localStorage', {
getItem: (key: string) => stored.get(key) ?? null,
setItem: (key: string, value: string) => stored.set(key, value),
removeItem: (key: string) => stored.delete(key),
clear: () => stored.clear(),
})
const mocks = vi.hoisted(() => ({
getOverview: vi.fn<(period: string, provider: string, range?: DateRange, configSource?: string | null) => 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(),
getSessions: vi.fn(),
getCompareModels: vi.fn(),
getCompare: vi.fn(),
getQuota: vi.fn(),
getPlans: vi.fn(),
getActReport: vi.fn(),
getYield: vi.fn(),
getDevices: vi.fn(),
getDevicesScan: vi.fn(),
getIdentity: vi.fn(),
cliStatus: vi.fn(),
}))
vi.mock('./lib/ipc', async orig => {
const actual = await orig<typeof import('./lib/ipc')>()
return { ...actual, codeburn: mocks }
})
function dateKey(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
function overviewPayload(): MenubarPayload {
const now = new Date()
return {
generated: now.toISOString(),
current: {
label: 'Last 30 days',
cost: 12.34,
calls: 12,
sessions: 2,
oneShotRate: null,
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
cacheHitPercent: 0,
codexCredits: 0,
topActivities: [],
topModels: [],
localModelSavings: { totalUSD: 0, calls: 0, byModel: [], byProvider: [] },
providers: { claude: 10, codex: 2 },
topProjects: [],
modelEfficiency: [],
topSessions: [],
retryTax: { totalUSD: 0, retries: 0, editTurns: 0, byModel: [] },
routingWaste: { totalSavingsUSD: 0, baselineModel: '', baselineCostPerEdit: 0, byModel: [] },
tools: [],
skills: [],
subagents: [],
mcpServers: [],
},
optimize: { findingCount: 0, savingsUSD: 0, topFindings: [] },
history: {
daily: [
{
date: dateKey(now),
cost: 12.34,
savingsUSD: 0,
calls: 12,
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
topModels: [],
},
],
},
}
}
const CONFIG_A = 'claude-config:aaaa000011112222'
const CONFIG_B = 'claude-desktop:bbbb000011112222'
function withConfigs(payload: MenubarPayload): MenubarPayload {
return {
...payload,
claudeConfigs: {
selectedId: null,
options: [
{ id: CONFIG_A, label: 'Default Claude', path: '/Users/x/.claude' },
{ id: CONFIG_B, label: 'Claude Desktop', path: '/Users/x/Library/Application Support/Claude' },
],
},
}
}
describe('App shortcuts', () => {
beforeEach(() => {
for (const mock of Object.values(mocks)) mock.mockReset()
mocks.getOverview.mockResolvedValue(overviewPayload())
mocks.getSpendFlow.mockResolvedValue({ period: { label: 'Last 30 days', start: '', end: '' }, models: [], projects: [], links: [] })
mocks.getOptimizeReport.mockResolvedValue({
period: { label: 'Last 30 days', start: null, end: null },
summary: {
healthScore: 100, healthGrade: 'A', findingCount: 0, periodCostUSD: 0,
sessions: 0, calls: 0, potentialSavingsTokens: 0, potentialSavingsCostUSD: 0,
potentialSavingsPercent: 0, costRateUSD: 0,
},
findings: [],
})
mocks.getModels.mockResolvedValue([])
mocks.getSessions.mockResolvedValue([])
mocks.getCompareModels.mockResolvedValue([])
mocks.getQuota.mockResolvedValue([
{ provider: 'claude', connection: 'disconnected', primary: null, details: [], planLabel: null, footerLines: [] },
{ provider: 'codex', connection: 'disconnected', primary: null, details: [], planLabel: null, footerLines: [] },
])
mocks.getPlans.mockResolvedValue({})
mocks.getActReport.mockResolvedValue({ totals: { realizedCostUSD: 0, measuredActions: 0 } })
mocks.getYield.mockResolvedValue({
period: { label: 'Last 30 days', start: '', end: '' },
summary: {
productive: { costUSD: 0, sessions: 0, costPercent: 0, sessionPercent: 0 },
reverted: { costUSD: 0, sessions: 0, costPercent: 0, sessionPercent: 0 },
abandoned: { costUSD: 0, sessions: 0, costPercent: 0, sessionPercent: 0 },
total: { costUSD: 0, sessions: 0 },
productiveToRevertedCostRatio: null,
},
details: [],
})
mocks.getIdentity.mockResolvedValue({ name: 'CodeBurn Mac', fingerprint: 'AA:BB:CC' })
mocks.getDevicesScan.mockResolvedValue({ found: [] })
mocks.getDevices.mockResolvedValue({
perDevice: [],
combined: {
cost: 0,
calls: 0,
sessions: 0,
inputTokens: 0,
outputTokens: 0,
cacheCreateTokens: 0,
cacheReadTokens: 0,
totalTokens: 0,
deviceCount: 1,
reachableCount: 1,
},
})
localStorage.clear()
document.documentElement.removeAttribute('data-theme')
})
it('applies the persisted theme on app boot before Settings mounts', async () => {
localStorage.setItem('codeburn.theme', 'dark')
render(<App />)
await waitFor(() => expect(document.documentElement).toHaveAttribute('data-theme', 'dark'))
expect(screen.queryByRole('heading', { name: 'General' })).not.toBeInTheDocument()
})
it('boots with the persisted default period from Settings', async () => {
localStorage.setItem('codeburn.defaultPeriod', 'today')
render(<App />)
await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('today', 'all'))
})
it('switches sections with command-number shortcuts', async () => {
render(<App />)
expect(await screen.findByText('Most expensive sessions')).toBeInTheDocument()
fireEvent.keyDown(document, { key: '2', metaKey: true })
expect(await screen.findByText('No sessions in this range yet.')).toBeInTheDocument()
})
it('keeps command navigation, settings, and refresh shortcuts active without stale hints', async () => {
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.queryByText('Command')).not.toBeInTheDocument()
expect(screen.queryByText('Export view')).not.toBeInTheDocument()
fireEvent.keyDown(document, { key: '2', metaKey: true })
expect(await screen.findByText('No sessions in this range yet.')).toBeInTheDocument()
fireEvent.keyDown(document, { key: '3', metaKey: true })
expect(await screen.findByText('Cost flow · model → project')).toBeInTheDocument()
fireEvent.keyDown(document, { key: '4', metaKey: true })
expect(await screen.findByText('No waste findings in this range yet.')).toBeInTheDocument()
fireEvent.keyDown(document, { key: '5', metaKey: true })
expect(await screen.findByText('No model usage in this range yet.')).toBeInTheDocument()
fireEvent.keyDown(document, { key: '6', metaKey: true })
expect(await screen.findByText('Need at least two models with usage in this range to compare.')).toBeInTheDocument()
fireEvent.keyDown(document, { key: '7', metaKey: true })
expect(await screen.findByText('Not connected. Log in with the Claude CLI.')).toBeInTheDocument()
fireEvent.keyDown(document, { key: ',', metaKey: true })
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 })
await waitFor(() => expect(mocks.getOverview.mock.calls.length).toBeGreaterThan(overviewCalls))
})
it('re-polls visible section data when period or provider changes', async () => {
render(<App />)
fireEvent.keyDown(document, { key: '3', metaKey: true })
expect(await screen.findByText('Cost flow · model → project')).toBeInTheDocument()
fireEvent.click(screen.getByText('Today'))
await waitFor(() => {
expect(mocks.getOverview).toHaveBeenCalledWith('today', 'all')
expect(mocks.getSpendFlow).toHaveBeenCalledWith('today', 'all')
})
fireEvent.click(screen.getByText('All providers'))
fireEvent.click(await screen.findByRole('option', { name: 'Claude' }))
await waitFor(() => {
expect(mocks.getOverview).toHaveBeenCalledWith('today', 'claude')
expect(mocks.getSpendFlow).toHaveBeenCalledWith('today', 'claude')
})
})
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).
const payload = overviewPayload()
payload.current.providers = { 'grok build': 5, claude: 10 }
payload.current.providerDetails = [
{ id: 'grok', label: 'Grok Build', cost: 5 },
{ id: 'claude', label: 'Claude', cost: 10 },
]
mocks.getOverview.mockResolvedValue(payload)
render(<App />)
expect(await screen.findByText('Most expensive sessions')).toBeInTheDocument()
fireEvent.click(screen.getByText('All providers'))
fireEvent.click(await screen.findByRole('option', { name: 'Grok Build' }))
await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('30days', 'grok'))
})
it('hides the Claude config picker when the payload carries no claudeConfigs', async () => {
render(<App />)
expect(await screen.findByText('Most expensive sessions')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Claude config source' })).not.toBeInTheDocument()
})
it('shows the config picker, re-fetches overview with the flag, and persists the choice', async () => {
mocks.getOverview.mockResolvedValue(withConfigs(overviewPayload()))
render(<App />)
const trigger = await screen.findByRole('button', { name: 'Claude config source' })
fireEvent.click(trigger)
fireEvent.click(await screen.findByRole('option', { name: 'Default Claude' }))
await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('30days', 'all', undefined, CONFIG_A))
expect(localStorage.getItem('codeburn.claudeConfigSource')).toBe(CONFIG_A)
})
it('resets a non-Claude provider filter to all when a config is selected', async () => {
const payload = withConfigs(overviewPayload())
payload.current.providers = { claude: 10, codex: 2 }
mocks.getOverview.mockResolvedValue(payload)
render(<App />)
expect(await screen.findByText('Most expensive sessions')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Providers' }))
fireEvent.click(await screen.findByRole('option', { name: 'Codex' }))
await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('30days', 'codex'))
fireEvent.click(screen.getByRole('button', { name: 'Claude config source' }))
fireEvent.click(await screen.findByRole('option', { name: 'Default Claude' }))
await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('30days', 'all', undefined, CONFIG_A))
// The Claude-incompatible provider filter must never reach the CLI with the flag.
expect(mocks.getOverview.mock.calls).not.toContainEqual(['30days', 'codex', undefined, CONFIG_A])
})
it('clears the config scope when a non-Claude provider is picked afterwards', async () => {
const payload = withConfigs(overviewPayload())
payload.current.providers = { claude: 10, codex: 2 }
mocks.getOverview.mockResolvedValue(payload)
render(<App />)
expect(await screen.findByText('Most expensive sessions')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Claude config source' }))
fireEvent.click(await screen.findByRole('option', { name: 'Default Claude' }))
await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('30days', 'all', undefined, CONFIG_A))
fireEvent.click(screen.getByRole('button', { name: 'Providers' }))
fireEvent.click(await screen.findByRole('option', { name: 'Codex' }))
await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('30days', 'codex'))
// The incompatible combination must never reach the CLI.
expect(mocks.getOverview.mock.calls).not.toContainEqual(['30days', 'codex', undefined, CONFIG_A])
expect(localStorage.getItem('codeburn.claudeConfigSource')).toBeNull()
})
it('boots with the persisted config source and clears it via All Claude configs', async () => {
localStorage.setItem('codeburn.claudeConfigSource', CONFIG_A)
mocks.getOverview.mockResolvedValue(withConfigs(overviewPayload()))
render(<App />)
await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('30days', 'all', undefined, CONFIG_A))
fireEvent.click(await screen.findByRole('button', { name: 'Claude config source' }))
fireEvent.click(await screen.findByRole('option', { name: 'All Claude configs' }))
await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('30days', 'all'))
expect(localStorage.getItem('codeburn.claudeConfigSource')).toBeNull()
})
it('applies a calendar range to overview and visible section polls', async () => {
render(<App />)
fireEvent.keyDown(document, { key: '3', metaKey: true })
expect(await screen.findByText('Cost flow · model → project')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Choose date range' }))
const to = new Date()
const from = new Date(to.getFullYear(), to.getMonth(), to.getDate() - 2)
const fromLabel = from.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' })
const toLabel = to.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' })
const range = { from: dateKey(from), to: dateKey(to) }
fireEvent.mouseDown(screen.getByRole('button', { name: fromLabel }))
fireEvent.mouseEnter(screen.getByRole('button', { name: toLabel }))
fireEvent.mouseUp(screen.getByRole('button', { name: toLabel }))
await waitFor(() => {
expect(mocks.getOverview).toHaveBeenCalledWith('30days', 'all', range)
expect(mocks.getSpendFlow).toHaveBeenCalledWith('30days', 'all', range)
})
expect(screen.getByRole('button', { name: // })).toBeInTheDocument()
expect(screen.getByText('30D')).not.toHaveClass('on')
})
it('shows no daily budget banner when none is configured', async () => {
render(<App />)
expect(await screen.findByText('Most expensive sessions')).toBeInTheDocument()
expect(screen.queryByText(/daily budget/i)).not.toBeInTheDocument()
})
it('shows no banner when today spend is under 80% of the budget', async () => {
localStorage.setItem('codeburn.dailyBudget', JSON.stringify({ kind: 'usd', value: 100 }))
render(<App />)
expect(await screen.findByText('Most expensive sessions')).toBeInTheDocument()
expect(screen.queryByText(/daily budget/i)).not.toBeInTheDocument()
})
it('warns when today spend reaches 80% of the daily budget', async () => {
localStorage.setItem('codeburn.dailyBudget', JSON.stringify({ kind: 'usd', value: 14 }))
render(<App />)
// 12.34 / 14 = 88.1% → warning band
expect(await screen.findByText("Today's spend is at 88% of your daily budget")).toBeInTheDocument()
})
it('alerts and dismisses for the rest of the day when the budget is exceeded', async () => {
localStorage.setItem('codeburn.dailyBudget', JSON.stringify({ kind: 'usd', value: 10 }))
render(<App />)
expect(await screen.findByText('Daily budget exceeded: $12.34 of $10.00')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Dismiss' }))
await waitFor(() => expect(screen.queryByText(/Daily budget exceeded/)).not.toBeInTheDocument())
expect(localStorage.getItem('codeburn.dailyBudget.dismissed')).toBe(dateKey(new Date()))
})
it('evaluates a token budget only on the all-providers view', async () => {
const payload = overviewPayload()
payload.history.daily[0]!.inputTokens = 60_000
payload.history.daily[0]!.outputTokens = 40_000
mocks.getOverview.mockResolvedValue(payload)
localStorage.setItem('codeburn.dailyBudget', JSON.stringify({ kind: 'tokens', value: 90_000 }))
render(<App />)
expect(await screen.findByText('Daily budget exceeded: 100K of 90K')).toBeInTheDocument()
// A specific-provider filter zeroes history.daily token fields, so the token
// cap can no longer be evaluated: the banner must disappear.
fireEvent.click(screen.getByText('All providers'))
fireEvent.click(await screen.findByRole('option', { name: 'Claude' }))
await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('30days', 'claude'))
await waitFor(() => expect(screen.queryByText(/Daily budget exceeded/)).not.toBeInTheDocument())
})
})

351
app/renderer/App.tsx Normal file
View file

@ -0,0 +1,351 @@
import { useCallback, useEffect, useState } from 'react'
import { EmptyNote } from './components/EmptyState'
import { ErrorBoundary } from './components/ErrorBoundary'
import { Hint } from './components/Hint'
import { Panel } from './components/Panel'
import { Sidebar, type Section } from './components/Sidebar'
import { Splash } from './components/Splash'
import { ToastHost } from './components/ToastHost'
import { rangeLabel, TopBar } from './components/TopBar'
import { Window } from './components/Window'
import { usePolled } from './hooks/usePolled'
import { readDailyBudget } from './lib/budget'
import { formatCompact, formatUsd, setActiveCurrency } from './lib/format'
import { motionClass } from './lib/motion'
import { codeburn } from './lib/ipc'
import { localDateKey } from './lib/period'
import { OverviewContent } from './sections/Overview'
import { OptimizeContent } from './sections/Optimize'
import { Models } from './sections/Models'
import { Sessions } from './sections/Sessions'
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, Period } from './lib/types'
const SECTION_TITLES: Record<Section, string> = {
overview: 'Overview',
sessions: 'Sessions',
spend: 'Spend',
optimize: 'Optimize',
models: 'Models',
compare: 'Compare',
plans: 'Plans',
settings: 'Settings',
}
const PERIOD_LABELS: Record<Period, string> = {
today: 'Today',
week: 'Last 7 days',
month: 'This month',
'30days': 'Last 30 days',
all: 'All time',
}
const STANDARD_PERIODS: Period[] = ['today', 'week', '30days', 'month', 'all']
function isPeriod(value: string): value is Period {
return (STANDARD_PERIODS as string[]).includes(value)
}
/** Boot period = the persisted "Default period" Settings writes, else 30 days. */
function initialPeriod(): Period {
let saved: string | null = null
try { saved = globalThis.localStorage?.getItem('codeburn.defaultPeriod') ?? null } catch { /* storage can be unavailable */ }
return saved && isPeriod(saved) ? saved : '30days'
}
/** Persisted Claude config override (empty/absent = aggregate all configs). */
function initialConfigSource(): string | null {
try { return globalThis.localStorage?.getItem('codeburn.claudeConfigSource') || null } catch { return null }
}
function persistConfigSource(id: string | null): void {
try {
if (id) globalThis.localStorage?.setItem('codeburn.claudeConfigSource', id)
else globalThis.localStorage?.removeItem('codeburn.claudeConfigSource')
} catch { /* storage can be unavailable */ }
}
function providerName(provider: string): string {
if (provider === 'all') return 'All providers'
return provider
.split(/[-\s]+/)
.filter(Boolean)
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ')
}
function refreshedLabel(lastSuccessAt: number | null, loading: boolean, now: number): string {
if (loading && lastSuccessAt === null) return 'refreshing…'
if (lastSuccessAt === null) return 'not refreshed yet'
const seconds = Math.max(0, Math.floor((now - lastSuccessAt) / 1000))
if (seconds < 1) return 'refreshed just now'
if (seconds < 60) return `refreshed ${seconds}s ago`
const minutes = Math.floor(seconds / 60)
return `refreshed ${minutes}m ago`
}
export function App() {
const [section, setSection] = useState<Section>('overview')
const [settingsPane, setSettingsPane] = useState<SettingsPane>('general')
const [period, setPeriod] = useState<Period>(initialPeriod)
const [provider, setProvider] = useState<string>('all')
const [detectedProviders, setDetectedProviders] = useState<Array<{ id: string; label: string }>>([])
const [customRange, setCustomRange] = useState<DateRange | null>(null)
const [claudeConfigSource, setClaudeConfigSource] = useState<string | null>(initialConfigSource)
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.
const overview = usePolled<MenubarPayload>(
() => 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],
)
const refreshOverview = overview.refresh
useEffect(() => {
let saved: string | null = null
try { saved = globalThis.localStorage?.getItem('codeburn.theme') ?? null } catch { /* storage can be unavailable */ }
if (saved === 'light' || saved === 'dark') document.documentElement.setAttribute('data-theme', saved)
else document.documentElement.removeAttribute('data-theme')
}, [])
useEffect(() => {
if (!overview.data) return
const details = overview.data.current.providerDetails
// Prefer providerDetails (internal id + display label); fall back to the
// providers map keys (lowercased display names) for older CLIs.
const found = details
? details.filter(entry => entry.cost > 0).map(entry => ({ id: entry.id, label: entry.label }))
: Object.entries(overview.data.current.providers)
// Fallback map keys are lowercased display names; ones with spaces
// ("grok build") cannot round-trip as --provider, so exclude them
// rather than offer a filter that is guaranteed to error.
.filter(([key, value]) => value > 0 && /^[a-z0-9-]+$/.test(key))
.map(([key]) => ({ id: key, label: providerName(key) }))
setDetectedProviders(current => {
const next = [...current]
for (const item of found) if (!next.some(entry => entry.id === item.id)) next.push(item)
return next.length === current.length ? current : next
})
}, [overview.data])
useEffect(() => {
const currency = overview.data?.currency
if (!currency) return
setActiveCurrency(currency)
setCurrencyTick(tick => tick + 1)
}, [overview.data?.currency?.code, overview.data?.currency?.rate, overview.data?.currency?.symbol])
useEffect(() => {
const id = window.setInterval(() => setNow(Date.now()), 1000)
return () => window.clearInterval(id)
}, [])
const refreshVisible = useCallback(() => {
refreshOverview()
setRefreshToken(token => token + 1)
}, [refreshOverview])
const navigate = useCallback((next: Section, pane: SettingsPane = 'general') => {
setSettingsPane(pane)
setSection(next)
}, [])
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (!event.metaKey || event.altKey || event.ctrlKey || event.shiftKey) 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 === ',') navigate('settings')
else if (key === 'r') refreshVisible()
else return
event.preventDefault()
}
document.addEventListener('keydown', onKeyDown)
return () => document.removeEventListener('keydown', onKeyDown)
}, [refreshVisible, navigate])
const onPeriodChange = (value: string) => {
if (isPeriod(value)) {
setCustomRange(null)
setPeriod(value)
}
}
// 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).
const onConfigSelect = (id: string) => {
const next = id || null
if (next && provider !== 'all' && provider !== 'claude') setProvider('all')
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.
const onProviderSelect = (value: string) => {
if (claudeConfigSource && value !== 'all' && value !== 'claude') {
setClaudeConfigSource(null)
persistConfigSource(null)
}
setProvider(value)
}
const claudeConfigs = overview.data?.claudeConfigs
const providerOptions = [
{ value: 'all', label: 'All providers' },
...detectedProviders.map(entry => ({ value: entry.id, label: entry.label })),
]
const providerLabel = detectedProviders.find(entry => entry.id === provider)?.label ?? providerName(provider)
const activeConfigLabel = claudeConfigSource
? claudeConfigs?.options.find(option => option.id === claudeConfigSource)?.label ?? null
: null
const scope = `${customRange ? rangeLabel(customRange) : PERIOD_LABELS[period]} · ${providerLabel}${activeConfigLabel ? ` · ${activeConfigLabel}` : ''}`
return (
<Window>
<Sidebar active={section} onNavigate={navigate} status={<StatusLine polled={overview} />} />
<ToastHost />
<Splash hasData={overview.data != null} hasError={overview.error != null} />
<div className="ct">
<DailyBudgetBanner payload={overview.data ?? null} provider={provider} />
<ErrorBoundary key={section}>
{section === 'plans' ? (
<Plans period={period} refreshToken={refreshToken} onNavigate={navigate} />
) : section === 'settings' ? (
<Settings period={period} refreshToken={refreshToken} onNavigate={navigate} initialPane={settingsPane} claudeConfigs={claudeConfigs} claudeConfigSource={claudeConfigSource} />
) : (
<>
<TopBar
title={SECTION_TITLES[section]}
scope={scope}
period={period}
onPeriodChange={onPeriodChange}
customRange={customRange}
onRangeSelect={setCustomRange}
provider={provider}
providerLabel={providerLabel}
providerOptions={providerOptions}
onProviderSelect={onProviderSelect}
claudeConfigs={claudeConfigs}
configSource={claudeConfigSource}
onConfigSelect={onConfigSelect}
/>
<div className={motionClass('body', 'section-fade')}>
{section === 'overview' ? (
<OverviewContent period={period} provider={provider} range={customRange} overview={overview} onNavigate={navigate} />
) : section === 'sessions' ? (
<Sessions period={period} provider={provider} range={customRange} refreshToken={refreshToken} detectedProviders={detectedProviders} onProviderChange={onProviderSelect} />
) : section === 'spend' ? (
<SpendContent period={period} provider={provider} range={customRange} overview={overview} refreshToken={refreshToken} />
) : section === 'optimize' ? (
<OptimizeContent period={period} provider={provider} range={customRange} overview={overview} refreshToken={refreshToken} />
) : section === 'models' ? (
<Models period={period} provider={provider} range={customRange} refreshToken={refreshToken} onNavigate={navigate} />
) : section === 'compare' ? (
<Compare period={period} provider={provider} range={customRange} refreshToken={refreshToken} />
) : (
<SectionPlaceholder title={SECTION_TITLES[section]} />
)}
</div>
</>
)}
</ErrorBoundary>
{section !== 'settings' && (
<Hint
items={[
{ k: '⌘1-7', label: 'Navigate' },
{ k: '⌘,', label: 'Settings' },
{ k: '⌘R', label: 'Refresh' },
]}
right={refreshedLabel(overview.lastSuccessAt, overview.loading, now)}
/>
)}
</div>
</Window>
)
}
function StatusLine({ polled }: { polled: ReturnType<typeof usePolled<MenubarPayload>> }) {
if (polled.data) {
return (
<>
{polled.data.current.label} <b>{formatUsd(polled.data.current.cost)}</b>
</>
)
}
if (polled.error?.kind === 'not-found') return <>CLI not found</>
if (polled.loading) return <>scanning</>
return <></>
}
function SectionPlaceholder({ title }: { title: string }) {
return (
<Panel title={title}>
<EmptyNote>{title} lands in a later task. The shell, data bridge, and design system are in place.</EmptyNote>
</Panel>
)
}
/** App-wide daily-budget alert: reads today's usage from the overview payload and
* warns at >=80% / alerts at >=100% of the configured cap. Dismissible per day. */
function DailyBudgetBanner({ payload, provider }: { payload: MenubarPayload | null; provider: string }) {
const [, bumpDismiss] = useState(0)
const budget = readDailyBudget()
if (!budget || !payload) return null
// Token totals in history.daily are zeroed under a specific-provider filter
// (only cost is per-provider), so a token cap can only be evaluated honestly on
// the all-providers view; otherwise we'd compare usage against a false zero.
if (budget.kind === 'tokens' && provider !== 'all') return null
const todayKey = localDateKey(new Date())
let dismissed: string | null = null
try { dismissed = globalThis.localStorage?.getItem('codeburn.dailyBudget.dismissed') ?? null } catch { /* storage can be unavailable */ }
if (dismissed === todayKey) return null
// Today's entry may be absent when there has been no activity yet: that's 0 used.
const entry = payload.history.daily.find(day => day.date === todayKey)
const used = budget.kind === 'usd'
? entry?.cost ?? 0
: entry ? entry.inputTokens + entry.outputTokens : 0
const percent = (used / budget.value) * 100
if (percent < 80) return null
const exceeded = percent >= 100
const spent = budget.kind === 'usd' ? formatUsd(used) : formatCompact(used)
const cap = budget.kind === 'usd' ? formatUsd(budget.value) : formatCompact(budget.value)
const text = exceeded
? `Daily budget exceeded: ${spent} of ${cap}`
: `Today's spend is at ${Math.floor(percent)}% of your daily budget`
const dismiss = () => {
try { globalThis.localStorage?.setItem('codeburn.dailyBudget.dismissed', todayKey) } catch { /* storage can be unavailable */ }
bumpDismiss(tick => tick + 1)
}
return (
<div role="status" className={exceeded ? 'budget-banner exceeded' : 'budget-banner'}>
<span>{text}</span>
<button type="button" className="set-text-button" onClick={dismiss}>Dismiss</button>
</div>
)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 696 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="257" preserveAspectRatio="xMidYMid" viewBox="0 0 256 257"><path fill="#D97757" d="m50.228 170.321 50.357-28.257.843-2.463-.843-1.361h-2.462l-8.426-.518-28.775-.778-24.952-1.037-24.175-1.296-6.092-1.297L0 125.796l.583-3.759 5.12-3.434 7.324.648 16.202 1.101 24.304 1.685 17.629 1.037 26.118 2.722h4.148l.583-1.685-1.426-1.037-1.101-1.037-25.147-17.045-27.22-18.017-14.258-10.37-7.713-5.25-3.888-4.925-1.685-10.758 7-7.713 9.397.649 2.398.648 9.527 7.323 20.35 15.75L94.817 91.9l3.889 3.24 1.555-1.102.195-.777-1.75-2.917-14.453-26.118-15.425-26.572-6.87-11.018-1.814-6.61c-.648-2.723-1.102-4.991-1.102-7.778l7.972-10.823L71.42 0 82.05 1.426l4.472 3.888 6.61 15.101 10.694 23.786 16.591 32.34 4.861 9.592 2.592 8.879.973 2.722h1.685v-1.556l1.36-18.211 2.528-22.36 2.463-28.776.843-8.1 4.018-9.722 7.971-5.25 6.222 2.981 5.12 7.324-.713 4.73-3.046 19.768-5.962 30.98-3.889 20.739h2.268l2.593-2.593 10.499-13.934 17.628-22.036 7.778-8.749 9.073-9.657 5.833-4.601h11.018l8.1 12.055-3.628 12.443-11.342 14.388-9.398 12.184-13.48 18.147-8.426 14.518.778 1.166 2.01-.194 30.46-6.481 16.462-2.982 19.637-3.37 8.88 4.148.971 4.213-3.5 8.62-20.998 5.184-24.628 4.926-36.682 8.685-.454.324.519.648 16.526 1.555 7.065.389h17.304l32.21 2.398 8.426 5.574 5.055 6.805-.843 5.184-12.962 6.611-17.498-4.148-40.83-9.721-14-3.5h-1.944v1.167l11.666 11.406 21.387 19.314 26.767 24.887 1.36 6.157-3.434 4.86-3.63-.518-23.526-17.693-9.073-7.972-20.545-17.304h-1.36v1.814l4.73 6.935 25.017 37.59 1.296 11.536-1.814 3.76-6.481 2.268-7.13-1.297-14.647-20.544-15.1-23.138-12.185-20.739-1.49.843-7.194 77.448-3.37 3.953-7.778 2.981-6.48-4.925-3.436-7.972 3.435-15.749 4.148-20.544 3.37-16.333 3.046-20.285 1.815-6.74-.13-.454-1.49.194-15.295 20.999-23.267 31.433-18.406 19.702-4.407 1.75-7.648-3.954.713-7.064 4.277-6.286 25.47-32.405 15.36-20.092 9.917-11.6-.065-1.686h-.583L44.07 198.125l-12.055 1.555-5.185-4.86.648-7.972 2.463-2.593 20.35-13.999-.064.065Z"/></svg>

After

Width:  |  Height:  |  Size: 2 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid" viewBox="0 0 256 208"><path fill="#fff" d="M205.3 31.4c14 14.8 20 35.2 22.5 63.6 6.6 0 12.8 1.5 17 7.2l7.8 10.6c2.2 3 3.4 6.6 3.4 10.4v28.7a12 12 0 0 1-4.8 9.5C215.9 187.2 172.3 208 128 208c-49 0-98.2-28.3-123.2-46.6a12 12 0 0 1-4.8-9.5v-28.7c0-3.8 1.2-7.4 3.4-10.5l7.8-10.5c4.2-5.7 10.4-7.2 17-7.2 2.5-28.4 8.4-48.8 22.5-63.6C77.3 3.2 112.6 0 127.6 0h.4c14.7 0 50.4 2.9 77.3 31.4ZM128 78.7c-3 0-6.5.2-10.3.6a27.1 27.1 0 0 1-6 12.1 45 45 0 0 1-32 13c-6.8 0-13.9-1.5-19.7-5.2-5.5 1.9-10.8 4.5-11.2 11-.5 12.2-.6 24.5-.6 36.8 0 6.1 0 12.3-.2 18.5 0 3.6 2.2 6.9 5.5 8.4C79.9 185.9 105 192 128 192s48-6 74.5-18.1a9.4 9.4 0 0 0 5.5-8.4c.3-18.4 0-37-.8-55.3-.4-6.6-5.7-9.1-11.2-11-5.8 3.7-13 5.1-19.7 5.1a45 45 0 0 1-32-12.9 27.1 27.1 0 0 1-6-12.1c-3.4-.4-6.9-.5-10.3-.6Zm-27 44c5.8 0 10.5 4.6 10.5 10.4v19.2a10.4 10.4 0 0 1-20.8 0V133c0-5.8 4.6-10.4 10.4-10.4Zm53.4 0c5.8 0 10.4 4.6 10.4 10.4v19.2a10.4 10.4 0 0 1-20.8 0V133c0-5.8 4.7-10.4 10.4-10.4Zm-73-94.4c-11.2 1.1-20.6 4.8-25.4 10-10.4 11.3-8.2 40.1-2.2 46.2A31.2 31.2 0 0 0 75 91.7c6.8 0 19.6-1.5 30.1-12.2 4.7-4.5 7.5-15.7 7.2-27-.3-9.1-2.9-16.7-6.7-19.9-4.2-3.6-13.6-5.2-24.2-4.3Zm69 4.3c-3.8 3.2-6.4 10.8-6.7 19.9-.3 11.3 2.5 22.5 7.2 27a41.7 41.7 0 0 0 30 12.2c8.9 0 17-2.9 21.3-7.2 6-6.1 8.2-34.9-2.2-46.3-4.8-5-14.2-8.8-25.4-9.9-10.6-1-20 .7-24.2 4.3ZM128 56c-2.6 0-5.6.2-9 .5.4 1.7.5 3.7.7 5.7 0 1.5 0 3-.2 4.5 3.2-.3 6-.3 8.5-.3 2.6 0 5.3 0 8.5.3-.2-1.6-.2-3-.2-4.5.2-2 .3-4 .7-5.7-3.4-.3-6.4-.5-9-.5Z"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid" viewBox="0 0 256 208"><path d="M205.3 31.4c14 14.8 20 35.2 22.5 63.6 6.6 0 12.8 1.5 17 7.2l7.8 10.6c2.2 3 3.4 6.6 3.4 10.4v28.7a12 12 0 0 1-4.8 9.5C215.9 187.2 172.3 208 128 208c-49 0-98.2-28.3-123.2-46.6a12 12 0 0 1-4.8-9.5v-28.7c0-3.8 1.2-7.4 3.4-10.5l7.8-10.5c4.2-5.7 10.4-7.2 17-7.2 2.5-28.4 8.4-48.8 22.5-63.6C77.3 3.2 112.6 0 127.6 0h.4c14.7 0 50.4 2.9 77.3 31.4ZM128 78.7c-3 0-6.5.2-10.3.6a27.1 27.1 0 0 1-6 12.1 45 45 0 0 1-32 13c-6.8 0-13.9-1.5-19.7-5.2-5.5 1.9-10.8 4.5-11.2 11-.5 12.2-.6 24.5-.6 36.8 0 6.1 0 12.3-.2 18.5 0 3.6 2.2 6.9 5.5 8.4C79.9 185.9 105 192 128 192s48-6 74.5-18.1a9.4 9.4 0 0 0 5.5-8.4c.3-18.4 0-37-.8-55.3-.4-6.6-5.7-9.1-11.2-11-5.8 3.7-13 5.1-19.7 5.1a45 45 0 0 1-32-12.9 27.1 27.1 0 0 1-6-12.1c-3.4-.4-6.9-.5-10.3-.6Zm-27 44c5.8 0 10.5 4.6 10.5 10.4v19.2a10.4 10.4 0 0 1-20.8 0V133c0-5.8 4.6-10.4 10.4-10.4Zm53.4 0c5.8 0 10.4 4.6 10.4 10.4v19.2a10.4 10.4 0 0 1-20.8 0V133c0-5.8 4.7-10.4 10.4-10.4Zm-73-94.4c-11.2 1.1-20.6 4.8-25.4 10-10.4 11.3-8.2 40.1-2.2 46.2A31.2 31.2 0 0 0 75 91.7c6.8 0 19.6-1.5 30.1-12.2 4.7-4.5 7.5-15.7 7.2-27-.3-9.1-2.9-16.7-6.7-19.9-4.2-3.6-13.6-5.2-24.2-4.3Zm69 4.3c-3.8 3.2-6.4 10.8-6.7 19.9-.3 11.3 2.5 22.5 7.2 27a41.7 41.7 0 0 0 30 12.2c8.9 0 17-2.9 21.3-7.2 6-6.1 8.2-34.9-2.2-46.3-4.8-5-14.2-8.8-25.4-9.9-10.6-1-20 .7-24.2 4.3ZM128 56c-2.6 0-5.6.2-9 .5.4 1.7.5 3.7.7 5.7 0 1.5 0 3-.2 4.5 3.2-.3 6-.3 8.5-.3 2.6 0 5.3 0 8.5.3-.2-1.6-.2-3-.2-4.5.2-2 .3-4 .7-5.7-3.4-.3-6.4-.5-9-.5Z"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Ebene_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 466.73 532.09">
<!-- Generator: Adobe Illustrator 29.6.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 9) -->
<defs>
<style>
.st0 {
fill: #edecec;
}
</style>
</defs>
<path class="st0" d="M457.43,125.94L244.42,2.96c-6.84-3.95-15.28-3.95-22.12,0L9.3,125.94c-5.75,3.32-9.3,9.46-9.3,16.11v247.99c0,6.65,3.55,12.79,9.3,16.11l213.01,122.98c6.84,3.95,15.28,3.95,22.12,0l213.01-122.98c5.75-3.32,9.3-9.46,9.3-16.11v-247.99c0-6.65-3.55-12.79-9.3-16.11h-.01ZM444.05,151.99l-205.63,356.16c-1.39,2.4-5.06,1.42-5.06-1.36v-233.21c0-4.66-2.49-8.97-6.53-11.31L24.87,145.67c-2.4-1.39-1.42-5.06,1.36-5.06h411.26c5.84,0,9.49,6.33,6.57,11.39h-.01Z"/>
</svg>

After

Width:  |  Height:  |  Size: 793 B

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Ebene_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 466.73 532.09">
<!-- Generator: Adobe Illustrator 29.6.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 9) -->
<defs>
<style>
.st0 {
fill: #26251e;
}
</style>
</defs>
<path class="st0" d="M457.43,125.94L244.42,2.96c-6.84-3.95-15.28-3.95-22.12,0L9.3,125.94c-5.75,3.32-9.3,9.46-9.3,16.11v247.99c0,6.65,3.55,12.79,9.3,16.11l213.01,122.98c6.84,3.95,15.28,3.95,22.12,0l213.01-122.98c5.75-3.32,9.3-9.46,9.3-16.11v-247.99c0-6.65-3.55-12.79-9.3-16.11h-.01ZM444.05,151.99l-205.63,356.16c-1.39,2.4-5.06,1.42-5.06-1.36v-233.21c0-4.66-2.49-8.97-6.53-11.31L24.87,145.67c-2.4-1.39-1.42-5.06,1.36-5.06h411.26c5.84,0,9.49,6.33,6.57,11.39h-.01Z"/>
</svg>

After

Width:  |  Height:  |  Size: 793 B

View file

@ -0,0 +1 @@
<svg viewBox="0 0 296 298" xmlns="http://www.w3.org/2000/svg" width="296" height="298" fill="none"><mask id="a" width="296" height="298" x="0" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#3186FF" d="M141.201 4.886c2.282-6.17 11.042-6.071 13.184.148l5.985 17.37a184.004 184.004 0 0 0 111.257 113.049l19.304 6.997c6.143 2.227 6.156 10.91.02 13.155l-19.35 7.082a184.001 184.001 0 0 0-109.495 109.385l-7.573 20.629c-2.241 6.105-10.869 6.121-13.133.025l-7.908-21.296a184 184 0 0 0-109.02-108.658l-19.698-7.239c-6.102-2.243-6.118-10.867-.025-13.132l20.083-7.467A183.998 183.998 0 0 0 133.291 26.28l7.91-21.394Z"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="163" cy="149" fill="#3689FF" rx="196" ry="159"/></g><g filter="url(#c)"><ellipse cx="33.5" cy="142.5" fill="#F6C013" rx="68.5" ry="72.5"/></g><g filter="url(#d)"><ellipse cx="19.5" cy="148.5" fill="#F6C013" rx="68.5" ry="72.5"/></g><g filter="url(#e)"><path fill="#FA4340" d="M194 10.5C172 82.5 65.5 134.333 22.5 135L144-66l50 76.5Z"/></g><g filter="url(#f)"><path fill="#FA4340" d="M190.5-12.5C168.5 59.5 62 111.333 19 112L140.5-89l50 76.5Z"/></g><g filter="url(#g)"><path fill="#14BB69" d="M194.5 279.5C172.5 207.5 66 155.667 23 155l121.5 201 50-76.5Z"/></g><g filter="url(#h)"><path fill="#14BB69" d="M196.5 320.5C174.5 248.5 68 196.667 25 196l121.5 201 50-76.5Z"/></g></g><defs><filter id="b" width="464" height="390" x="-69" y="-46" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_69_17998" stdDeviation="18"/></filter><filter id="c" width="265" height="273" x="-99" y="6" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_69_17998" stdDeviation="32"/></filter><filter id="d" width="265" height="273" x="-113" y="12" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_69_17998" stdDeviation="32"/></filter><filter id="e" width="299.5" height="329" x="-41.5" y="-130" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_69_17998" stdDeviation="32"/></filter><filter id="f" width="299.5" height="329" x="-45" y="-153" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_69_17998" stdDeviation="32"/></filter><filter id="g" width="299.5" height="329" x="-41" y="91" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_69_17998" stdDeviation="32"/></filter><filter id="h" width="299.5" height="329" x="-39" y="132" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_69_17998" stdDeviation="32"/></filter></defs></svg>

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

View file

@ -0,0 +1,4 @@
<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M395.479 633.828L735.91 381.105C752.599 368.715 776.454 373.548 784.406 392.792C826.26 494.285 807.561 616.253 724.288 699.996C641.016 783.739 525.151 802.104 419.247 760.277L303.556 814.143C469.49 928.202 670.987 899.995 796.901 773.282C896.776 672.843 927.708 535.937 898.785 412.476L899.047 412.739C857.105 231.37 909.358 158.874 1016.4 10.6326C1018.93 7.11771 1021.47 3.60279 1024 0L883.144 141.651V141.212L395.392 633.916" fill="white"/>
<path d="M325.226 695.251C206.128 580.84 226.662 403.776 328.285 301.668C403.431 226.097 526.549 195.254 634.026 240.596L749.454 186.994C728.657 171.88 702.007 155.623 671.424 144.2C533.19 86.9942 367.693 115.465 255.323 228.382C147.234 337.081 113.244 504.215 171.613 646.833C215.216 753.423 143.739 828.818 71.7385 904.916C46.2237 931.893 20.6216 958.87 0 987.429L325.139 695.339" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 961 B

View file

@ -0,0 +1,4 @@
<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M395.479 633.828L735.91 381.105C752.599 368.715 776.454 373.548 784.406 392.792C826.26 494.285 807.561 616.253 724.288 699.996C641.016 783.739 525.151 802.104 419.247 760.277L303.556 814.143C469.49 928.202 670.987 899.995 796.901 773.282C896.776 672.843 927.708 535.937 898.785 412.476L899.047 412.739C857.105 231.37 909.358 158.874 1016.4 10.6326C1018.93 7.11771 1021.47 3.60279 1024 0L883.144 141.651V141.212L395.392 633.916" fill="#0A0A0A"/>
<path d="M325.226 695.251C206.128 580.84 226.662 403.776 328.285 301.668C403.431 226.097 526.549 195.254 634.026 240.596L749.454 186.994C728.657 171.88 702.007 155.623 671.424 144.2C533.19 86.9942 367.693 115.465 255.323 228.382C147.234 337.081 113.244 504.215 171.613 646.833C215.216 753.423 143.739 828.818 71.7385 904.916C46.2237 931.893 20.6216 958.87 0 987.429L325.139 695.339" fill="#0A0A0A"/>
</svg>

After

Width:  |  Height:  |  Size: 965 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="260" preserveAspectRatio="xMidYMid" viewBox="0 0 256 260"><path fill="#fff" d="M239.184 106.203a64.716 64.716 0 0 0-5.576-53.103C219.452 28.459 191 15.784 163.213 21.74A65.586 65.586 0 0 0 52.096 45.22a64.716 64.716 0 0 0-43.23 31.36c-14.31 24.602-11.061 55.634 8.033 76.74a64.665 64.665 0 0 0 5.525 53.102c14.174 24.65 42.644 37.324 70.446 31.36a64.72 64.72 0 0 0 48.754 21.744c28.481.025 53.714-18.361 62.414-45.481a64.767 64.767 0 0 0 43.229-31.36c14.137-24.558 10.875-55.423-8.083-76.483Zm-97.56 136.338a48.397 48.397 0 0 1-31.105-11.255l1.535-.87 51.67-29.825a8.595 8.595 0 0 0 4.247-7.367v-72.85l21.845 12.636c.218.111.37.32.409.563v60.367c-.056 26.818-21.783 48.545-48.601 48.601Zm-104.466-44.61a48.345 48.345 0 0 1-5.781-32.589l1.534.921 51.722 29.826a8.339 8.339 0 0 0 8.441 0l63.181-36.425v25.221a.87.87 0 0 1-.358.665l-52.335 30.184c-23.257 13.398-52.97 5.431-66.404-17.803ZM23.549 85.38a48.499 48.499 0 0 1 25.58-21.333v61.39a8.288 8.288 0 0 0 4.195 7.316l62.874 36.272-21.845 12.636a.819.819 0 0 1-.767 0L41.353 151.53c-23.211-13.454-31.171-43.144-17.804-66.405v.256Zm179.466 41.695-63.08-36.63L161.73 77.86a.819.819 0 0 1 .768 0l52.233 30.184a48.6 48.6 0 0 1-7.316 87.635v-61.391a8.544 8.544 0 0 0-4.4-7.213Zm21.742-32.69-1.535-.922-51.619-30.081a8.39 8.39 0 0 0-8.492 0L99.98 99.808V74.587a.716.716 0 0 1 .307-.665l52.233-30.133a48.652 48.652 0 0 1 72.236 50.391v.205ZM88.061 139.097l-21.845-12.585a.87.87 0 0 1-.41-.614V65.685a48.652 48.652 0 0 1 79.757-37.346l-1.535.87-51.67 29.825a8.595 8.595 0 0 0-4.246 7.367l-.051 72.697Zm11.868-25.58 28.138-16.217 28.188 16.218v32.434l-28.086 16.218-28.188-16.218-.052-32.434Z"/></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="260" preserveAspectRatio="xMidYMid" viewBox="0 0 256 260"><path d="M239.184 106.203a64.716 64.716 0 0 0-5.576-53.103C219.452 28.459 191 15.784 163.213 21.74A65.586 65.586 0 0 0 52.096 45.22a64.716 64.716 0 0 0-43.23 31.36c-14.31 24.602-11.061 55.634 8.033 76.74a64.665 64.665 0 0 0 5.525 53.102c14.174 24.65 42.644 37.324 70.446 31.36a64.72 64.72 0 0 0 48.754 21.744c28.481.025 53.714-18.361 62.414-45.481a64.767 64.767 0 0 0 43.229-31.36c14.137-24.558 10.875-55.423-8.083-76.483Zm-97.56 136.338a48.397 48.397 0 0 1-31.105-11.255l1.535-.87 51.67-29.825a8.595 8.595 0 0 0 4.247-7.367v-72.85l21.845 12.636c.218.111.37.32.409.563v60.367c-.056 26.818-21.783 48.545-48.601 48.601Zm-104.466-44.61a48.345 48.345 0 0 1-5.781-32.589l1.534.921 51.722 29.826a8.339 8.339 0 0 0 8.441 0l63.181-36.425v25.221a.87.87 0 0 1-.358.665l-52.335 30.184c-23.257 13.398-52.97 5.431-66.404-17.803ZM23.549 85.38a48.499 48.499 0 0 1 25.58-21.333v61.39a8.288 8.288 0 0 0 4.195 7.316l62.874 36.272-21.845 12.636a.819.819 0 0 1-.767 0L41.353 151.53c-23.211-13.454-31.171-43.144-17.804-66.405v.256Zm179.466 41.695-63.08-36.63L161.73 77.86a.819.819 0 0 1 .768 0l52.233 30.184a48.6 48.6 0 0 1-7.316 87.635v-61.391a8.544 8.544 0 0 0-4.4-7.213Zm21.742-32.69-1.535-.922-51.619-30.081a8.39 8.39 0 0 0-8.492 0L99.98 99.808V74.587a.716.716 0 0 1 .307-.665l52.233-30.133a48.652 48.652 0 0 1 72.236 50.391v.205ZM88.061 139.097l-21.845-12.585a.87.87 0 0 1-.41-.614V65.685a48.652 48.652 0 0 1 79.757-37.346l-1.535.87-51.67 29.825a8.595 8.595 0 0 0-4.246 7.367l-.051 72.697Zm11.868-25.58 28.138-16.217 28.188 16.218v32.434l-28.086 16.218-28.188-16.218-.052-32.434Z"/></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View file

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" width="512" height="512"><svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="512" height="512" fill="#131010"></rect>
<path d="M320 224V352H192V224H320Z" fill="#5A5858"></path>
<path fill-rule="evenodd" clip-rule="evenodd" d="M384 416H128V96H384V416ZM320 160H192V352H320V160Z" fill="white"></path>
</svg><style>@media (prefers-color-scheme: light) { :root { filter: none; } }
@media (prefers-color-scheme: dark) { :root { filter: none; } }
</style></svg>

After

Width:  |  Height:  |  Size: 612 B

View file

@ -0,0 +1,6 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="512" height="512" fill="#FDFCFC"/>
<path d="M320 224V352H192V224H320Z" fill="#E6E5E6"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M384 416H128V96H384V416ZM320 160H192V352H320V160Z" fill="#17181C"/>
</svg>

After

Width:  |  Height:  |  Size: 325 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 316 B

View file

@ -0,0 +1 @@
<svg fill="#ffff" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Qwen</title><path d="M12.604 1.34c.393.69.784 1.382 1.174 2.075a.18.18 0 00.157.091h5.552c.174 0 .322.11.446.327l1.454 2.57c.19.337.24.478.024.837-.26.43-.513.864-.76 1.3l-.367.658c-.106.196-.223.28-.04.512l2.652 4.637c.172.301.111.494-.043.77-.437.785-.882 1.564-1.335 2.34-.159.272-.352.375-.68.37-.777-.016-1.552-.01-2.327.016a.099.099 0 00-.081.05 575.097 575.097 0 01-2.705 4.74c-.169.293-.38.363-.725.364-.997.003-2.002.004-3.017.002a.537.537 0 01-.465-.271l-1.335-2.323a.09.09 0 00-.083-.049H4.982c-.285.03-.553-.001-.805-.092l-1.603-2.77a.543.543 0 01-.002-.54l1.207-2.12a.198.198 0 000-.197 550.951 550.951 0 01-1.875-3.272l-.79-1.395c-.16-.31-.173-.496.095-.965.465-.813.927-1.625 1.387-2.436.132-.234.304-.334.584-.335a338.3 338.3 0 012.589-.001.124.124 0 00.107-.063l2.806-4.895a.488.488 0 01.422-.246c.524-.001 1.053 0 1.583-.006L11.704 1c.341-.003.724.032.9.34zm-3.432.403a.06.06 0 00-.052.03L6.254 6.788a.157.157 0 01-.135.078H3.253c-.056 0-.07.025-.041.074l5.81 10.156c.025.042.013.062-.034.063l-2.795.015a.218.218 0 00-.2.116l-1.32 2.31c-.044.078-.021.118.068.118l5.716.008c.046 0 .08.02.104.061l1.403 2.454c.046.081.092.082.139 0l5.006-8.76.783-1.382a.055.055 0 01.096 0l1.424 2.53a.122.122 0 00.107.062l2.763-.02a.04.04 0 00.035-.02.041.041 0 000-.04l-2.9-5.086a.108.108 0 010-.113l.293-.507 1.12-1.977c.024-.041.012-.062-.035-.062H9.2c-.059 0-.073-.026-.043-.077l1.434-2.505a.107.107 0 000-.114L9.225 1.774a.06.06 0 00-.053-.031zm6.29 8.02c.046 0 .058.02.034.06l-.832 1.465-2.613 4.585a.056.056 0 01-.05.029.058.058 0 01-.05-.029L8.498 9.841c-.02-.034-.01-.052.028-.054l.216-.012 6.722-.012z"></path></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View file

@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Qwen</title><path d="M12.604 1.34c.393.69.784 1.382 1.174 2.075a.18.18 0 00.157.091h5.552c.174 0 .322.11.446.327l1.454 2.57c.19.337.24.478.024.837-.26.43-.513.864-.76 1.3l-.367.658c-.106.196-.223.28-.04.512l2.652 4.637c.172.301.111.494-.043.77-.437.785-.882 1.564-1.335 2.34-.159.272-.352.375-.68.37-.777-.016-1.552-.01-2.327.016a.099.099 0 00-.081.05 575.097 575.097 0 01-2.705 4.74c-.169.293-.38.363-.725.364-.997.003-2.002.004-3.017.002a.537.537 0 01-.465-.271l-1.335-2.323a.09.09 0 00-.083-.049H4.982c-.285.03-.553-.001-.805-.092l-1.603-2.77a.543.543 0 01-.002-.54l1.207-2.12a.198.198 0 000-.197 550.951 550.951 0 01-1.875-3.272l-.79-1.395c-.16-.31-.173-.496.095-.965.465-.813.927-1.625 1.387-2.436.132-.234.304-.334.584-.335a338.3 338.3 0 012.589-.001.124.124 0 00.107-.063l2.806-4.895a.488.488 0 01.422-.246c.524-.001 1.053 0 1.583-.006L11.704 1c.341-.003.724.032.9.34zm-3.432.403a.06.06 0 00-.052.03L6.254 6.788a.157.157 0 01-.135.078H3.253c-.056 0-.07.025-.041.074l5.81 10.156c.025.042.013.062-.034.063l-2.795.015a.218.218 0 00-.2.116l-1.32 2.31c-.044.078-.021.118.068.118l5.716.008c.046 0 .08.02.104.061l1.403 2.454c.046.081.092.082.139 0l5.006-8.76.783-1.382a.055.055 0 01.096 0l1.424 2.53a.122.122 0 00.107.062l2.763-.02a.04.04 0 00.035-.02.041.041 0 000-.04l-2.9-5.086a.108.108 0 010-.113l.293-.507 1.12-1.977c.024-.041.012-.062-.035-.062H9.2c-.059 0-.073-.026-.043-.077l1.434-2.505a.107.107 0 000-.114L9.225 1.774a.06.06 0 00-.053-.031zm6.29 8.02c.046 0 .058.02.034.06l-.832 1.465-2.613 4.585a.056.056 0 01-.05.029.058.058 0 01-.05-.029L8.498 9.841c-.02-.034-.01-.052.028-.054l.216-.012 6.722-.012z"></path></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5 KiB

Binary file not shown.

View file

@ -0,0 +1,79 @@
import { useEffect, type MouseEvent, type ReactNode } from 'react'
import { version } from '../../package.json'
import { FlameMark } from './FlameMark'
import { codeburn } from '../lib/ipc'
export type SocialLink = {
label: string
url: string
icon: ReactNode
}
const RELEASES_URL = 'https://github.com/getagentseal/codeburn/releases'
function openExternal(event: MouseEvent<HTMLAnchorElement>, url: string): void {
event.preventDefault()
void codeburn.openExternal(url)
}
export function AboutModal({ socials, onClose }: { socials: SocialLink[]; onClose: () => void }) {
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') onClose()
}
document.addEventListener('keydown', onKeyDown)
return () => document.removeEventListener('keydown', onKeyDown)
}, [onClose])
return (
<div className="about-modal-backdrop" onClick={onClose}>
<div
className="about-modal"
role="dialog"
aria-modal="true"
aria-labelledby="about-modal-title"
onClick={event => event.stopPropagation()}
>
<button className="about-modal-close" type="button" aria-label="Close About" onClick={onClose}>×</button>
<div className="about-modal-grid">
<div className="about-modal-hero">
<span className="about-modal-logo" aria-hidden="true"><FlameMark size={52} /></span>
<div className="about-modal-name" id="about-modal-title">CodeBurn</div>
<div className="about-modal-version">v{version}</div>
<div className="about-modal-tagline">Know where every token goes, across every AI coding tool.</div>
</div>
<div className="about-modal-side">
<div className="about-modal-section">
<div className="about-modal-section-title">Links</div>
{socials.map(social => (
<a
className="about-modal-link"
href={social.url}
key={social.label}
onClick={event => openExternal(event, social.url)}
>
{social.icon}
<span>{social.label}</span>
<span className="about-modal-external" aria-hidden="true"></span>
</a>
))}
</div>
<div className="about-modal-section about-modal-updates">
<div className="about-modal-section-title">Updates</div>
<button
className="about-modal-update-button"
type="button"
onClick={() => { void codeburn.openExternal(RELEASES_URL) }}
>
Check for updates
</button>
</div>
</div>
</div>
<div className="about-modal-credit">Developed by Resham Joshi · github.com/iamtoruk</div>
</div>
</div>
)
}

View file

@ -0,0 +1,158 @@
import { useLayoutEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { formatUsd } from '../lib/format'
import { localDateKey } from '../lib/period'
import type { DailyHistoryEntry } from '../lib/types'
type HeatmapDay = {
date: string
cost: number
calls: number
level: number
isFuture: boolean
}
const WEEK_COUNT = 26
const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
function dateFromKey(key: string): Date {
const [year, month, day] = key.split('-').map(Number)
return new Date(year, month - 1, day)
}
function formatDate(key: string): string {
return dateFromKey(key).toLocaleString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})
}
function intensityLevel(cost: number, maxCost: number): number {
if (cost <= 0 || maxCost <= 0) return 0
const ratio = Math.min(1, cost / maxCost)
if (ratio < 0.25) return 1
if (ratio < 0.5) return 2
if (ratio < 0.75) return 3
return 4
}
function buildHeatmapDays(daily: DailyHistoryEntry[], now: Date): HeatmapDay[] {
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const startOfWeek = new Date(today)
startOfWeek.setDate(today.getDate() - today.getDay())
const firstDay = new Date(startOfWeek)
firstDay.setDate(startOfWeek.getDate() - (WEEK_COUNT - 1) * 7)
const byDate = new Map(daily.map(day => [day.date, day]))
const visibleCosts: number[] = []
for (let offset = 0; offset < WEEK_COUNT * 7; offset++) {
const date = new Date(firstDay)
date.setDate(firstDay.getDate() + offset)
if (date <= today) visibleCosts.push(byDate.get(localDateKey(date))?.cost ?? 0)
}
const maxCost = Math.max(...visibleCosts, 0)
return Array.from({ length: WEEK_COUNT * 7 }, (_, offset) => {
const date = new Date(firstDay)
date.setDate(firstDay.getDate() + offset)
const isFuture = date > today
const entry = byDate.get(localDateKey(date))
const cost = isFuture ? 0 : (entry?.cost ?? 0)
return {
date: localDateKey(date),
cost,
calls: isFuture ? 0 : (entry?.calls ?? 0),
level: intensityLevel(cost, maxCost),
isFuture,
}
})
}
export function ActivityHeatmap({ daily, bare = false }: { daily: DailyHistoryEntry[]; bare?: boolean }) {
const days = useMemo(() => buildHeatmapDays(daily, new Date()), [daily])
const activeDays = days.filter(day => !day.isFuture && day.cost > 0).length
const [tip, setTip] = useState<{ day: HeatmapDay; x: number; y: number } | null>(null)
const [tipPosition, setTipPosition] = useState<{ left: number; top: number } | null>(null)
const tipRef = useRef<HTMLDivElement>(null)
useLayoutEffect(() => {
if (!tip) {
setTipPosition(null)
return
}
const width = tipRef.current?.offsetWidth ?? 180
const height = tipRef.current?.offsetHeight ?? 58
const gutter = 8
const cursorGap = 12
let left = tip.x + cursorGap
if (left + width > window.innerWidth - gutter) left = tip.x - width - cursorGap
left = Math.max(gutter, Math.min(left, window.innerWidth - width - gutter))
let top = tip.y - height - cursorGap
if (top < gutter) top = tip.y + cursorGap
top = Math.max(gutter, Math.min(top, window.innerHeight - height - gutter))
setTipPosition({ left, top })
}, [tip])
const head = (
<div className={bare ? 'ov-activity-head' : 'ov-panel-head'}>
{bare ? <span className="ov-label">Daily activity</span> : <h3>Daily activity</h3>}
<span className="r ov-active-days">{activeDays} active days</span>
</div>
)
const grid = (
<div className="ov-heatmap-scroll">
<div className="ov-heatmap" role="grid" aria-label="Daily activity contribution heatmap">
<div className="ov-heatmap-labels" aria-hidden="true">
{WEEKDAYS.map((weekday, index) => (
<span key={weekday}>{index === 1 || index === 3 || index === 5 ? weekday : ''}</span>
))}
</div>
<div className="ov-heatmap-cells">
{days.map(day => (
<button
type="button"
role="gridcell"
key={day.date}
className={`ov-heat-cell heat-level-${day.level}${day.isFuture ? ' future' : ''}`}
aria-label={`${formatDate(day.date)}: ${day.isFuture ? 'future day' : `${formatUsd(day.cost)}, ${day.calls} calls`}`}
data-date={day.date}
data-cost={day.cost}
data-active={!day.isFuture && day.cost > 0 ? 'true' : 'false'}
onMouseEnter={event => setTip({ day, x: event.clientX, y: event.clientY })}
onMouseMove={event => setTip({ day, x: event.clientX, y: event.clientY })}
onMouseLeave={() => setTip(null)}
/>
))}
</div>
</div>
</div>
)
const tooltip = tip
? createPortal(
<div
ref={tipRef}
className={`chart-tip${tipPosition ? ' on' : ''}`}
style={{ position: 'fixed', ...(tipPosition ?? { left: 0, top: 0 }) }}
role="tooltip"
>
<div className="chart-tip-d">{formatDate(tip.day.date)}</div>
<div className="chart-tip-v">{tip.day.isFuture ? 'Future day' : formatUsd(tip.day.cost)}</div>
<div className="chart-tip-s">{tip.day.isFuture ? 'No activity yet' : `${tip.day.calls} calls`}</div>
</div>,
document.body,
)
: null
if (bare) {
return <div className="ov-heatmap-bare">{head}{grid}{tooltip}</div>
}
return (
<div className="ov-card ov-panel ov-heatmap-panel">
{head}
<div className="ov-panel-body">{grid}</div>
{tooltip}
</div>
)
}

View file

@ -0,0 +1,59 @@
import { Panel } from './Panel'
import type { CliError } from '../lib/types'
export function isPermissionCliError(error: CliError | null): boolean {
return error?.kind === 'nonzero' && /permission|full disk access|eacces/i.test(error.message)
}
export function cliErrorDisplay(error: CliError): { title: string; message: string; tone: 'amber' | 'red' | 'muted' } {
if (error.kind === 'not-found') {
return {
title: 'Locate the codeburn CLI',
message: 'Install it with npm i -g codeburn, then reopen this window.',
tone: 'muted',
}
}
if (isPermissionCliError(error)) {
return {
title: 'Permission denied',
message: 'permission denied; grant Full Disk Access',
tone: 'amber',
}
}
return { title: "Couldn't read data", message: error.message, tone: 'red' }
}
function colorForTone(tone: 'amber' | 'red' | 'muted'): string {
if (tone === 'amber') return 'var(--warn)'
if (tone === 'red') return 'var(--bad)'
return 'var(--mut2)'
}
export function CliErrorText({ error }: { error: CliError }) {
const display = cliErrorDisplay(error)
return <p style={{ color: colorForTone(display.tone), margin: 0, fontSize: 12 }}>{display.message}</p>
}
export function CliErrorPanel({ error, subject = 'usage' }: { error: CliError; subject?: string }) {
const display = cliErrorDisplay(error)
if (error.kind === 'not-found') {
return (
<Panel title={display.title}>
<p style={{ color: 'var(--mut)', margin: '0 0 6px', fontSize: 12.5 }}>
CodeBurn Desktop reads {subject} by running the{' '}
<code style={{ fontFamily: 'var(--mono)', color: 'var(--accent)' }}>codeburn</code> command, but it isn&apos;t
on your PATH yet.
</p>
<p style={{ color: colorForTone(display.tone), margin: 0, fontSize: 11.5 }}>
Install it with <code style={{ fontFamily: 'var(--mono)', color: 'var(--accent)' }}>npm i -g codeburn</code>,
then reopen this window.
</p>
</Panel>
)
}
return (
<Panel title={display.title}>
<CliErrorText error={error} />
</Panel>
)
}

View file

@ -0,0 +1,41 @@
import { useState } from 'react'
import type { QuotaProvider } from '../lib/types'
// The exact terminal login command per provider. No interactive login is
// attempted from the app — we only show the command to copy and a Refresh.
const LOGIN: Record<QuotaProvider['provider'], { command: string; hint?: string }> = {
claude: { command: 'claude', hint: 'then type /login' },
codex: { command: 'codex login' },
}
/** Inline "Connect" affordance for a disconnected or access-denied provider: a
* short status line plus a text-button that expands the copy-paste login
* command, the keychain-Allow note (access-denied), and a forced Refresh. */
export function ConnectAffordance({ provider, connection, onRefresh }: {
provider: QuotaProvider['provider']
connection: 'disconnected' | 'accessDenied'
onRefresh: () => void
}) {
const [open, setOpen] = useState(false)
const name = provider === 'claude' ? 'Claude' : 'Codex'
const message = connection === 'accessDenied'
? 'Keychain access needed: click Allow when macOS asks, then Refresh.'
: `Not connected. Log in with the ${name} CLI.`
const login = LOGIN[provider]
return (
<div className="quota-connect">
<span className="quota-connection-note">{message}</span>
<button type="button" className="set-text-button quota-connect-toggle" aria-expanded={open} onClick={() => setOpen(value => !value)}>Connect</button>
{open && (
<div className="quota-connect-guide">
<p className="quota-connection-note">Sign in from a terminal, then Refresh:</p>
<p className="quota-connect-cmd"><code className="set-mono">{login.command}</code>{login.hint ? <span className="quota-connect-cmd-hint"> {login.hint}</span> : null}</p>
{connection === 'accessDenied' && <p className="quota-connection-note">Already logged in? Click Allow when macOS asks for keychain access.</p>}
<button type="button" className="set-text-button" onClick={onRefresh}>Refresh</button>
</div>
)}
</div>
)
}

View file

@ -0,0 +1,137 @@
import { useEffect, useRef, useState, type ReactNode } from 'react'
export type DropdownOption = { value: string; label: string }
export function Dropdown({
value,
options,
onChange,
ariaLabel,
id,
width,
renderIcon,
footer,
}: {
value: string
options: DropdownOption[]
onChange: (value: string) => void
ariaLabel: string
id: string
width?: React.CSSProperties['width']
/** Optional leading glyph (e.g. a provider logo) shown in the trigger and each option. */
renderIcon?: (value: string) => ReactNode
/** Optional non-interactive note pinned below the options in the open menu. */
footer?: ReactNode
}) {
const [open, setOpen] = useState(false)
const selectedIndex = Math.max(0, options.findIndex(option => option.value === value))
const [activeIndex, setActiveIndex] = useState(selectedIndex)
const wrapRef = useRef<HTMLDivElement>(null)
const triggerRef = useRef<HTMLButtonElement>(null)
const optionRefs = useRef<Array<HTMLButtonElement | null>>([])
const menuId = `${id}-menu`
const selected = options.find(option => option.value === value)
useEffect(() => {
if (!open) return
const onPointerDown = (event: MouseEvent) => {
if (!wrapRef.current?.contains(event.target as Node)) setOpen(false)
}
document.addEventListener('mousedown', onPointerDown)
return () => document.removeEventListener('mousedown', onPointerDown)
}, [open])
useEffect(() => {
if (open) optionRefs.current[activeIndex]?.focus()
}, [activeIndex, open])
const show = (index = selectedIndex) => {
setActiveIndex(index)
setOpen(true)
}
const close = (restoreFocus = false) => {
setOpen(false)
if (restoreFocus) triggerRef.current?.focus()
}
const choose = (index: number) => {
const option = options[index]
if (!option) return
onChange(option.value)
close(true)
}
const move = (offset: number) => {
if (options.length === 0) return
setActiveIndex(current => (current + offset + options.length) % options.length)
}
return (
<div className="pop-wrap dropdown" ref={wrapRef} style={{ width }}>
<button
id={id}
ref={triggerRef}
type="button"
className="pop dropdown-trigger"
aria-label={ariaLabel}
aria-haspopup="listbox"
aria-expanded={open}
aria-controls={menuId}
onClick={() => open ? close() : show()}
onKeyDown={event => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
if (open) choose(activeIndex)
else show()
} else if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault()
show(event.key === 'ArrowDown' ? selectedIndex : Math.max(0, options.length - 1))
} else if (event.key === 'Escape' && open) {
event.preventDefault()
close()
}
}}
>
{renderIcon?.(value)}
<span className="dropdown-label">{selected?.label ?? value}</span>
<span className="dropdown-chevron" aria-hidden="true"></span>
</button>
{open && (
<div id={menuId} className="pop-menu dropdown-menu" role="listbox" aria-label={ariaLabel}>
{options.map((option, index) => (
<button
key={option.value}
ref={node => { optionRefs.current[index] = node }}
type="button"
className={`pop-item${option.value === value ? ' on' : ''}`}
role="option"
aria-selected={option.value === value}
tabIndex={index === activeIndex ? 0 : -1}
onClick={() => choose(index)}
onFocus={() => setActiveIndex(index)}
onKeyDown={event => {
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault()
move(event.key === 'ArrowDown' ? 1 : -1)
} else if (event.key === 'Home' || event.key === 'End') {
event.preventDefault()
setActiveIndex(event.key === 'Home' ? 0 : options.length - 1)
} else if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
choose(index)
} else if (event.key === 'Escape') {
event.preventDefault()
close(true)
} else if (event.key === 'Tab') {
close()
}
}}
>
{renderIcon?.(option.value)}
{option.label}
</button>
))}
{footer && <div className="dropdown-foot">{footer}</div>}
</div>
)}
</div>
)
}

View file

@ -0,0 +1,6 @@
import type { ReactNode } from 'react'
/** The canonical empty/placeholder note: muted text at one size. */
export function EmptyNote({ children }: { children: ReactNode }) {
return <p className="empty-note">{children}</p>
}

View file

@ -0,0 +1,31 @@
// @vitest-environment jsdom
import { render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ErrorBoundary } from './ErrorBoundary'
function Boom(): never {
throw new Error('kaboom detail')
}
describe('ErrorBoundary', () => {
beforeEach(() => {
// React logs the caught error; silence it so the run stays clean.
vi.spyOn(console, 'error').mockImplementation(() => {})
})
afterEach(() => {
vi.restoreAllMocks()
})
it('renders children when nothing throws', () => {
render(<ErrorBoundary><p>all good</p></ErrorBoundary>)
expect(screen.getByText('all good')).toBeTruthy()
})
it('shows the error message instead of white-screening when a child throws', () => {
render(<ErrorBoundary><Boom /></ErrorBoundary>)
expect(screen.getByText('This screen hit an error')).toBeTruthy()
expect(screen.getByText('kaboom detail')).toBeTruthy()
expect(screen.getByRole('button', { name: 'Reload' })).toBeTruthy()
})
})

View file

@ -0,0 +1,40 @@
import { Component, type ErrorInfo, type ReactNode } from 'react'
type Props = { children: ReactNode }
type State = { error: Error | null; stack: string | null }
/**
* Catches render/lifecycle errors in a screen so a single crashing section
* shows a readable error instead of white-screening the whole app. Keyed by
* section in App, so navigating to another screen remounts and recovers.
*/
export class ErrorBoundary extends Component<Props, State> {
state: State = { error: null, stack: null }
static getDerivedStateFromError(error: Error): Partial<State> {
return { error }
}
componentDidCatch(error: Error, info: ErrorInfo): void {
// Surface to the renderer console for diagnosis in dev.
console.error('Screen crashed:', error, info.componentStack)
this.setState({ stack: info.componentStack ?? null })
}
render(): ReactNode {
const { error, stack } = this.state
if (!error) return this.props.children
return (
<div className="error-boundary">
<div className="panel error-card">
<h3 className="error-title">This screen hit an error</h3>
<p className="error-msg">{error.message || String(error)}</p>
{stack && <pre className="error-stack">{stack.trim()}</pre>}
<button className="btn" type="button" onClick={() => window.location.reload()}>
Reload
</button>
</div>
</div>
)
}
}

View file

@ -0,0 +1,31 @@
import { useMemo } from 'react'
import { motionEnabled } from '../lib/motion'
import flame from '../assets/flame.png'
/**
* Brand flame mark: the exact approved icon art (app/build/icon.png minus the
* squircle), so the splash, sidebar, About dialog and dock icon are literally
* one image. `live` gives the mark an all-but-imperceptible idle flicker; the
* phase is randomized once per mount so a row of flames never metronomes. All
* motion is gated by motionEnabled().
*/
export function FlameMark({ size = 20, live = false }: { size?: number; live?: boolean }) {
// Random negative delay so the loop starts mid-cycle at a different point each
// mount. Computed once; only takes effect when the flicker class is present.
const flickerStyle = useMemo(() => ({ animationDelay: `-${(Math.random() * 4 + 1).toFixed(2)}s` }), [])
const flicker = live && motionEnabled()
return (
<img
className={flicker ? 'flamemark fm-flicker' : 'flamemark'}
style={flicker ? flickerStyle : undefined}
src={flame}
width={size}
height={size}
alt=""
aria-hidden="true"
draggable={false}
/>
)
}

View file

@ -0,0 +1,18 @@
import type { ReactNode } from 'react'
export type HintItem = { k?: string; label: ReactNode }
/** The `.hint` footer strip: keycap hints on the left, optional right-aligned note. */
export function Hint({ items, right }: { items: HintItem[]; right?: ReactNode }) {
return (
<div className="hint">
{items.map((item, i) => (
<span key={i}>
{item.k && <span className="k">{item.k}</span>}
{item.label}
</span>
))}
{right !== undefined && <span className="r">{right}</span>}
</div>
)
}

View file

@ -0,0 +1,58 @@
import type { CSSProperties, KeyboardEvent, ReactNode } from 'react'
export { seriesColorForModel } from '../lib/modelSeries'
/**
* A `.li` list row: optional rank `.no`, optional model series `.mdot`, a
* title + sub line `.lx`, an optional right-aligned `.val`. When `onClick` is
* provided the row becomes a keyboard-operable button and shows the trailing
* chevron; without it the row is inert and the chevron is omitted (honest).
* Pass `expanded` on an interactive row to reflect its open/closed state
* (`aria-expanded`, which also rotates the chevron).
*/
export function ListRow({
no,
dotColor,
title,
sub,
value,
valueClass,
onClick,
expanded,
}: {
no?: ReactNode
dotColor?: string
title: ReactNode
sub?: ReactNode
value?: ReactNode
valueClass?: string
onClick?: () => void
expanded?: boolean
}) {
const dot: CSSProperties | undefined = dotColor ? { background: dotColor } : undefined
const interactive = onClick !== undefined
const onKeyDown = interactive
? (event: KeyboardEvent<HTMLDivElement>) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
onClick()
}
}
: undefined
return (
<div
className={interactive ? 'li li-clickable' : 'li'}
{...(interactive ? { role: 'button', tabIndex: 0, onClick, onKeyDown } : {})}
{...(interactive && expanded !== undefined ? { 'aria-expanded': expanded } : {})}
>
{no !== undefined && <span className="no">{no}</span>}
{dotColor !== undefined && <span className="mdot" style={dot} />}
<div className="lx">
<b>{title}</b>
{sub !== undefined && <span>{sub}</span>}
</div>
{value !== undefined && <span className={valueClass ? `val ${valueClass}` : 'val'}>{value}</span>}
{interactive && <span className="chev"></span>}
</div>
)
}

View file

@ -0,0 +1,31 @@
import type { ReactNode } from 'react'
/** Dash0-style card: optional `.phead` title strip + `.pbody` content. */
export function Panel({
title,
right,
rightLink,
className,
children,
}: {
title?: ReactNode
right?: ReactNode
/** Render the `right` slot as a lavender action link (`.r.link`, e.g. "See all "). */
rightLink?: boolean
className?: string
children?: ReactNode
}) {
return (
<div className={className ? `panel ${className}` : 'panel'}>
{title !== undefined && (
<div className="phead">
<b>{title}</b>
{right !== undefined && <span className={rightLink ? 'r link' : 'r'}>{right}</span>}
</div>
)}
<div className="pbody">
{children}
</div>
</div>
)
}

View file

@ -0,0 +1,51 @@
import claude from '../assets/providers/claude.svg'
import copilotDark from '../assets/providers/copilot-dark.svg'
import copilotLight from '../assets/providers/copilot-light.svg'
import cursorDark from '../assets/providers/cursor-dark.svg'
import cursorLight from '../assets/providers/cursor-light.svg'
import cursorAgent from '../assets/providers/cursor-agent.jpg'
import gemini from '../assets/providers/gemini.svg'
import goose from '../assets/providers/goose.png'
import grokDark from '../assets/providers/grok-dark.svg'
import grokLight from '../assets/providers/grok-light.svg'
import opencodeDark from '../assets/providers/opencode-dark.svg'
import opencodeLight from '../assets/providers/opencode-light.svg'
import openaiDark from '../assets/providers/openai-dark.svg'
import openaiLight from '../assets/providers/openai-light.svg'
import pi from '../assets/providers/pi.png'
import qwenDark from '../assets/providers/qwen-dark.svg'
import qwenLight from '../assets/providers/qwen-light.svg'
import warp from '../assets/providers/warp.jpg'
const SINGLE_LOGOS: Record<string, string> = {
claude,
'cursor-agent': cursorAgent,
gemini,
goose,
pi,
warp,
}
const THEMED_LOGOS: Record<string, { light: string; dark: string }> = {
codex: { light: openaiLight, dark: openaiDark },
copilot: { light: copilotLight, dark: copilotDark },
cursor: { light: cursorLight, dark: cursorDark },
grok: { light: grokLight, dark: grokDark },
opencode: { light: opencodeLight, dark: opencodeDark },
qwen: { light: qwenLight, dark: qwenDark },
}
export function ProviderLogo({ provider, size = 16 }: { provider: string; size?: number }) {
const singleLogo = SINGLE_LOGOS[provider]
if (singleLogo) {
return <img src={singleLogo} width={size} height={size} alt="" aria-hidden className="provider-logo" />
}
const logos = THEMED_LOGOS[provider]
if (!logos) return null
return <>
<img src={logos.light} width={size} height={size} alt="" aria-hidden className="provider-logo pl-light" />
<img src={logos.dark} width={size} height={size} alt="" aria-hidden className="provider-logo pl-dark" />
</>
}

View file

@ -0,0 +1,32 @@
import { Dropdown } from './Dropdown'
import { ProviderLogo } from './ProviderLogo'
export type ProviderOption = { value: string; label: string }
/**
* Provider selector built on the Dropdown listbox (roving tabindex, arrow-key
* navigation, one tab stop, focus-visible ring via `.dropdown-trigger`), with a
* provider logo rendered in the trigger and each option. `label` is retained for
* API compatibility; the Dropdown derives the display label from `options`.
*/
export function ProviderPop({
value,
options,
onSelect,
}: {
value: string
label: string
options: ProviderOption[]
onSelect: (value: string) => void
}) {
return (
<Dropdown
id="provider-select"
ariaLabel="Providers"
value={value}
options={options}
onChange={onSelect}
renderIcon={provider => <ProviderLogo provider={provider} />}
/>
)
}

View file

@ -0,0 +1,121 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import type { DateRange } from '../lib/types'
const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
function dateKey(date: Date): string {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
}
function normalize(from: string, to: string): DateRange {
return from <= to ? { from, to } : { from: to, to: from }
}
function addDays(date: Date, days: number): Date {
return new Date(date.getFullYear(), date.getMonth(), date.getDate() + days)
}
export function RangeCalendar({ value, onSelect }: { value: DateRange | null; onSelect: (range: DateRange) => void }) {
const today = useMemo(() => new Date(), [])
const todayKey = dateKey(today)
const [month, setMonth] = useState(() => new Date(today.getFullYear(), today.getMonth(), 1))
const [preview, setPreview] = useState<DateRange | null>(value)
const dragAnchor = useRef<string | null>(null)
const clickAnchor = useRef<string | null>(null)
useEffect(() => setPreview(value), [value])
useEffect(() => {
const finishDrag = () => {
dragAnchor.current = null
}
document.addEventListener('mouseup', finishDrag)
return () => document.removeEventListener('mouseup', finishDrag)
}, [])
const first = new Date(month.getFullYear(), month.getMonth(), 1)
const gridStart = addDays(first, -first.getDay())
const days = Array.from({ length: 42 }, (_, index) => addDays(gridStart, index))
const shown = preview ?? value
const commit = (from: string, to: string) => {
const range = normalize(from, to)
clickAnchor.current = null
dragAnchor.current = null
setPreview(range)
onSelect(range)
}
return (
<div className="range-calendar" aria-label="Date range calendar">
<div className="calendar-head">
<button
type="button"
className="calendar-nav"
aria-label="Previous month"
onClick={() => setMonth(current => new Date(current.getFullYear(), current.getMonth() - 1, 1))}
>
</button>
<strong>{month.toLocaleDateString('en-US', { month: 'long', year: 'numeric' })}</strong>
<button
type="button"
className="calendar-nav"
aria-label="Next month"
disabled={month.getFullYear() === today.getFullYear() && month.getMonth() === today.getMonth()}
onClick={() => setMonth(current => new Date(current.getFullYear(), current.getMonth() + 1, 1))}
>
</button>
</div>
<div className="calendar-grid">
{WEEKDAYS.map(day => <span className="calendar-weekday" key={day}>{day}</span>)}
{days.map(day => {
const key = dateKey(day)
const outside = day.getMonth() !== month.getMonth()
const disabled = key > todayKey
const endpoint = shown ? key === shown.from || key === shown.to : false
const inRange = shown ? key >= shown.from && key <= shown.to : false
const className = [
'calendar-day',
outside ? 'outside' : '',
inRange ? 'in-range' : '',
endpoint ? 'endpoint' : '',
].filter(Boolean).join(' ')
return (
<button
type="button"
key={key}
className={className}
disabled={disabled}
aria-label={day.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' })}
onMouseDown={event => {
event.preventDefault()
dragAnchor.current = key
if (!clickAnchor.current) setPreview({ from: key, to: key })
}}
onMouseEnter={() => {
if (dragAnchor.current) setPreview(normalize(dragAnchor.current, key))
}}
onMouseUp={() => {
const start = dragAnchor.current
if (start && start !== key) {
commit(start, key)
} else if (clickAnchor.current) {
commit(clickAnchor.current, key)
} else {
clickAnchor.current = key
dragAnchor.current = null
setPreview({ from: key, to: key })
}
}}
>
{day.getDate()}
</button>
)
})}
</div>
</div>
)
}

View file

@ -0,0 +1,164 @@
import { formatUsd, shortenProjectPath } from '../lib/format'
import { isOtherNode, seriesColorForModel } from '../lib/modelSeries'
import type { SpendFlow, SpendFlowNode } from '../lib/types'
type LayoutNode = SpendFlowNode & {
x: number
y: number
h: number
fill: string
displayLabel: string
}
const VIEW_W = 760
const VIEW_H = 190
const TOP = 14
const BOTTOM = 18
const LEFT_X = 126
const RIGHT_X = 520
const NODE_W = 5
const GAP = 8
const MIN_RIBBON_W = 2
export function Sankey({ flow }: { flow: SpendFlow }) {
const models = layoutNodes(flow.models, LEFT_X, true)
const projects = layoutNodes(flow.projects, RIGHT_X, false)
const modelById = new Map(models.map(node => [node.id, node]))
const projectById = new Map(projects.map(node => [node.id, node]))
const sourceOffset = new Map<string, number>()
const targetOffset = new Map<string, number>()
const ribbons = flow.links.flatMap((link, i) => {
const source = modelById.get(link.model)
const target = projectById.get(link.project)
if (!source || !target || link.cost <= 0) return []
const sourceSegment = segmentSize(source, link.cost)
const targetSegment = segmentSize(target, link.cost)
const width = Math.max(MIN_RIBBON_W, (sourceSegment + targetSegment) / 2)
const sy = source.y + (sourceOffset.get(source.id) ?? 0) + sourceSegment / 2
const ty = target.y + (targetOffset.get(target.id) ?? 0) + targetSegment / 2
sourceOffset.set(source.id, (sourceOffset.get(source.id) ?? 0) + sourceSegment)
targetOffset.set(target.id, (targetOffset.get(target.id) ?? 0) + targetSegment)
const gradId = gradientId(source.id)
return [
<path
key={`${link.model}-${link.project}-${i}`}
data-testid="sankey-ribbon"
data-model={source.id}
data-project={target.id}
d={`M ${LEFT_X + NODE_W + 1} ${round(sy)} C 300 ${round(sy)} 380 ${round(ty)} ${RIGHT_X - 1} ${round(ty)}`}
stroke={`url(#${gradId})`}
strokeWidth={round(width)}
fill="none"
strokeOpacity=".40"
/>,
]
})
return (
<svg viewBox={`0 0 ${VIEW_W} ${VIEW_H}`} width="100%" style={{ minWidth: 560, display: 'block' }}>
<defs>
{models.map(model => (
<linearGradient key={model.id} id={gradientId(model.id)} x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stopColor={model.fill} />
<stop offset="1" stopColor={model.fill} stopOpacity=".25" />
</linearGradient>
))}
</defs>
{ribbons}
{models.map(node => (
<rect
key={node.id}
data-testid="sankey-node"
data-node-id={node.id}
x={node.x}
y={round(node.y)}
width={NODE_W}
height={round(node.h)}
rx="2.5"
fill={node.fill}
/>
))}
{projects.map(node => (
<rect
key={node.id}
data-testid="sankey-node"
data-node-id={node.id}
x={node.x}
y={round(node.y)}
width={NODE_W}
height={round(node.h)}
rx="2.5"
fill={node.fill}
/>
))}
{models.map(node => (
<text key={node.id} x="118" y={round(node.y + node.h / 2 + 3)} textAnchor="end" fontSize="10" fill="var(--mut)">
{node.displayLabel} · {formatUsd(node.cost)}
</text>
))}
{projects.map(node => (
<text key={node.id} x="534" y={round(node.y + node.h / 2 + 3)} fontSize="10" fill="var(--mut)">
{node.displayLabel} · {formatUsd(node.cost)}
</text>
))}
</svg>
)
}
function layoutNodes(nodes: SpendFlowNode[], x: number, modelSide: boolean): LayoutNode[] {
if (nodes.length === 0) return []
const usable = VIEW_H - TOP - BOTTOM - GAP * Math.max(0, nodes.length - 1)
const total = nodes.reduce((sum, node) => sum + Math.max(0, node.cost), 0)
const rawHeights = nodes.map(node => (total > 0 ? (Math.max(0, node.cost) / total) * usable : usable / nodes.length))
const minH = Math.min(10, usable / nodes.length)
const inflated = rawHeights.map(h => Math.max(minH, h))
const scale = inflated.reduce((sum, h) => sum + h, 0) > usable ? usable / inflated.reduce((sum, h) => sum + h, 0) : 1
let y = TOP
return nodes.map((node, i) => {
const h = Math.max(2, inflated[i] * scale)
const neutral = isOtherNode(node.id) || isOtherNode(node.label)
const fill = modelSide && !neutral ? seriesColorForModel(node.label || node.id) : neutral ? 'var(--s-other)' : 'var(--mut2)'
const displayLabel = modelSide ? modelDisplayLabel(node.label || node.id) : projectDisplayLabel(node.label || node.id)
const laidOut = { ...node, x, y, h, fill, displayLabel }
y += h + GAP
return laidOut
})
}
function segmentSize(node: LayoutNode, cost: number): number {
return node.cost > 0 ? (Math.max(0, cost) / node.cost) * node.h : 0
}
function gradientId(id: string): string {
return `sankey-${id.replace(/[^a-zA-Z0-9_-]/g, '-')}`
}
function round(n: number): number {
return Math.round(n * 10) / 10
}
function modelDisplayLabel(raw: string): string {
const value = raw.trim()
return ellipsize(shortenId(value), 18)
}
function projectDisplayLabel(raw: string): string {
const value = raw.trim()
if (isOtherNode(value)) return 'Other'
return ellipsize(shortenProjectPath(value), 24)
}
function shortenId(value: string): string {
return value.replace(/^claude[-_]/i, '').replace(/^openai[-_]/i, '')
}
function ellipsize(value: string, max: number): string {
return value.length > max ? `${value.slice(0, max - 3)}...` : value
}

View file

@ -0,0 +1,37 @@
export type SegOption = { value: string; label: string }
/** The `.seg` segmented control used for period and lens switching. */
export function SegTabs({
options,
value,
onChange,
style,
}: {
options: SegOption[]
value: string
onChange: (value: string) => void
style?: React.CSSProperties
}) {
return (
<div className="seg" role="tablist" style={style}>
{options.map(opt => (
<span
key={opt.value}
className={opt.value === value ? 'on' : undefined}
role="tab"
aria-selected={opt.value === value}
tabIndex={0}
onClick={() => onChange(opt.value)}
onKeyDown={e => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onChange(opt.value)
}
}}
>
{opt.label}
</span>
))}
</div>
)
}

View file

@ -0,0 +1,37 @@
// @vitest-environment jsdom
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { Sidebar } from './Sidebar'
describe('Sidebar', () => {
it('renders all eight nav items in the desktop order', () => {
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()
})
it('calls onNavigate with the section id when a nav item is clicked', () => {
const onNavigate = vi.fn()
render(<Sidebar active="overview" onNavigate={onNavigate} />)
fireEvent.click(screen.getByRole('button', { name: /Spend/ }))
expect(onNavigate).toHaveBeenCalledWith('spend')
})
it('marks the active item with the "on" class', () => {
render(<Sidebar active="models" onNavigate={() => {}} />)
expect(screen.getByRole('button', { name: /Models/ })).toHaveClass('on')
expect(screen.getByRole('button', { name: /Overview/ })).not.toHaveClass('on')
})
it('renders the brand flame mark, static under the closed motion gate', () => {
const { container } = render(<Sidebar active="overview" onNavigate={() => {}} />)
const flame = container.querySelector('.app .flamemark')
expect(flame?.tagName.toLowerCase()).toBe('img')
// motionEnabled() is off under vitest, so the idle flicker never attaches.
expect(container.querySelector('.fm-flicker')).toBeNull()
})
})

View file

@ -0,0 +1,99 @@
import { useState, type ReactNode } from 'react'
import { codeburn } from '../lib/ipc'
import { AboutModal, type SocialLink } from './AboutModal'
import { FlameMark } from './FlameMark'
export type Section = 'overview' | 'sessions' | '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: (
<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: (
<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: (
<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: (
<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: (
<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: (
<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: (
<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: (
<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>
) },
]
const SOCIALS: SocialLink[] = [
{ label: 'GitHub', url: 'https://github.com/getagentseal/codeburn', icon: <svg viewBox="0 0 24 24"><path d="M12 .5C5.37.5 0 5.87 0 12.5c0 5.3 3.44 9.8 8.21 11.39.6.11.82-.26.82-.58 0-.29-.01-1.05-.02-2.06-3.34.73-4.04-1.61-4.04-1.61-.55-1.39-1.34-1.76-1.34-1.76-1.09-.75.08-.73.08-.73 1.2.09 1.84 1.24 1.84 1.24 1.07 1.83 2.81 1.3 3.5.99.11-.78.42-1.3.76-1.6-2.67-.3-5.47-1.33-5.47-5.93 0-1.31.47-2.38 1.24-3.22-.12-.3-.54-1.52.12-3.18 0 0 1.01-.32 3.3 1.23a11.5 11.5 0 0 1 6 0c2.29-1.55 3.3-1.23 3.3-1.23.66 1.66.24 2.88.12 3.18.77.84 1.23 1.91 1.23 3.22 0 4.61-2.8 5.62-5.48 5.92.43.37.81 1.1.81 2.22 0 1.6-.01 2.9-.01 3.29 0 .32.22.7.83.58A12 12 0 0 0 24 12.5C24 5.87 18.63.5 12 .5z" /></svg> },
{ label: 'Discord', url: 'https://discord.com/invite/w2sw8mCqep', icon: <svg viewBox="0 0 24 24"><path d="M20.32 4.37A19.8 19.8 0 0 0 15.45 3c-.21.38-.46.9-.63 1.31a18.3 18.3 0 0 0-5.47 0C8.71 3.9 8.45 3.38 8.24 3a19.7 19.7 0 0 0-4.88 1.37C.86 8.75.05 13.02.45 17.23a19.9 19.9 0 0 0 6 3.03c.48-.66.91-1.36 1.28-2.11-.7-.26-1.37-.58-2-.96.17-.12.33-.25.49-.38a14.2 14.2 0 0 0 12.16 0c.16.14.32.26.49.38-.63.38-1.31.7-2 .96.37.75.8 1.45 1.28 2.11a19.8 19.8 0 0 0 6-3.03c.47-4.87-.8-9.1-3.83-12.86zM8.02 14.65c-1.18 0-2.15-1.08-2.15-2.41 0-1.33.95-2.42 2.15-2.42 1.2 0 2.17 1.09 2.15 2.42 0 1.33-.95 2.41-2.15 2.41zm7.96 0c-1.18 0-2.15-1.08-2.15-2.41 0-1.33.95-2.42 2.15-2.42 1.2 0 2.17 1.09 2.15 2.42 0 1.33-.95 2.41-2.15 2.41z" /></svg> },
{ label: 'X', url: 'https://x.com/_codeburn', icon: <svg viewBox="0 0 24 24"><path d="M18.9 1.15h3.68l-8.04 9.19L24 22.85h-7.4l-5.8-7.58-6.63 7.58H.49l8.6-9.83L0 1.15h7.59l5.24 6.93 6.07-6.93zm-1.29 19.5h2.04L6.49 3.24H4.3l13.31 17.41z" /></svg> },
{ label: 'YouTube', url: 'https://www.youtube.com/@codeburnn', icon: <svg viewBox="0 0 24 24"><path d="M23.5 6.2a3.02 3.02 0 0 0-2.12-2.14C19.5 3.5 12 3.5 12 3.5s-7.5 0-9.38.56A3.02 3.02 0 0 0 .5 6.2 31.5 31.5 0 0 0 0 12a31.5 31.5 0 0 0 .5 5.8 3.02 3.02 0 0 0 2.12 2.14C4.5 20.5 12 20.5 12 20.5s7.5 0 9.38-.56A3.02 3.02 0 0 0 23.5 17.8 31.5 31.5 0 0 0 24 12a31.5 31.5 0 0 0-.5-5.8zM9.55 15.57V8.43L15.82 12l-6.27 3.57z" /></svg> },
{ label: 'LinkedIn', url: 'https://www.linkedin.com/showcase/codeburnn/', icon: <svg viewBox="0 0 256 256"><path fill="currentColor" d="M218.123 218.127h-37.931v-59.403c0-14.165-.253-32.4-19.728-32.4-19.756 0-22.779 15.434-22.779 31.369v60.43h-37.93V95.967h36.413v16.694h.51a39.907 39.907 0 0 1 35.928-19.733c38.445 0 45.533 25.288 45.533 58.186l-.016 67.013ZM56.955 79.27c-12.157.002-22.014-9.852-22.016-22.009-.002-12.157 9.851-22.014 22.008-22.016 12.157-.003 22.014 9.851 22.016 22.008A22.013 22.013 0 0 1 56.955 79.27m18.966 138.858H37.95V95.967h37.97v122.16ZM237.033.018H18.89C8.58-.098.125 8.161-.001 18.471v219.053c.122 10.315 8.576 18.582 18.89 18.474h218.144c10.336.128 18.823-8.139 18.966-18.474V18.454c-.147-10.33-8.635-18.588-18.966-18.453" /></svg> },
]
export function Sidebar({
active,
onNavigate,
}: {
active: Section
onNavigate: (section: Section) => void
status?: ReactNode
}) {
const [aboutOpen, setAboutOpen] = useState(false)
return (
<>
<nav className="sb">
<div className="app"><FlameMark size={20} live /><b>CodeBurn</b></div>
{NAV_ITEMS.map(item => (
<div
key={item.id}
className={item.id === active ? 'ni on' : 'ni'}
role="button"
aria-current={item.id === active ? 'page' : undefined}
tabIndex={0}
onClick={() => onNavigate(item.id)}
onKeyDown={e => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onNavigate(item.id)
}
}}
>
{item.icon}
{item.label}
<span className="k">{item.key}</span>
</div>
))}
<div className="push" />
<div className="foot">
<a className="about" href="#about" onClick={event => { event.preventDefault(); setAboutOpen(true) }}>About</a>
<div className="social">
{SOCIALS.map(social => (
<a
key={social.label}
href={social.url}
title={social.label}
aria-label={social.label}
onClick={event => { event.preventDefault(); void codeburn.openExternal(social.url) }}
>
{social.icon}
</a>
))}
</div>
</div>
</nav>
{aboutOpen ? <AboutModal socials={SOCIALS} onClose={() => setAboutOpen(false)} /> : null}
</>
)
}

View file

@ -0,0 +1,18 @@
// @vitest-environment jsdom
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { SectionSkeleton } from './Skeleton'
describe('SectionSkeleton', () => {
it('renders shimmer blocks and keeps the loading label for screen readers', () => {
const { container } = render(<SectionSkeleton label="Scanning spend…" rows={4} chart />)
expect(container.querySelectorAll('.skel').length).toBeGreaterThan(0)
expect(container.querySelector('.skel-chart')).toBeInTheDocument()
const label = screen.getByText('Scanning spend…')
expect(label).toHaveClass('sr-only')
expect(label).toHaveAttribute('role', 'status')
})
})

View file

@ -0,0 +1,21 @@
/**
* First-load placeholder: CSS shimmer blocks shown while a section has no data
* and no error. The visible blocks are aria-hidden; the real loading text is
* kept for screen readers via a visually-hidden status node.
*/
export function SectionSkeleton({ label, rows = 4, chart = false }: { label: string; rows?: number; chart?: boolean }) {
return (
<div className="panel skel-card">
<span className="sr-only" role="status">{label}</span>
<div className="phead skel-head" aria-hidden="true">
<span className="skel skel-line" style={{ width: '38%' }} />
</div>
<div className="pbody skel-body" aria-hidden="true">
{chart && <span className="skel skel-chart" />}
{Array.from({ length: rows }, (_, index) => (
<span key={index} className="skel skel-line" style={{ width: `${88 - index * 13}%` }} />
))}
</div>
</div>
)
}

View file

@ -0,0 +1,86 @@
// @vitest-environment jsdom
import { act, render } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Splash } from './Splash'
import { mockMatchMedia as mockReducedMotion } from '../lib/testMatchMedia'
function splashEl(): HTMLElement | null {
return document.querySelector('.splash')
}
afterEach(() => {
vi.useRealTimers()
Reflect.deleteProperty(window, 'matchMedia')
})
describe('Splash', () => {
it('stays up while the first overview fetch has neither data nor error', () => {
render(<Splash hasData={false} hasError={false} />)
const el = splashEl()
expect(el).toBeInTheDocument()
// Static under vitest / the closed motion gate: no ignite/pulse class,
// the static mark instead of the loader video.
expect(el).not.toHaveClass('splash-lit')
expect(el?.querySelector('video')).toBeNull()
expect(el?.querySelector('.flamemark')).not.toBeNull()
})
it('holds the min on-screen time, then crossfades away once data lands', () => {
vi.useFakeTimers()
const { rerender } = render(<Splash hasData={false} hasError={false} />)
expect(splashEl()).toBeInTheDocument()
// First data lands immediately (warm cache): the floor must keep it up.
rerender(<Splash hasData hasError={false} />)
act(() => { vi.advanceTimersByTime(599) })
expect(splashEl()).toBeInTheDocument()
expect(splashEl()).not.toHaveClass('splash-out')
// Floor reached: begin the crossfade (still on screen during it).
act(() => { vi.advanceTimersByTime(1) })
expect(splashEl()).toHaveClass('splash-out')
// Crossfade complete: gone.
act(() => { vi.advanceTimersByTime(250) })
expect(splashEl()).not.toBeInTheDocument()
})
it('yields immediately when the first fetch errors, with no min-time', () => {
vi.useFakeTimers()
const { rerender } = render(<Splash hasData={false} hasError={false} />)
expect(splashEl()).toBeInTheDocument()
rerender(<Splash hasData={false} hasError />)
expect(splashEl()).not.toBeInTheDocument()
})
it('never reappears on a later loading state after it has dismissed', () => {
vi.useFakeTimers()
const { rerender } = render(<Splash hasData={false} hasError={false} />)
rerender(<Splash hasData hasError={false} />)
act(() => { vi.advanceTimersByTime(600) })
act(() => { vi.advanceTimersByTime(250) })
expect(splashEl()).not.toBeInTheDocument()
// A filter change re-enters loading and can clear last-good data; the splash
// must not come back.
rerender(<Splash hasData={false} hasError={false} />)
expect(splashEl()).not.toBeInTheDocument()
rerender(<Splash hasData hasError={false} />)
expect(splashEl()).not.toBeInTheDocument()
})
it('swaps instantly under reduced motion (no fade, no min-time)', () => {
mockReducedMotion(true)
const { rerender } = render(<Splash hasData={false} hasError={false} />)
const el = splashEl()
expect(el).toBeInTheDocument()
expect(el).not.toHaveClass('splash-lit')
// No timers advanced: data lands and the overlay is gone at once.
rerender(<Splash hasData hasError={false} />)
expect(splashEl()).not.toBeInTheDocument()
})
})

View file

@ -0,0 +1,73 @@
import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { FlameMark } from './FlameMark'
import { motionClass, motionEnabled, reducedMotion } from '../lib/motion'
import { version } from '../../package.json'
import loaderVideo from '../assets/splash-loader.webm'
const MIN_ON_SCREEN_MS = 600
const CROSSFADE_MS = 250
type Phase = 'lit' | 'out' | 'done'
/**
* Full-window branded startup loader -- the same scanning moment as the menubar
* app's ignite-while-loading flame. Mounts once with the app and stays up while
* the FIRST overview fetch has neither data nor error. On first data it holds a
* floor of MIN_ON_SCREEN_MS (so a warm cache does not flash-blink) then
* crossfades out. A first-fetch error dismisses it instantly, so the user is
* never trapped behind branding; reduced motion swaps instantly with no fade.
* A `done` latch means later loading states -- polls, filter changes -- never
* bring it back.
*/
export function Splash({ hasData, hasError }: { hasData: boolean; hasError: boolean }) {
const [phase, setPhase] = useState<Phase>('lit')
const shownAt = useRef(Date.now())
const done = useRef(false)
useEffect(() => {
if (done.current) return
if (!hasData && !hasError) return
// First resolution of the first fetch. An error or a reduced-motion
// preference swaps instantly; otherwise honor the on-screen floor first.
if (hasError || reducedMotion()) {
done.current = true
setPhase('done')
return
}
const wait = Math.max(0, MIN_ON_SCREEN_MS - (Date.now() - shownAt.current))
const timer = setTimeout(() => setPhase('out'), wait)
return () => clearTimeout(timer)
}, [hasData, hasError])
useEffect(() => {
if (phase !== 'out') return
const timer = setTimeout(() => {
done.current = true
setPhase('done')
}, CROSSFADE_MS)
return () => clearTimeout(timer)
}, [phase])
if (phase === 'done' || typeof document === 'undefined') return null
const base = phase === 'out' ? 'splash splash-out' : 'splash'
return createPortal(
<div className={motionClass(base, 'splash-lit')} aria-hidden="true">
{motionEnabled() ? (
// The animated burn as VP9-with-alpha, floating directly on the splash
// gradient while the first scan runs. Static mark under reduced motion.
<video className="splash-video" src={loaderVideo} width={232} height={232} autoPlay muted loop playsInline />
) : (
<div className="splash-mark">
<FlameMark size={76} />
</div>
)}
<div className="splash-word">CodeBurn</div>
<div className="splash-version">v{version}</div>
</div>,
document.body,
)
}

View file

@ -0,0 +1,50 @@
// @vitest-environment jsdom
import { render } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import type { DailyHistoryEntry } from '../lib/types'
import { StackedBars } from './StackedBars'
function entry(day: number): DailyHistoryEntry {
return {
date: `2026-07-${String(day).padStart(2, '0')}`,
cost: day,
savingsUSD: 0,
calls: 1,
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
topModels: [],
}
}
describe('StackedBars', () => {
it('renders every supplied day and axis ticks every fourth day plus the last', () => {
const daily = Array.from({ length: 16 }, (_, index) => entry(index + 1))
const { container } = render(<StackedBars daily={daily} />)
expect(container.querySelectorAll('.sbars .c')).toHaveLength(16)
const ticks = container.querySelectorAll('.sbars-wrap > .ov-xax span')
expect([...ticks].map(tick => tick.textContent)).toEqual(['Jul 1', 'Jul 5', 'Jul 9', 'Jul 13', 'Jul 16'])
})
it('draws a single cost-only fallback bar and a provider legend when a day has cost but no model breakdown', () => {
// Provider-filtered days: cost present, topModels empty (the Swift menubar
// draws these from day.cost). A zero-cost day stays empty.
const daily = [
{ ...entry(9), cost: 0 },
{ ...entry(10), cost: 12 },
]
const { container } = render(<StackedBars daily={daily} fallbackLabel="Claude" />)
const columns = container.querySelectorAll('.sbars .c')
expect(columns[0].querySelectorAll('.s')).toHaveLength(0)
expect(columns[1].querySelectorAll('.s')).toHaveLength(1)
expect(columns[1].querySelector('.s-other')).toBeInTheDocument()
const legend = container.querySelector('.legend')!
expect(legend.querySelectorAll('span')).toHaveLength(1)
expect(legend).toHaveTextContent('Claude')
})
})

View file

@ -0,0 +1,93 @@
import { useRef } from 'react'
import { formatUsd } from '../lib/format'
import { useBarGrowIn } from '../lib/motion'
import { SERIES_LABELS, type SeriesKey, seriesClassForKey, seriesClassForModel, seriesKeyForModel } from '../lib/modelSeries'
import { formatChartDate } from '../lib/period'
import type { DailyHistoryEntry } from '../lib/types'
const SERIES_ORDER: readonly SeriesKey[] = ['opus', 'fable', 'haiku', 'gpt', 'sonnet', 'other']
function modelSpend(day: DailyHistoryEntry): number {
return day.topModels.reduce((sum, model) => sum + Math.max(0, model.cost), 0)
}
export function StackedBars({ daily, fallbackLabel = 'All models', animateKey = '' }: { daily: DailyHistoryEntry[]; fallbackLabel?: string; animateKey?: string }) {
const barsRef = useRef<HTMLDivElement>(null)
useBarGrowIn(barsRef, '.c', [animateKey])
const presentSeries = new Set<SeriesKey>()
let usesFallback = false
for (const day of daily) {
if (modelSpend(day) > 0) {
for (const model of day.topModels) {
if (model.cost > 0) presentSeries.add(seriesKeyForModel(model.name))
}
} else if (day.cost > 0) {
// Provider-filtered days carry day.cost but no per-model breakdown; the
// bar must still reflect spend (the Swift menubar draws from day.cost).
usesFallback = true
}
}
// Fallback days contribute day.cost to the scale so their single segment is proportional.
const maxTotal = Math.max(1, ...daily.map(day => (modelSpend(day) > 0 ? modelSpend(day) : Math.max(0, day.cost))))
const legendSeries = SERIES_ORDER.filter(series => presentSeries.has(series))
const ticks = daily.filter((_, index) => index % 4 === 0)
const lastDay = daily.at(-1)
if (lastDay && ticks.at(-1) !== lastDay) ticks.push(lastDay)
return (
<div className="sbars-wrap">
<div className="sbars" aria-label="Daily spend by model" ref={barsRef}>
{daily.map(day => (
<div className="c" key={day.date} data-date={day.date} title={`${day.date} · ${formatUsd(day.cost)}`}>
{modelSpend(day) > 0 ? (
[...day.topModels].sort(
(a, b) => SERIES_ORDER.indexOf(seriesKeyForModel(a.name)) - SERIES_ORDER.indexOf(seriesKeyForModel(b.name)),
).map(model => {
const pct = Math.max(1, (Math.max(0, model.cost) / maxTotal) * 100)
return (
<span
key={`${day.date}-${model.name}`}
className={`s ${seriesClassForModel(model.name)}`}
style={{ height: `${pct}%` }}
title={`${model.name} · ${formatUsd(model.cost)}`}
/>
)
})
) : day.cost > 0 ? (
<span
className={`s ${seriesClassForKey('other')}`}
style={{ height: `${Math.max(1, (day.cost / maxTotal) * 100)}%` }}
title={`${fallbackLabel} · ${formatUsd(day.cost)}`}
/>
) : null}
</div>
))}
</div>
<div className="ov-xax">
{ticks.map(day => {
const index = daily.indexOf(day)
return (
<span key={day.date} style={{ left: `${daily.length > 1 ? index / (daily.length - 1) * 100 : 0}%` }}>
{formatChartDate(day.date)}
</span>
)
})}
</div>
<div className="legend">
{legendSeries.map(series => (
<span key={series}>
<i className={seriesClassForKey(series)} />
{SERIES_LABELS[series]}
</span>
))}
{usesFallback && !presentSeries.has('other') && (
<span key="fallback">
<i className={seriesClassForKey('other')} />
{fallbackLabel}
</span>
)}
</div>
</div>
)
}

View file

@ -0,0 +1,14 @@
// @vitest-environment jsdom
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { StaleBanner } from './StaleBanner'
describe('StaleBanner', () => {
it('shows the last-good notice with the error summary', () => {
render(<StaleBanner error={{ kind: 'nonzero', message: 'codeburn exited 1' }} />)
const banner = screen.getByRole('status')
expect(banner).toHaveTextContent('Refresh failed, showing last good data · codeburn exited 1')
})
})

View file

@ -0,0 +1,13 @@
import type { CliError } from '../lib/types'
/**
* One-line notice shown when a section still has last-good data but the latest
* background poll failed. Muted so it never competes with the data below it.
*/
export function StaleBanner({ error }: { error: CliError }) {
return (
<div role="status" className="stale-banner">
Refresh failed, showing last good data · {error.message}
</div>
)
}

View file

@ -0,0 +1,22 @@
import type { ReactNode } from 'react'
/** A `.panel.stat` metric card: label strip + big value + delta line. */
export function Stat({
label,
value,
delta,
}: {
label: ReactNode
value: ReactNode
delta?: ReactNode
}) {
return (
<div className="panel stat">
<div className="phead"><b>{label}</b></div>
<div className="pbody">
<div className="v">{value}</div>
{delta !== undefined && <div className="d">{delta}</div>}
</div>
</div>
)
}

View file

@ -0,0 +1,40 @@
// @vitest-environment jsdom
import { act, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ToastHost } from './ToastHost'
import { dismissToast, showToast } from '../lib/toast'
afterEach(() => {
dismissToast()
vi.useRealTimers()
})
describe('ToastHost', () => {
it('shows an action toast and auto-dismisses it after 3s', () => {
vi.useFakeTimers()
render(<ToastHost />)
act(() => { showToast('Exported to /tmp/out') })
expect(screen.getByRole('status')).toHaveTextContent('Exported to /tmp/out')
act(() => { vi.advanceTimersByTime(2999) })
expect(screen.getByRole('status')).toBeInTheDocument()
act(() => { vi.advanceTimersByTime(1) })
expect(screen.queryByRole('status')).not.toBeInTheDocument()
})
it('keeps only the most recent toast (one at a time)', () => {
vi.useFakeTimers()
render(<ToastHost />)
act(() => { showToast('First') })
act(() => { showToast('Second', 'error') })
const toasts = screen.getAllByRole('status')
expect(toasts).toHaveLength(1)
expect(toasts[0]).toHaveTextContent('Second')
expect(toasts[0]).toHaveClass('toast-error')
})
})

View file

@ -0,0 +1,36 @@
import { useEffect, useReducer, useRef } from 'react'
import { createPortal } from 'react-dom'
import { getToast, isPrimaryHost, registerToastHost, subscribeToasts } from '../lib/toast'
import { motionClass } from '../lib/motion'
/** Bottom-right toast surface for action feedback. One at a time, role=status,
* slide-in when motion is on, auto-dismissed by the store. Ref-counted so only
* the primary host paints even if more than one is mounted. */
export function ToastHost() {
const idRef = useRef(0)
const [, force] = useReducer((n: number) => n + 1, 0)
useEffect(() => {
const { id, release } = registerToastHost()
idRef.current = id
const unsubscribe = subscribeToasts(force)
force()
return () => {
release()
unsubscribe()
}
}, [])
const toast = getToast()
if (typeof document === 'undefined' || !isPrimaryHost(idRef.current) || !toast) return null
return createPortal(
<div className="toast-host" aria-live="polite">
<div key={toast.id} className={motionClass(`toast toast-${toast.kind}`, 'toast-in')} role="status">
{toast.text}
</div>
</div>,
document.body,
)
}

View file

@ -0,0 +1,148 @@
import { useEffect, useRef, useState, type ReactNode } from 'react'
import type { ClaudeConfigSelector, DateRange } from '../lib/types'
import { Dropdown } from './Dropdown'
import { ProviderPop, type ProviderOption } from './ProviderPop'
import { RangeCalendar } from './RangeCalendar'
import { SegTabs, type SegOption } from './SegTabs'
/** Sentinel option value: no --claude-config-source flag (aggregate all configs). */
const ALL_CONFIGS = ''
/** The real CLI period vocabulary (`codeburn ... --period`). */
export const PERIOD_OPTIONS: SegOption[] = [
{ value: 'today', label: 'Today' },
{ value: 'week', label: '7D' },
{ value: '30days', label: '30D' },
{ value: 'month', label: 'Month' },
{ value: 'all', label: '6M' },
]
/** The `.bar` top bar: title, scope caption, period SegTabs, provider ProviderPop. */
export function TopBar({
title,
scope,
period,
onPeriodChange,
customRange,
onRangeSelect,
provider,
providerLabel,
providerOptions,
onProviderSelect,
claudeConfigs,
configSource,
onConfigSelect,
}: {
title: ReactNode
scope?: ReactNode
period: string
onPeriodChange: (value: string) => void
customRange: DateRange | null
onRangeSelect: (range: DateRange) => void
provider: string
providerLabel: string
providerOptions: ProviderOption[]
onProviderSelect: (value: string) => void
claudeConfigs?: ClaudeConfigSelector
configSource: string | null
onConfigSelect: (id: string) => void
}) {
return (
<div className="bar">
<div className="t">{title}</div>
{scope !== undefined && <span className="scope">{scope}</span>}
<div className="sp" />
<SegTabs options={PERIOD_OPTIONS} value={customRange ? '' : period} onChange={onPeriodChange} />
<CalendarPop value={customRange} onSelect={onRangeSelect} />
<ProviderPop value={provider} label={providerLabel} options={providerOptions} onSelect={onProviderSelect} />
{claudeConfigs && <ConfigPicker configs={claudeConfigs} value={configSource} onSelect={onConfigSelect} />}
</div>
)
}
/** Claude config source switcher. Only getOverview honors the selection, so the
* footer names the limit; the active label is also echoed in the scope line. */
function ConfigPicker({ configs, value, onSelect }: { configs: ClaudeConfigSelector; value: string | null; onSelect: (id: string) => void }) {
const options = [
{ value: ALL_CONFIGS, label: 'All Claude configs' },
...configs.options.map(option => ({ value: option.id, label: option.label })),
]
return (
<Dropdown
id="claude-config-select"
ariaLabel="Claude config source"
value={value ?? ALL_CONFIGS}
options={options}
onChange={onSelect}
width={168}
footer="Applies to the overview data. Manage config folders with the codeburn CLI."
/>
)
}
function formatRange(range: DateRange): string {
const from = new Date(`${range.from}T12:00:00`)
const to = new Date(`${range.to}T12:00:00`)
const sameYear = from.getFullYear() === to.getFullYear()
const sameMonth = sameYear && from.getMonth() === to.getMonth()
const left = from.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: sameYear ? undefined : 'numeric' })
const right = to.toLocaleDateString('en-US', { month: sameMonth ? undefined : 'short', day: 'numeric', year: sameYear ? undefined : 'numeric' })
return `${left} ${right}`
}
export function rangeLabel(range: DateRange): string {
return formatRange(range)
}
function CalendarPop({ value, onSelect }: { value: DateRange | null; onSelect: (range: DateRange) => void }) {
const [open, setOpen] = useState(false)
const wrapRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
const onPointerDown = (event: MouseEvent) => {
if (!wrapRef.current?.contains(event.target as Node)) setOpen(false)
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') setOpen(false)
}
document.addEventListener('mousedown', onPointerDown)
document.addEventListener('keydown', onKeyDown)
return () => {
document.removeEventListener('mousedown', onPointerDown)
document.removeEventListener('keydown', onKeyDown)
}
}, [open])
const label = value ? formatRange(value) : 'Choose date range'
return (
<div className="calendar-wrap" ref={wrapRef}>
<button
type="button"
className={`calendar-trigger${value ? ' on' : ''}`}
aria-label={label}
aria-haspopup="dialog"
aria-expanded={open}
onClick={() => setOpen(current => !current)}
>
<svg viewBox="0 0 16 16" aria-hidden="true">
<rect x="2.25" y="3.25" width="11.5" height="10.5" rx="1.5" />
<path d="M5 1.75v3M11 1.75v3M2.5 6.25h11" />
</svg>
{value && <span>{label}</span>}
</button>
{open && (
<div className="calendar-popover" role="dialog" aria-label="Choose date range">
<RangeCalendar
value={value}
onSelect={range => {
onSelect(range)
setOpen(false)
}}
/>
</div>
)}
</div>
)
}

View file

@ -0,0 +1,6 @@
import type { ReactNode } from 'react'
/** The `.win` shell: sidebar + content area live inside as children. */
export function Window({ children }: { children: ReactNode }) {
return <div className="win">{children}</div>
}

View file

@ -0,0 +1,58 @@
// @vitest-environment jsdom
import { describe, it, expect, vi } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { usePolled } from './usePolled'
describe('usePolled', () => {
it('discards a stale in-flight fetch that resolves after a newer one (epoch guard)', async () => {
// A fetcher we resolve by hand, one deferred per call, so we can force a
// SLOW deps-A fetch to resolve AFTER a FAST deps-B fetch.
const resolvers: Array<(v: string) => void> = []
const fetcher = vi.fn(() => new Promise<string>(resolve => { resolvers.push(resolve) }))
const { result, rerender } = renderHook(
({ p }: { p: string }) => usePolled(fetcher, [p]),
{ initialProps: { p: 'A' } },
)
// #0 mount fetch (deps A) — resolve it to establish a known baseline.
await act(async () => { resolvers[0]!('A0') })
expect(fetcher).toHaveBeenCalledTimes(1)
expect(result.current.data).toBe('A0')
// #1 refresh() while deps are still A → an in-flight SLOW fetch whose cancel
// handle the hook discards. Leave it unresolved for now.
act(() => { result.current.refresh() })
expect(fetcher).toHaveBeenCalledTimes(2)
// #2 deps change A→B → a FAST fetch that resolves first with fresh data.
rerender({ p: 'B' })
expect(fetcher).toHaveBeenCalledTimes(3)
await act(async () => { resolvers[2]!('B-fresh') })
expect(result.current.data).toBe('B-fresh')
// #1 (the slow deps-A fetch) now resolves LATE. It must NOT clobber B.
await act(async () => { resolvers[1]!('A-stale') })
expect(result.current.data).toBe('B-fresh')
})
it('keeps last-good data and exposes the error when a background reload fails', async () => {
const calls: Array<{ resolve: (v: string) => void; reject: (e: unknown) => void }> = []
const fetcher = vi.fn(() => new Promise<string>((resolve, reject) => { calls.push({ resolve, reject }) }))
const { result } = renderHook(() => usePolled(fetcher, []))
// Establish last-good data.
await act(async () => { calls[0]!.resolve('good') })
expect(result.current.data).toBe('good')
expect(result.current.error).toBeNull()
// A reload clears the error up front; if it fails, data is retained and the
// error is surfaced alongside it (the StaleBanner condition).
act(() => { result.current.refresh() })
expect(result.current.error).toBeNull()
await act(async () => { calls[1]!.reject({ kind: 'nonzero', message: 'boom' }) })
expect(result.current.data).toBe('good')
expect(result.current.error).toMatchObject({ kind: 'nonzero', message: 'boom' })
})
})

View file

@ -0,0 +1,73 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { normalizeCliError } from '../lib/ipc'
import type { CliError } from '../lib/types'
export type Polled<T> = {
data: T | null
error: CliError | null
loading: boolean
/** Wall-clock timestamp for the most recent successful fetch. */
lastSuccessAt: number | null
/** Re-run the fetcher immediately (period/provider change, manual refresh). */
refresh: () => void
}
/**
* Generic CLI-backed data hook: fetches on mount + whenever `deps` change, then
* re-polls every `intervalMs`. Errors are normalized to the CliError shape so
* sections can branch on `error.kind`. Last-good data is retained on error.
*/
export function usePolled<T>(fetcher: () => Promise<T>, deps: unknown[], intervalMs = 30_000): Polled<T> {
const [data, setData] = useState<T | null>(null)
const [error, setError] = useState<CliError | null>(null)
const [loading, setLoading] = useState(true)
const [lastSuccessAt, setLastSuccessAt] = useState<number | null>(null)
// Generation counter: every load() (mount, deps change, interval, refresh)
// claims the next epoch; a fetch applies its result only while its epoch is
// still current. This is what keeps a slow fetch from an older deps/period
// from clobbering a newer one that already resolved.
const epochRef = useRef(0)
const load = useCallback(() => {
const epoch = ++epochRef.current
setLoading(true)
// Clear any prior error at the start of each attempt so a fresh poll never
// shows a stale banner while it is still in flight; last-good `data` stays.
setError(null)
fetcher()
.then(result => {
if (epochRef.current !== epoch) return
setData(result)
setError(null)
setLastSuccessAt(Date.now())
})
.catch(err => {
if (epochRef.current !== epoch) return
setError(normalizeCliError(err))
})
.finally(() => {
if (epochRef.current !== epoch) return
setLoading(false)
})
// deps are intentionally the caller-provided dependency list.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps)
useEffect(() => {
load()
const id = setInterval(() => load(), intervalMs)
return () => {
clearInterval(id)
// Retire this generation so an in-flight fetch can't resolve into state
// after unmount or a deps change.
epochRef.current++
}
}, [load, intervalMs])
const refresh = useCallback(() => {
load()
}, [load])
return { data, error, loading, lastSuccessAt, refresh }
}

34
app/renderer/index.html Normal file
View file

@ -0,0 +1,34 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self' ws://localhost:5173 http://localhost:5173"
/>
<title>CodeBurn</title>
<style>
/* App-shell glue only — indigo.css is the verbatim wireframe port.
Make the window fill the OS window instead of the doc-page card. */
html,
body,
#root {
height: 100%;
margin: 0;
}
#root {
display: flex;
}
.win {
flex: 1;
min-width: 0;
border-radius: 0;
}
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>

View file

@ -0,0 +1,19 @@
// Renderer-only daily budget setting (localStorage `codeburn.dailyBudget`).
// A 'usd' cap is raw-USD (compared against history.daily cost, then displayed
// via the currency-aware formatUsd); a 'tokens' cap counts input+output tokens.
export type DailyBudget = { kind: 'usd' | 'tokens'; value: number }
/** Parse the persisted budget, returning null when absent or malformed. */
export function readDailyBudget(): DailyBudget | null {
let raw: string | null = null
try { raw = globalThis.localStorage?.getItem('codeburn.dailyBudget') ?? null } catch { return null }
if (!raw) return null
try {
const parsed = JSON.parse(raw) as Partial<DailyBudget>
if ((parsed.kind === 'usd' || parsed.kind === 'tokens') && typeof parsed.value === 'number' && Number.isFinite(parsed.value) && parsed.value > 0) {
return { kind: parsed.kind, value: parsed.value }
}
} catch { /* malformed JSON */ }
return null
}

View file

@ -0,0 +1,58 @@
import { afterEach, describe, expect, it } from 'vitest'
import { formatCompact, formatConverted, formatDayLong, formatDayShort, formatDuration, formatUsd, setActiveCurrency } from './format'
describe('currency-aware formatting', () => {
afterEach(() => setActiveCurrency({ code: 'USD', symbol: '$', rate: 1 }))
it('formats raw USD with the default USD currency', () => {
expect(formatUsd(12.34)).toBe('$12.34')
expect(formatUsd(1_234.5)).toBe('$1,234.50')
})
it('applies the active FX rate and symbol to raw-USD values exactly once', () => {
setActiveCurrency({ code: 'EUR', symbol: '€', rate: 0.9 })
// 100 USD × 0.9 = 90.00 EUR
expect(formatUsd(100)).toBe('€90.00')
expect(formatUsd(1_000)).toBe('€900.00')
})
it('formatConverted swaps the symbol without re-applying the rate (CLI-converted values)', () => {
setActiveCurrency({ code: 'EUR', symbol: '€', rate: 0.9 })
// Already-EUR input renders as-is, never multiplied by the rate again.
expect(formatConverted(90)).toBe('€90.00')
expect(formatConverted(20)).toBe('€20.00')
})
})
describe('formatCompact', () => {
it('formats zero, plain counts, thousands, and millions compactly', () => {
expect(formatCompact(0)).toBe('0')
expect(formatCompact(842)).toBe('842')
expect(formatCompact(1_842)).toBe('1.8K')
expect(formatCompact(184_000)).toBe('184K')
expect(formatCompact(1_200_000)).toBe('1.2M')
})
it('trims trailing decimals and rejects non-finite values', () => {
expect(formatCompact(2_000_000)).toBe('2M')
expect(formatCompact(Number.NaN)).toBe('—')
})
})
describe('date and duration formatters', () => {
it('formats short and long calendar dates and handles invalid input', () => {
const date = '2026-07-10T12:00:00'
expect(formatDayShort(date)).toBe('Jul 10')
expect(formatDayLong(date)).toBe('Jul 10, 2026')
expect(formatDayShort('not-a-date')).toBe('—')
expect(formatDayLong('not-a-date')).toBe('—')
})
it('formats seconds, minutes, hours, and invalid durations', () => {
expect(formatDuration(29_000)).toBe('29s')
expect(formatDuration(47 * 60_000)).toBe('47m')
expect(formatDuration(134 * 60_000)).toBe('2h 14m')
expect(formatDuration(0)).toBe('—')
})
})

View file

@ -0,0 +1,82 @@
type ActiveCurrency = { code: string; symbol: string; rate: number }
// Single source of truth for display currency. App.tsx sets it from the overview
// payload; every formatUsd/formatConverted call site then converts for free.
// Defaults to USD so the first render (before the payload arrives) is correct.
let activeCurrency: ActiveCurrency = { code: 'USD', symbol: '$', rate: 1 }
export function setActiveCurrency(currency: ActiveCurrency): void {
activeCurrency = currency
}
/** Raw-USD input: multiplies by the active FX rate, then prefixes the symbol. */
export function formatUsd(n: number): string {
return formatConverted(n * activeCurrency.rate)
}
/**
* Already-converted input (CLI-side convertCost values, e.g. plan budgets): only
* prefixes the active symbol and formats the magnitude never re-applies the rate.
*/
export function formatConverted(n: number): string {
return `${activeCurrency.symbol}${n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
}
/** Shorten filesystem and CLI-mangled project paths to their useful trailing segments. */
export function shortenProjectPath(value: string, maxSegments = 3): string {
const trimmed = value.trim()
const pathDelimited = /[\\/]/.test(trimmed)
const parts = trimmed.split(pathDelimited ? /[\\/]+/ : /-+/).filter(Boolean)
let displayParts = parts.slice(-Math.max(1, maxSegments))
// A tail rooted directly under a home directory starts with the user name,
// which adds noise rather than useful project context.
const precedingPart = parts.at(-(displayParts.length + 1))
if (displayParts.length > 1 && /^(users?|home)$/i.test(precedingPart ?? '')) {
displayParts = displayParts.slice(1)
}
if (/^(projects|src|config)$/i.test(displayParts[0] ?? '')) {
displayParts[0] = displayParts[0].toLowerCase()
}
return displayParts.join('/') || trimmed
}
/** Compact token/count formatting: 1_842 → "1.8K", 184_000 → "184K", 1_200_000 → "1.2M". */
export function formatCompact(n: number): string {
if (!Number.isFinite(n)) return '—'
if (n === 0) return '0'
const abs = Math.abs(n)
if (abs < 1_000) return String(Math.round(n))
if (abs < 1_000_000) return `${trim(n / 1_000)}K`
if (abs < 1_000_000_000) return `${trim(n / 1_000_000)}M`
return `${trim(n / 1_000_000_000)}B`
}
// One decimal, but drop a trailing ".0" (184.0K → "184K", 1.2K stays "1.2K").
function trim(v: number): string {
const s = v.toFixed(1)
return s.endsWith('.0') ? s.slice(0, -2) : s
}
/** "Jul 10" — short month + day, no year. */
export function formatDayShort(iso: string): string {
const d = new Date(iso)
return Number.isNaN(d.getTime()) ? '—' : d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
}
/** "Jul 10, 2026" — full date. */
export function formatDayLong(iso: string): string {
const d = new Date(iso)
return Number.isNaN(d.getTime()) ? '—' : d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
}
/** "2h 14m" / "47m" / "38s" from a duration in ms. */
export function formatDuration(ms: number): string {
if (!Number.isFinite(ms) || ms <= 0) return '—'
const totalMin = Math.round(ms / 60_000)
if (totalMin < 1) return `${Math.round(ms / 1000)}s`
if (totalMin < 60) return `${totalMin}m`
return `${Math.floor(totalMin / 60)}h ${totalMin % 60}m`
}

23
app/renderer/lib/ipc.ts Normal file
View file

@ -0,0 +1,23 @@
import type { CliError, CodeburnBridge } from './types'
// The preload script (app/electron/preload.ts) exposes `window.codeburn` as the
// typed CodeburnBridge (methods resolve to CLI JSON or reject with a plain
// CliError).
declare global {
interface Window {
codeburn: CodeburnBridge
}
}
/** The typed bridge. Import this instead of touching `window` directly. */
export const codeburn: CodeburnBridge = window.codeburn
/** Coerce anything thrown across the IPC boundary into a CliError shape. */
export function normalizeCliError(err: unknown): CliError {
if (err && typeof err === 'object' && 'kind' in err && typeof (err as CliError).kind === 'string') {
const e = err as CliError
return { kind: e.kind, message: e.message ?? 'codeburn CLI error' }
}
const message = err instanceof Error ? err.message : String(err)
return { kind: 'nonzero', message }
}

View file

@ -0,0 +1,55 @@
export type SeriesKey = 'opus' | 'fable' | 'sonnet' | 'haiku' | 'gpt' | 'other'
export const SERIES_LABELS: Record<SeriesKey, string> = {
opus: 'Opus',
fable: 'Fable',
sonnet: 'Sonnet',
haiku: 'Haiku',
gpt: 'GPT / Codex',
other: 'Other',
}
const SERIES_CSS_VAR: Record<SeriesKey, string> = {
opus: 'var(--s-opus)',
fable: 'var(--s-fable)',
sonnet: 'var(--s-sonnet)',
haiku: 'var(--s-haiku)',
gpt: 'var(--s-gpt)',
other: 'var(--s-other)',
}
const SERIES_CLASS: Record<SeriesKey, string> = {
opus: 's-opus',
fable: 's-fable',
sonnet: 's-son',
haiku: 's-hai',
gpt: 's-gpt',
other: 's-other',
}
export function seriesKeyForModel(model?: string): SeriesKey {
const m = (model ?? '').toLowerCase()
if (m.includes('opus')) return 'opus'
if (m.includes('fable')) return 'fable'
if (m.includes('sonnet')) return 'sonnet'
if (m.includes('haiku')) return 'haiku'
if (m.includes('gpt') || m.includes('codex')) return 'gpt'
return 'other'
}
export function seriesColorForModel(model?: string): string {
return SERIES_CSS_VAR[seriesKeyForModel(model)]
}
export function seriesClassForModel(model?: string): string {
return SERIES_CLASS[seriesKeyForModel(model)]
}
export function seriesClassForKey(series: SeriesKey): string {
return SERIES_CLASS[series]
}
export function isOtherNode(idOrLabel?: string): boolean {
const value = (idOrLabel ?? '').trim().toLowerCase()
return value === '__other__' || value === 'other' || value === 'others'
}

View file

@ -0,0 +1,62 @@
// @vitest-environment jsdom
import { render } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import gsap from 'gsap'
import { motionClass, motionEnabled, reducedMotion } from './motion'
import { StackedBars } from '../components/StackedBars'
import type { DailyHistoryEntry } from './types'
import { mockMatchMedia } from './testMatchMedia'
function entry(day: number): DailyHistoryEntry {
return {
date: `2026-07-${String(day).padStart(2, '0')}`,
cost: day,
savingsUSD: 0,
calls: 1,
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
topModels: [],
}
}
afterEach(() => {
vi.restoreAllMocks()
Reflect.deleteProperty(window, 'matchMedia')
})
describe('motion gate', () => {
it('reducedMotion mirrors the prefers-reduced-motion query', () => {
mockMatchMedia(true)
expect(reducedMotion()).toBe(true)
mockMatchMedia(false)
expect(reducedMotion()).toBe(false)
})
it('reducedMotion is false when matchMedia is unavailable', () => {
Reflect.deleteProperty(window, 'matchMedia')
expect(reducedMotion()).toBe(false)
})
it('motionEnabled stays off under vitest even without a reduced-motion preference', () => {
mockMatchMedia(false)
expect(motionEnabled()).toBe(false)
})
it('motionClass drops the animation class while motion is off', () => {
mockMatchMedia(false)
expect(motionClass('body', 'section-fade')).toBe('body')
})
it('never drives a chart grow-in through gsap while the gate is closed', () => {
const spy = vi.spyOn(gsap, 'from')
mockMatchMedia(false)
const { container } = render(<StackedBars daily={[entry(1), entry(2)]} animateKey="a" />)
expect(spy).not.toHaveBeenCalled()
// The bars still render at their natural, un-transformed size.
expect(container.querySelectorAll('.sbars .c')).toHaveLength(2)
})
})

View file

@ -0,0 +1,59 @@
import type { RefObject } from 'react'
import gsap from 'gsap'
import { useGSAP } from '@gsap/react'
/** True while the vitest suite is running; animations stay off so tests observe
* the final, settled DOM rather than an in-flight tween. `process` is undefined
* in the packaged renderer, so the typeof guard keeps this false there. */
function underTest(): boolean {
return typeof process !== 'undefined' && Boolean(process.env?.VITEST)
}
/** Reads the user's reduced-motion preference. This is the real gate the tests
* exercise (via a matchMedia mock); it is safe when matchMedia is absent. */
export function reducedMotion(): boolean {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false
try {
return window.matchMedia('(prefers-reduced-motion: reduce)').matches
} catch {
return false
}
}
/** The single switch every animation path checks first. Off under vitest, when
* matchMedia is unavailable, or when the user asked for reduced motion. */
export function motionEnabled(): boolean {
if (underTest()) return false
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false
return !reducedMotion()
}
/** Append `animated` to `base` only when motion is on, so a class-based
* (CSS keyframe) animation never renders under reduced motion or in tests. */
export function motionClass(base: string, animated: string): string {
return motionEnabled() ? `${base} ${animated}` : base
}
/**
* Grow bars up from their baseline (scaleY 0 1, bottom-anchored) with a short
* stagger, capped so the whole sweep stays under 400ms regardless of bar count.
* Runs on mount and whenever `deps` change (a filter switch) but NOT on the 30s
* poll re-render, because callers pass a stable filter key not the data as
* the dependency.
*/
export function useBarGrowIn(scope: RefObject<HTMLElement | null>, selector: string, deps: unknown[]): void {
useGSAP(() => {
if (!motionEnabled()) return
const bars = gsap.utils.toArray<HTMLElement>(selector, scope.current)
if (!bars.length) return
const each = Math.min(0.02, 0.26 / Math.max(1, bars.length - 1))
gsap.from(bars, {
scaleY: 0,
transformOrigin: 'bottom',
duration: 0.14,
ease: 'power1.out',
stagger: each,
})
}, { scope, dependencies: deps })
}

View file

@ -0,0 +1,87 @@
import { describe, expect, it } from 'vitest'
import type { DailyHistoryEntry, Period } from './types'
import { contiguousDailyWindow, formatChartDate, periodWindowStart, sliceDailyToPeriod } from './period'
function entry(date: string): DailyHistoryEntry {
return {
date,
cost: 1,
savingsUSD: 0,
calls: 1,
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
topModels: [],
}
}
const NOW = new Date(2026, 6, 10, 12, 0, 0)
const DAILY = [
entry('2026-05-31'),
entry('2026-06-01'),
entry('2026-06-10'),
entry('2026-06-11'),
entry('2026-07-01'),
entry('2026-07-03'),
entry('2026-07-04'),
entry('2026-07-09'),
entry('2026-07-10'),
entry('2026-07-11'),
]
describe('sliceDailyToPeriod', () => {
it.each<[Period, string[]]>([
['today', ['2026-07-10']],
// Window boundaries mirror src/cli-date.ts: week = now-7, 30days = now-30.
['week', ['2026-07-03', '2026-07-04', '2026-07-09', '2026-07-10']],
['30days', ['2026-06-10', '2026-06-11', '2026-07-01', '2026-07-03', '2026-07-04', '2026-07-09', '2026-07-10']],
['month', ['2026-07-01', '2026-07-03', '2026-07-04', '2026-07-09', '2026-07-10']],
[
'all',
[
'2026-05-31',
'2026-06-01',
'2026-06-10',
'2026-06-11',
'2026-07-01',
'2026-07-03',
'2026-07-04',
'2026-07-09',
'2026-07-10',
],
],
])('returns only in-window entries for %s', (period, expectedDates) => {
expect(sliceDailyToPeriod(DAILY, period, NOW).map(day => day.date)).toEqual(expectedDates)
})
})
describe('periodWindowStart', () => {
it.each<[Period, string]>([
['today', '2026-07-10'],
['week', '2026-07-03'],
['30days', '2026-06-10'],
['month', '2026-07-01'],
['all', '2026-01-01'],
])('aligns %s to the CLI window start', (period, expected) => {
expect(periodWindowStart(period, NOW)).toBe(expected)
})
})
describe('contiguousDailyWindow', () => {
it('zero-fills inactive calendar days between sparse real entries', () => {
const sparse = [entry('2026-07-08'), entry('2026-07-10')]
const window = contiguousDailyWindow(sparse, '2026-07-07', '2026-07-10')
expect(window.map(day => day.date)).toEqual(['2026-07-07', '2026-07-08', '2026-07-09', '2026-07-10'])
// The real entries keep their cost; the two gaps are zero-filled.
expect(window.map(day => day.cost)).toEqual([0, 1, 0, 1])
})
})
describe('formatChartDate', () => {
it('formats date keys without shifting the local calendar day', () => {
expect(formatChartDate('2026-07-01')).toBe('Jul 1')
})
})

View file

@ -0,0 +1,83 @@
import type { DailyHistoryEntry, Period } from './types'
// The CLI emits `history.daily` as a SPARSE list of active days only (not a
// backfilled calendar). Charts must zero-fill inactive days client-side to keep
// the time axis honest; helpers here own that windowing.
/** Local calendar date key "YYYY-MM-DD", matching the CLI's `dateKey` (src/day-aggregator.ts). */
export function localDateKey(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
// Aligned to src/cli-date.ts:getDateRange so client windows match CLI totals.
const ALL_TIME_MONTHS = 6
/** Inclusive lower bound (date key) of the selected period's window, matching the CLI. */
export function periodWindowStart(period: Period, now = new Date()): string {
switch (period) {
case 'today':
return localDateKey(now)
case 'week':
return localDateKey(new Date(now.getFullYear(), now.getMonth(), now.getDate() - 7))
case '30days':
return localDateKey(new Date(now.getFullYear(), now.getMonth(), now.getDate() - 30))
case 'month':
return localDateKey(new Date(now.getFullYear(), now.getMonth(), 1))
case 'all':
return localDateKey(new Date(now.getFullYear(), now.getMonth() - ALL_TIME_MONTHS, 1))
}
}
/** `history.daily` entries within the selected period's date window. */
export function sliceDailyToPeriod(daily: DailyHistoryEntry[], period: Period, now = new Date()): DailyHistoryEntry[] {
const start = periodWindowStart(period, now)
const todayKey = localDateKey(now)
return daily.filter(d => d.date >= start && d.date <= todayKey)
}
/** `history.daily` entries within an explicit [from..to] date-key range (custom range). */
export function sliceDailyToRange(daily: DailyHistoryEntry[], from: string, to: string): DailyHistoryEntry[] {
return daily.filter(d => d.date >= from && d.date <= to)
}
function zeroEntry(date: string): DailyHistoryEntry {
return {
date,
cost: 0,
savingsUSD: 0,
calls: 0,
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
topModels: [],
}
}
/**
* Contiguous calendar window [fromKey..toKey] with exactly one entry per day.
* Real (sparse) `history.daily` entries fill their day; inactive days are
* zero-filled so the chart axis reflects true calendar spacing. Keys are local
* YYYY-MM-DD (localDateKey / the CLI's dateKey), so lookups always match.
*/
export function contiguousDailyWindow(daily: DailyHistoryEntry[], fromKey: string, toKey: string): DailyHistoryEntry[] {
if (fromKey > toKey) return []
const byDate = new Map(daily.map(d => [d.date, d]))
const [fy, fm, fd] = fromKey.split('-').map(Number)
const [ty, tm, td] = toKey.split('-').map(Number)
const cursor = new Date(fy, fm - 1, fd)
const end = new Date(ty, tm - 1, td)
const out: DailyHistoryEntry[] = []
while (cursor <= end) {
const key = localDateKey(cursor)
out.push(byDate.get(key) ?? zeroEntry(key))
cursor.setDate(cursor.getDate() + 1)
}
return out
}
/** Format a local date key for compact chart-axis labels such as "Jul 1". */
export function formatChartDate(dateKey: string): string {
const [year, month, day] = dateKey.split('-').map(Number)
return new Date(year, month - 1, day).toLocaleString('en-US', { month: 'short', day: 'numeric' })
}

View file

@ -0,0 +1,15 @@
import { vi } from 'vitest'
/** Test-only matchMedia stub: every query reports the given `matches` value. */
export function mockMatchMedia(matches: boolean): void {
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
matches,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
})) as unknown as typeof window.matchMedia
}

76
app/renderer/lib/toast.ts Normal file
View file

@ -0,0 +1,76 @@
export type ToastKind = 'ok' | 'error'
export type Toast = { id: number; text: string; kind: ToastKind }
let current: Toast | null = null
let seq = 0
let timer: ReturnType<typeof setTimeout> | null = null
const hosts: number[] = []
let hostSeq = 0
const listeners = new Set<() => void>()
function emit(): void {
for (const listener of listeners) listener()
}
function clearTimer(): void {
if (timer) {
clearTimeout(timer)
timer = null
}
}
/** Show a toast, replacing any current one (only ever one at a time), and start
* its auto-dismiss timer. */
export function showToast(text: string, kind: ToastKind = 'ok', durationMs = 3000): void {
seq += 1
current = { id: seq, text, kind }
clearTimer()
timer = setTimeout(() => {
current = null
timer = null
emit()
}, durationMs)
emit()
}
export function dismissToast(): void {
clearTimer()
current = null
emit()
}
export function getToast(): Toast | null {
return current
}
export function subscribeToasts(listener: () => void): () => void {
listeners.add(listener)
return () => {
listeners.delete(listener)
}
}
/** Register a Toast host. Only the first-registered host renders (so App and a
* standalone-tested Settings can both mount one without doubling the toast). The
* store resets when the last host unmounts, keeping tests isolated. */
export function registerToastHost(): { id: number; release: () => void } {
const id = ++hostSeq
hosts.push(id)
emit()
return {
id,
release: () => {
const index = hosts.indexOf(id)
if (index >= 0) hosts.splice(index, 1)
if (hosts.length === 0) {
clearTimer()
current = null
}
emit()
},
}
}
export function isPrimaryHost(id: number): boolean {
return hosts.length > 0 && hosts[0] === id
}

586
app/renderer/lib/types.ts Normal file
View file

@ -0,0 +1,586 @@
// Types mirrored verbatim from the codeburn CLI (`src/*`). The renderer is a
// pure view over CLI JSON, so these shapes must match the emitters exactly.
// Do not invent fields — copy from the cited source files.
// ————— Period + IPC error contract —————
export type Period = 'today' | 'week' | '30days' | 'month' | 'all'
export type DateRange = { from: string; to: string }
export type CliErrorKind = 'not-found' | 'nonzero' | 'bad-json' | 'timeout' | 'too-large' | 'bad-args'
/** Structured failure surfaced across the IPC boundary as plain data. */
export interface CliError {
kind: CliErrorKind
message: string
}
export type AliasRow = { from: string; to: string }
export type ActionResult = { ok: boolean; stdout: string; stderr: string; code: number | null }
export type QuotaWindow = {
label: string
percent: number
resetsAt: string | null
}
export type QuotaProvider = {
provider: 'claude' | 'codex'
connection: 'connected' | 'disconnected' | 'accessDenied' | 'loading' | 'stale' | 'transientFailure' | 'terminalFailure'
primary: QuotaWindow | null
details: QuotaWindow[]
planLabel: string | null
footerLines: string[]
}
// ————— src/menubar-json.ts —————
export type DailyModelBreakdown = {
name: string
cost: number
savingsUSD: number
calls: number
inputTokens: number
outputTokens: number
}
export type DailyHistoryEntry = {
date: string
cost: number
savingsUSD: number
calls: number
inputTokens: number
outputTokens: number
cacheReadTokens: number
cacheWriteTokens: number
topModels: DailyModelBreakdown[]
}
export type LocalModelSavings = {
totalUSD: number
calls: number
byModel: Array<{
name: string
calls: number
actualUSD: number
savingsUSD: number
baselineModel: string
inputTokens: number
outputTokens: number
}>
byProvider: Array<{ name: string; calls: number; savingsUSD: number }>
}
export type DeviceSummary = {
id: string
name: string
local: boolean
error?: string
cost: number
calls: number
sessions: number
inputTokens: number
outputTokens: number
cacheCreateTokens: number
cacheReadTokens: number
totalTokens: number
}
export type CombinedUsage = {
perDevice: DeviceSummary[]
combined: {
cost: number
calls: number
sessions: number
inputTokens: number
outputTokens: number
cacheCreateTokens: number
cacheReadTokens: number
totalTokens: number
deviceCount: number
reachableCount: number
}
}
export type ClaudeConfigOption = {
id: string
label: string
path: string
}
export type ClaudeConfigSelector = {
selectedId: string | null
options: ClaudeConfigOption[]
}
export type MenubarPayload = {
generated: string
current: {
label: string
cost: number
calls: number
sessions: number
oneShotRate: number | null
inputTokens: number
outputTokens: number
cacheReadTokens: number
cacheWriteTokens: number
cacheHitPercent: number
codexCredits: number
topActivities: Array<{
name: string
cost: number
savingsUSD: number
turns: number
oneShotRate: number | null
}>
topModels: Array<{
name: string
cost: number
savingsUSD: number
savingsBaselineModel: string
calls: number
}>
unpricedModels?: Array<{ model: string; calls: number; tokens: number }>
localModelSavings: LocalModelSavings
providers: Record<string, number>
// Optional: older CLIs omit it. `id` is the internal provider name (round-trips
// as --provider), `label` the display name. Fall back to `providers` when absent.
providerDetails?: Array<{ id: string; label: string; cost: number }>
topProjects: Array<{
name: string
cost: number
savingsUSD: number
sessions: number
avgCostPerSession: number
sessionDetails: Array<{
cost: number
savingsUSD: number
calls: number
inputTokens: number
outputTokens: number
date: string
models: Array<{ name: string; cost: number; savingsUSD: number }>
}>
}>
modelEfficiency: Array<{
name: string
costPerEdit: number | null
oneShotRate: number | null
}>
topSessions: Array<{
project: string
cost: number
savingsUSD: number
calls: number
date: string
}>
retryTax: {
totalUSD: number
retries: number
editTurns: number
byModel: Array<{
name: string
taxUSD: number
retries: number
retriesPerEdit: number | null
}>
}
routingWaste: {
totalSavingsUSD: number
baselineModel: string
baselineCostPerEdit: number
byModel: Array<{
name: string
costPerEdit: number
editTurns: number
actualUSD: number
counterfactualUSD: number
savingsUSD: number
}>
}
tools: Array<{ name: string; calls: number }>
skills: Array<{ name: string; turns: number; cost: number }>
subagents: Array<{ name: string; calls: number; cost: number }>
mcpServers: Array<{ name: string; calls: number }>
}
optimize: {
findingCount: number
savingsUSD: number
topFindings: Array<{
title: string
impact: 'high' | 'medium' | 'low'
savingsUSD: number
}>
}
history: {
daily: DailyHistoryEntry[]
}
// Active display currency. Payload costs are raw USD; the renderer multiplies by
// `rate` and prefixes `symbol` at display time. Optional: older CLIs omit it.
currency?: { code: string; symbol: string; rate: number }
combined?: CombinedUsage
claudeConfigs?: ClaudeConfigSelector
}
// ————— src/types.ts + src/models-report.ts —————
export type TaskCategory =
| 'coding'
| 'debugging'
| 'feature'
| 'refactoring'
| 'testing'
| 'exploration'
| 'planning'
| 'delegation'
| 'git'
| 'build/deploy'
| 'conversation'
| 'brainstorming'
| 'general'
export type ModelReportRow = {
provider: string
providerDisplayName: string
model: string
modelDisplayName: string
category: TaskCategory | null
inputTokens: number
outputTokens: number
cacheWriteTokens: number
cacheReadTokens: number
totalTokens: number
costUSD: number
savingsUSD: number
savingsBaselineModel: string
calls: number
credits: number | null
topCategory?: TaskCategory
topCategoryCost?: number
topCategoryShare?: number
}
// ————— src/yield.ts —————
export type YieldCategory = 'productive' | 'reverted' | 'abandoned'
export type YieldBucketJson = {
costUSD: number
sessions: number
costPercent: number
sessionPercent: number
}
export type SessionYieldJson = {
sessionId: string
project: string
category: YieldCategory
commitCount: number
costUSD: number
}
export type YieldJsonReport = {
period: {
label: string
start: string
end: string
}
summary: {
productive: YieldBucketJson
reverted: YieldBucketJson
abandoned: YieldBucketJson
total: { costUSD: number; sessions: number }
productiveToRevertedCostRatio: number | null
}
details: SessionYieldJson[]
}
// ————— src/config.ts + src/plan-usage.ts + src/main.ts (status --format json) —————
export type PlanId =
| 'claude-pro'
| 'claude-max'
| 'claude-max-5x'
| 'cursor-pro'
| 'supergrok'
| 'supergrok-heavy'
| 'custom'
| 'none'
export type PlanProvider = 'claude' | 'codex' | 'cursor' | 'grok' | 'all'
export type PlanStatus = 'under' | 'near' | 'over'
/** Serialized plan summary from `attachPlanSummaries` (src/main.ts:90). */
export type JsonPlanSummary = {
id: PlanId
provider: PlanProvider
budget: number
spent: number
percentUsed: number
status: PlanStatus
projectedMonthEnd: number
daysUntilReset: number
periodStart: string
periodEnd: string
}
/** `codeburn status --format json` payload (src/main.ts:751), with plan summaries attached. */
export type StatusJson = {
currency: string
today: { cost: number; savings: number; calls: number }
month: { cost: number; savings: number; calls: number }
localModelSavings?: { today: number; month: number; callsToday: number; callsMonth: number }
plan?: JsonPlanSummary
plans?: Partial<Record<PlanProvider, JsonPlanSummary>>
}
// ————— T1a: src/spend-flow.ts (defined by the shared contract) —————
export type SpendFlowNode = { id: string; label: string; cost: number }
export type SpendFlowLink = { model: string; project: string; cost: number }
export type SpendFlow = {
period: { label: string; start: string; end: string }
models: SpendFlowNode[]
projects: SpendFlowNode[]
links: SpendFlowLink[]
}
// ————— src/optimize.ts —————
export type WasteAction =
| { type: 'paste'; label: string; text: string; destination?: 'claude-md' | 'session-opener' | 'prompt' | 'shell-config' }
| { type: 'command'; label: string; text: string }
| { type: 'file-content'; label: string; path: string; content: string }
export type OptimizeJsonReport = {
period: { label: string; start: string | null; end: string | null }
summary: {
healthScore: number
healthGrade: 'A' | 'B' | 'C' | 'D' | 'F'
findingCount: number
periodCostUSD: number
sessions: number
calls: number
potentialSavingsTokens: number
potentialSavingsCostUSD: number
potentialSavingsPercent: number | null
costRateUSD: number
}
findings: Array<{
id: string
title: string
explanation: string
severity: 'high' | 'medium' | 'low'
trend: 'active' | 'improving' | null
tokensSaved: number
estimatedSavingsUSD: number
fix: WasteAction
}>
}
// ————— T1b: src/sharing/* (defined by the shared contract) —————
export type PendingPairing = { id: string; name: string; code: string }
export type ShareStatus = {
sharing: boolean
name: string
port: number
always: boolean
peers: number
pending: PendingPairing[]
}
/** Public identity subset served by /api/identity (src/web-dashboard.ts:229). */
export type Identity = {
name: string
fingerprint: string
}
export type ScannedDevice = {
name: string
host: string
port: number
fingerprint: string
code: string
paired: boolean
}
export type DeviceScanResult = { found: ScannedDevice[] }
// ————— src/act/report.ts buildActReportJson —————
export type ActReportJson = {
totals: {
realizedCostUSD: number
measuredActions: number
}
}
// ————— src/sessions-report.ts —————
export type SessionRow = {
sessionId: string
project: string
provider: string
models: string[]
cost: number
savingsUSD: number
calls: number
turns: number
inputTokens: number
outputTokens: number
cacheReadTokens: number
cacheWriteTokens: number
startedAt: string
endedAt: string
durationMs: number
}
// ————— src/compare-stats.ts —————
export type ModelStats = {
model: string
calls: number
cost: number
outputTokens: number
inputTokens: number
cacheReadTokens: number
cacheWriteTokens: number
totalTurns: number
editTurns: number
oneShotTurns: number
retries: number
selfCorrections: number
editCost: number
firstSeen: string
lastSeen: string
}
export type ComparisonRow = {
section: string
label: string
valueA: number | null
valueB: number | null
formatFn: 'cost' | 'number' | 'percent' | 'decimal'
winner: 'a' | 'b' | 'tie' | 'none'
}
export type CategoryComparison = {
category: string
turnsA: number
editTurnsA: number
oneShotRateA: number | null
turnsB: number
editTurnsB: number
oneShotRateB: number | null
winner: 'a' | 'b' | 'tie' | 'none'
}
export type WorkingStyleRow = {
label: string
valueA: number | null
valueB: number | null
formatFn: ComparisonRow['formatFn']
}
export type CompareJsonReport = {
period: { label: string; provider: string }
modelA: ModelStats
modelB: ModelStats
metrics: ComparisonRow[]
categories: CategoryComparison[]
workingStyle: WorkingStyleRow[]
}
// ————— src/models.ts + src/audit-report.ts (audit --format json) —————
/** Per-token rates used for pricing (src/models.ts ModelCosts). */
export type ModelCosts = {
inputCostPerToken: number
outputCostPerToken: number
cacheWriteCostPerToken: number
cacheReadCostPerToken: number
webSearchCostPerRequest: number
fastMultiplier: number
}
/** One (provider, model) audit bucket (src/audit-report.ts AuditRow): raw
* provider token fields vs the normalized totals codeburn prices. */
export type AuditRow = {
provider: string
providerDisplayName: string
model: string
modelDisplayName: string
calls: number
raw: {
inputTokens: number
outputTokens: number
reasoningTokens: number
cacheCreationInputTokens: number
cacheReadInputTokens: number
cachedInputTokens: number
webSearchRequests: number
}
displayed: {
inputTokens: number
outputTokens: number
cacheWriteTokens: number
cacheReadTokens: number
}
rates: ModelCosts | null
cost: {
input: number
output: number
cacheWrite: number
cacheRead: number
webSearch: number
recomputedTotalUSD: number
}
attributedCostUSD: number
}
// ————— src/main.ts (price-override --list --format json) —————
/** Rates are USD per 1,000,000 tokens; cache rates are optional. */
export type PriceOverrideRow = {
model: string
inputPerM: number
outputPerM: number
cacheReadPerM?: number
cacheCreationPerM?: number
}
export type PriceOverrideList = { overrides: PriceOverrideRow[]; configPath: string }
/** A partial set of the four price-override rates, USD per 1M tokens. */
export type PriceRates = { input?: number; output?: number; cacheRead?: number; cacheCreation?: number }
// ————— IPC surface (preload contextBridge → window.codeburn) —————
export interface CodeburnBridge {
getQuota(force?: boolean): Promise<QuotaProvider[]>
getOverview(period: Period, provider: string, range?: DateRange, configSource?: string | null): Promise<MenubarPayload>
getPlans(period: Period): Promise<StatusJson>
getActReport(): Promise<ActReportJson>
readonly platform: string
getModels(period: Period, provider: string, byTask: boolean, range?: DateRange): Promise<ModelReportRow[]>
getSessions(period: Period, provider: string, range?: DateRange): Promise<SessionRow[]>
getCompareModels(period: Period, provider: string): Promise<ModelStats[]>
getCompare(period: Period, provider: string, modelA: string, modelB: string): Promise<CompareJsonReport>
getYield(period: Period, provider: string, range?: DateRange): Promise<YieldJsonReport>
getSpendFlow(period: Period, provider: string, range?: DateRange): Promise<SpendFlow>
getOptimizeReport(period: Period, provider: string, range?: DateRange): Promise<OptimizeJsonReport>
getDevices(period: Period): Promise<CombinedUsage>
getDevicesScan(): Promise<DeviceScanResult>
getShareStatus(): Promise<ShareStatus>
getIdentity(): Promise<Identity>
getAliases(): Promise<AliasRow[]>
getProxyPaths(): Promise<string[]>
getAudit(period: Period, provider: string, range?: DateRange): Promise<AuditRow[]>
getPriceOverrides(): Promise<PriceOverrideList>
setPriceOverride(model: string, rates: PriceRates): Promise<ActionResult>
removePriceOverride(model: string): Promise<ActionResult>
setCurrency(code: string): Promise<ActionResult>
resetCurrency(): Promise<ActionResult>
addAlias(from: string, to: string): Promise<ActionResult>
removeAlias(from: string): Promise<ActionResult>
removeDevice(name: string): Promise<ActionResult>
setPlan(id: string, provider: string): Promise<ActionResult>
resetPlan(provider: string): Promise<ActionResult>
exportData(format: string, provider: string, outPath: string): Promise<ActionResult>
chooseDirectory(): Promise<string | null>
cliStatus(): Promise<{ found: boolean; path: string | null; error?: string }>
openExternal(url: string): Promise<void>
}

20
app/renderer/main.tsx Normal file
View file

@ -0,0 +1,20 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { App } from './App'
import './styles/indigo.css'
import './styles/plain.css'
const root = document.getElementById('root')
if (!root) throw new Error('#root not found')
// Tag the platform so CSS can adapt native chrome (macOS hiddenInset insets +
// drag regions); harmless when the bridge is absent (tests/jsdom).
document.documentElement.dataset.platform =
(window as unknown as { codeburn?: { platform?: string } }).codeburn?.platform ?? ''
createRoot(root).render(
<StrictMode>
<App />
</StrictMode>,
)

View file

@ -0,0 +1,113 @@
// @vitest-environment jsdom
import { render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { CompareJsonReport, ModelStats } from '../lib/types'
import { Compare } from './Compare'
const mocks = vi.hoisted(() => ({
getCompareModels: vi.fn<(period: string, provider: string) => Promise<ModelStats[]>>(),
getCompare: vi.fn<(period: string, provider: string, modelA: string, modelB: string) => Promise<CompareJsonReport>>(),
}))
vi.mock('../lib/ipc', async orig => {
const actual = await orig<typeof import('../lib/ipc')>()
return { ...actual, codeburn: mocks }
})
const modelA: ModelStats = {
model: 'Opus 4.8', calls: 4812, cost: 331.2, outputTokens: 9_640_000, inputTokens: 152_600_000,
cacheReadTokens: 119_400_000, cacheWriteTokens: 16_000_000, totalTurns: 1000, editTurns: 786,
oneShotTurns: 558, retries: 267, selfCorrections: 33, editCost: 0.42,
firstSeen: '2026-06-12T00:00:00.000Z', lastSeen: '2026-07-11T00:00:00.000Z',
}
const modelB: ModelStats = {
model: 'Sonnet 5', calls: 3318, cost: 108.63, outputTokens: 6_080_000, inputTokens: 77_700_000,
cacheReadTokens: 63_300_000, cacheWriteTokens: 7_000_000, totalTurns: 850, editTurns: 641,
oneShotTurns: 404, retries: 300, selfCorrections: 40, editCost: 0.19,
firstSeen: '2026-06-14T00:00:00.000Z', lastSeen: '2026-07-11T00:00:00.000Z',
}
const report: CompareJsonReport = {
period: { label: 'Last 30 days', provider: 'all' },
modelA,
modelB,
metrics: [
{ section: 'Performance', label: 'One-shot rate', valueA: 71, valueB: 63, formatFn: 'percent', winner: 'a' },
{ section: 'Efficiency', label: 'Cost / call', valueA: 0.069, valueB: 0.033, formatFn: 'cost', winner: 'b' },
],
categories: [
{ category: 'Coding', turnsA: 400, editTurnsA: 312, oneShotRateA: 74, turnsB: 350, editTurnsB: 280, oneShotRateB: 66, winner: 'a' },
],
workingStyle: [
{ label: 'Planning rate', valueA: 22, valueB: 9, formatFn: 'percent' },
],
}
describe('Compare', () => {
beforeEach(() => {
mocks.getCompareModels.mockReset()
mocks.getCompare.mockReset()
})
it('defaults to the top two and renders formatted report panels and winners', async () => {
const user = userEvent.setup()
mocks.getCompareModels.mockResolvedValue([modelA, modelB])
mocks.getCompare.mockResolvedValue(report)
render(<Compare period="30days" provider="all" />)
const first = await screen.findByLabelText('First model')
const second = screen.getByLabelText('Second model')
await waitFor(() => {
expect(first).toHaveTextContent('Opus 4.8 · 4,812 calls')
expect(second).toHaveTextContent('Sonnet 5 · 3,318 calls')
})
expect(await screen.findByText('Performance')).toBeInTheDocument()
expect(mocks.getCompare).toHaveBeenCalledWith('30days', 'all', 'Opus 4.8', 'Sonnet 5')
expect(screen.getByText('Efficiency')).toBeInTheDocument()
expect(screen.getByText('Context')).toBeInTheDocument()
expect(screen.getByText('71%')).toHaveClass('cmp-best')
expect(screen.getByText('$0.03')).toHaveClass('cmp-best')
expect(screen.getByText('$331.20')).toBeInTheDocument()
expect(screen.getByText('152.6M')).toBeInTheDocument()
expect(screen.getByText('9.6M')).toBeInTheDocument()
const context = screen.getByText('Context').closest<HTMLElement>('.cmp-card')!
expect(within(context).getByText('Cache hit rate')).toBeInTheDocument()
expect(within(context).getByText('Days of data')).toBeInTheDocument()
await user.click(second)
await user.click(screen.getByRole('option', { name: 'Opus 4.8 · 4,812 calls' }))
await waitFor(() => expect(first).toHaveTextContent('Sonnet 5 · 3,318 calls'))
expect(mocks.getCompare).toHaveBeenCalledWith('30days', 'all', 'Sonnet 5', 'Opus 4.8')
})
it('computes cache hit rate over input + cache reads (excludes cache writes)', async () => {
mocks.getCompareModels.mockResolvedValue([modelA, modelB])
mocks.getCompare.mockResolvedValue(report)
render(<Compare period="30days" provider="all" />)
const context = (await screen.findByText('Context')).closest<HTMLElement>('.cmp-card')!
const row = within(context).getByText('Cache hit rate').closest('.cmp-metric')!
// 119.4M / (152.6M + 119.4M) = 44%, not 119.4 / (152.6 + 119.4 + 16) = 41%.
expect(row).toHaveTextContent('44%')
expect(row).not.toHaveTextContent('41%')
})
it('notes that custom ranges are unsupported and still compares by period', async () => {
mocks.getCompareModels.mockResolvedValue([modelA, modelB])
mocks.getCompare.mockResolvedValue(report)
render(<Compare period="30days" provider="all" range={{ from: '2026-07-01', to: '2026-07-11' }} />)
expect(await screen.findByText('Compare uses the selected period, custom dates are not supported yet.')).toBeInTheDocument()
expect(mocks.getCompareModels).toHaveBeenCalledWith('30days', 'all')
})
it('renders the need-two-models note without requesting a report', async () => {
mocks.getCompareModels.mockResolvedValue([modelA])
render(<Compare period="week" provider="all" />)
expect(await screen.findByText('Need at least two models with usage in this range to compare.')).toBeInTheDocument()
expect(mocks.getCompare).not.toHaveBeenCalled()
})
})

View file

@ -0,0 +1,269 @@
import { useCallback, useEffect, useState } from 'react'
import { CliErrorPanel } from '../components/CliErrorPanel'
import { Dropdown } from '../components/Dropdown'
import { EmptyNote } from '../components/EmptyState'
import { Panel } from '../components/Panel'
import { SectionSkeleton } from '../components/Skeleton'
import { usePolled } from '../hooks/usePolled'
import { formatCompact, formatUsd } from '../lib/format'
import { codeburn } from '../lib/ipc'
import type { CompareJsonReport, ComparisonRow, DateRange, ModelStats, Period, WorkingStyleRow } from '../lib/types'
function fmtMetric(v: number | null, fn: 'cost' | 'number' | 'percent' | 'decimal'): string {
if (v === null) return '—'
if (fn === 'cost') return formatUsd(v)
if (fn === 'percent') return `${v.toFixed(0)}%`
if (fn === 'decimal') return v.toFixed(2)
return Math.round(v).toLocaleString('en-US')
}
// The CLI `compare` command has no --from/--to, so a custom range falls back to
// the selected period. Say so instead of silently ignoring the dates.
function RangeNote() {
return (
<p className="cmp-range-note" role="status">
Compare uses the selected period, custom dates are not supported yet.
</p>
)
}
export function Compare({
period,
provider,
range = null,
refreshToken = 0,
}: {
period: Period
provider: string
range?: DateRange | null
refreshToken?: number
}) {
const models = usePolled<ModelStats[]>(
() => codeburn.getCompareModels(period, provider),
[period, provider, refreshToken],
)
const [modelA, setModelA] = useState<string | null>(null)
const [modelB, setModelB] = useState<string | null>(null)
useEffect(() => {
if (!models.data) return
const available = new Set(models.data.map(model => model.model))
setModelA(current => current && available.has(current) ? current : models.data?.[0]?.model ?? null)
setModelB(current => current && available.has(current) ? current : models.data?.[1]?.model ?? null)
}, [models.data])
const resetToDefaults = useCallback(() => {
if (!models.data) return
setModelA(models.data[0]?.model ?? null)
setModelB(models.data[1]?.model ?? null)
}, [models.data])
if (!models.data) {
if (models.error) return <CliErrorPanel error={models.error} subject="model comparisons" />
return <SectionSkeleton label="Scanning model usage…" rows={4} />
}
if (models.data.length < 2) {
return (
<Panel title="Compare">
<EmptyNote>Need at least two models with usage in this range to compare.</EmptyNote>
</Panel>
)
}
const modelRows = models.data
const nudgeDistinct = (chosen: string) => modelRows.find(model => model.model !== chosen)?.model ?? null
return (
<>
{range && <RangeNote />}
<div className="cmp-picker" aria-label="Models being compared">
<Dropdown
id="compare-first-model"
ariaLabel="First model"
value={modelA ?? ''}
options={modelRows.map(model => ({ value: model.model, label: `${model.model} · ${model.calls.toLocaleString()} calls` }))}
onChange={next => {
setModelA(next)
if (next === modelB) setModelB(nudgeDistinct(next))
}}
/>
<span className="cmp-vs">vs</span>
<Dropdown
id="compare-second-model"
ariaLabel="Second model"
value={modelB ?? ''}
options={modelRows.map(model => ({ value: model.model, label: `${model.model} · ${model.calls.toLocaleString()} calls` }))}
onChange={next => {
setModelB(next)
if (next === modelA) setModelA(nudgeDistinct(next))
}}
/>
</div>
{modelA && modelB && modelA !== modelB && (
<CompareReport
period={period}
provider={provider}
modelA={modelA}
modelB={modelB}
refreshToken={refreshToken}
onError={resetToDefaults}
/>
)}
</>
)
}
function CompareReport({
period,
provider,
modelA,
modelB,
refreshToken,
onError,
}: {
period: Period
provider: string
modelA: string
modelB: string
refreshToken: number
onError: () => void
}) {
const report = usePolled<CompareJsonReport>(
() => codeburn.getCompare(period, provider, modelA, modelB),
[period, provider, modelA, modelB, refreshToken],
)
useEffect(() => {
if (report.error) onError()
}, [report.error, onError])
if (!report.data) {
if (report.error) return <CliErrorPanel error={report.error} subject="model comparisons" />
return <SectionSkeleton label="Comparing models…" rows={4} />
}
const performance = report.data.metrics.filter(metric => metric.section === 'Performance')
const efficiency = report.data.metrics.filter(metric => metric.section === 'Efficiency')
return (
<div className="cmp-body">
<div className="cmp-pair">
<MetricCard title="Performance" rows={performance} modelA={report.data.modelA.model} modelB={report.data.modelB.model} showWinners />
<MetricCard title="Efficiency" rows={efficiency} modelA={report.data.modelA.model} modelB={report.data.modelB.model} showWinners />
</div>
<CategoryCard report={report.data} />
<div className="cmp-pair">
<MetricCard title="Working style" rows={report.data.workingStyle} modelA={report.data.modelA.model} modelB={report.data.modelB.model} />
<ContextCard modelA={report.data.modelA} modelB={report.data.modelB} />
</div>
</div>
)
}
function MetricCard({
title,
rows,
modelA,
modelB,
showWinners = false,
}: {
title: string
rows: Array<ComparisonRow | WorkingStyleRow>
modelA: string
modelB: string
showWinners?: boolean
}) {
return (
<div className="panel cmp-card">
<div className="cmp-head"><h3>{title}</h3></div>
<div className="cmp-metrics">
<MetricHeader modelA={modelA} modelB={modelB} />
{rows.map(row => {
const winner = 'winner' in row ? row.winner : 'none'
return (
<div className="cmp-metric" key={row.label}>
<span className="cmp-label">{row.label}</span>
<span className={`cmp-value${showWinners && winner === 'a' ? ' cmp-best' : ''}`}>{fmtMetric(row.valueA, row.formatFn)}</span>
<span className={`cmp-value${showWinners && winner === 'b' ? ' cmp-best' : ''}`}>{fmtMetric(row.valueB, row.formatFn)}</span>
</div>
)
})}
</div>
{showWinners && <div className="cmp-foot">Green = better on that metric.</div>}
</div>
)
}
function MetricHeader({ modelA, modelB }: { modelA: string; modelB: string }) {
return <div className="cmp-metric-head"><span>Metric</span><span>{modelA}</span><span>{modelB}</span></div>
}
function CategoryCard({ report }: { report: CompareJsonReport }) {
return (
<div className="panel cmp-card">
<div className="cmp-head"><h3>Category head-to-head</h3><span className="cmp-head-note">One-shot rate · edit turns</span></div>
<div className="cmp-category-body">
<div className="cmp-legend">
<span className="cmp-legend-item"><span className="cmp-key" />{report.modelA.model}</span>
<span className="cmp-legend-item"><span className="cmp-key cmp-key-b" />{report.modelB.model}</span>
</div>
<div className="cmp-categories">
{report.categories.map(category => (
<div className="cmp-category" key={category.category}>
<span className="cmp-category-name">{category.category}</span>
<div className="cmp-bars">
<div className="cmp-bar-row">
<span className="cmp-track"><span className="cmp-bar" style={{ width: `${category.oneShotRateA ?? 0}%` }} /></span>
<span className={`cmp-bar-value${category.winner === 'a' ? ' cmp-best' : ''}`}>{fmtMetric(category.oneShotRateA, 'percent')} <span className="cmp-turns">({category.editTurnsA})</span></span>
</div>
<div className="cmp-bar-row">
<span className="cmp-track"><span className="cmp-bar cmp-bar-b" style={{ width: `${category.oneShotRateB ?? 0}%` }} /></span>
<span className={`cmp-bar-value${category.winner === 'b' ? ' cmp-best' : ''}`}>{fmtMetric(category.oneShotRateB, 'percent')} <span className="cmp-turns">({category.editTurnsB})</span></span>
</div>
</div>
</div>
))}
</div>
</div>
</div>
)
}
function cacheHitRate(model: ModelStats): string {
// reads over reads + fresh input (matches menubar-json + compare-stats).
const total = model.inputTokens + model.cacheReadTokens
return total > 0 ? `${Math.round(model.cacheReadTokens / total * 100)}%` : '—'
}
function daysOfData(model: ModelStats): string {
if (!model.firstSeen || !model.lastSeen) return '—'
return String(Math.max(1, Math.round((new Date(model.lastSeen).getTime() - new Date(model.firstSeen).getTime()) / 86_400_000) + 1))
}
function ContextCard({ modelA, modelB }: { modelA: ModelStats; modelB: ModelStats }) {
const rows = [
['Calls', modelA.calls.toLocaleString(), modelB.calls.toLocaleString()],
['Total cost', formatUsd(modelA.cost), formatUsd(modelB.cost)],
['Input tokens', formatCompact(modelA.inputTokens), formatCompact(modelB.inputTokens)],
['Output tokens', formatCompact(modelA.outputTokens), formatCompact(modelB.outputTokens)],
['Edit turns', modelA.editTurns.toLocaleString(), modelB.editTurns.toLocaleString()],
['Self-corrections', modelA.selfCorrections.toLocaleString(), modelB.selfCorrections.toLocaleString()],
['Cache hit rate', cacheHitRate(modelA), cacheHitRate(modelB)],
['Days of data', daysOfData(modelA), daysOfData(modelB)],
]
return (
<div className="panel cmp-card">
<div className="cmp-head"><h3>Context</h3></div>
<div className="cmp-metrics">
<MetricHeader modelA={modelA.model} modelB={modelB.model} />
{rows.map(([label, valueA, valueB]) => (
<div className="cmp-metric" key={label}>
<span className="cmp-label">{label}</span><span className="cmp-value">{valueA}</span><span className="cmp-value">{valueB}</span>
</div>
))}
</div>
</div>
)
}

View file

@ -0,0 +1,259 @@
// @vitest-environment jsdom
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AuditRow, ModelReportRow } from '../lib/types'
import { Models } from './Models'
const { getModels, getAudit } = vi.hoisted(() => ({
getModels: vi.fn<(period: string, provider: string, byTask: boolean) => Promise<ModelReportRow[]>>(),
getAudit: vi.fn<(period: string, provider: string) => Promise<AuditRow[]>>(),
}))
vi.mock('../lib/ipc', async orig => {
const actual = await orig<typeof import('../lib/ipc')>()
return { ...actual, codeburn: { getModels, getAudit } }
})
const rows: ModelReportRow[] = [
{
provider: 'anthropic',
providerDisplayName: 'Anthropic',
model: 'claude-opus-4.8',
modelDisplayName: 'Claude Opus 4.8',
category: null,
topCategory: 'coding',
topCategoryShare: 0.71,
inputTokens: 152_600_000,
outputTokens: 9_640_000,
cacheWriteTokens: 16_000_000,
cacheReadTokens: 119_400_000,
totalTokens: 297_640_000,
calls: 4812,
costUSD: 331.2,
savingsUSD: 86.4,
savingsBaselineModel: 'Claude Opus 4.8',
credits: null,
},
{
provider: 'codex',
providerDisplayName: 'Codex',
model: 'gpt-5.5-codex',
modelDisplayName: 'GPT-5.5 Codex',
category: null,
topCategory: 'debugging',
topCategoryShare: 0.42,
inputTokens: 86_900_000,
outputTokens: 7_520_000,
cacheWriteTokens: 3_200_000,
cacheReadTokens: 45_100_000,
totalTokens: 142_720_000,
calls: 2704,
costUSD: 137.9,
savingsUSD: 35.1,
savingsBaselineModel: 'GPT-5.5 Codex',
credits: 173,
},
{
provider: 'local',
providerDisplayName: 'Local',
model: 'llama-local',
modelDisplayName: 'Llama Local',
category: null,
inputTokens: 750_000,
outputTokens: 400_000,
cacheWriteTokens: 0,
cacheReadTokens: 0,
totalTokens: 1_150_000,
calls: 82,
costUSD: 0,
savingsUSD: 12.34,
savingsBaselineModel: 'Claude Opus 4.8',
credits: null,
},
{
provider: 'custom',
providerDisplayName: 'Custom',
model: 'my-proxy-model',
modelDisplayName: 'my-proxy-model',
category: null,
inputTokens: 4_800_000,
outputTokens: 400_000,
cacheWriteTokens: 0,
cacheReadTokens: 0,
totalTokens: 5_200_000,
calls: 176,
costUSD: 0,
savingsUSD: 0,
savingsBaselineModel: '',
credits: null,
},
]
const byTaskRows: ModelReportRow[] = [
{
...rows[0],
category: 'coding',
calls: 3400,
inputTokens: 100_000_000,
outputTokens: 6_100_000,
cacheReadTokens: 88_000_000,
totalTokens: 210_100_000,
costUSD: 244.12,
savingsUSD: 61.22,
},
{
...rows[0],
category: 'delegation',
calls: 120,
inputTokens: 8_000_000,
outputTokens: 500_000,
cacheReadTokens: 6_000_000,
totalTokens: 14_500_000,
costUSD: 20.88,
savingsUSD: 5.18,
},
]
const auditRows: AuditRow[] = [
{
provider: 'anthropic',
providerDisplayName: 'Anthropic',
model: 'claude-opus-4.8',
modelDisplayName: 'Claude Opus 4.8',
calls: 1200,
raw: { inputTokens: 50_000_000, outputTokens: 3_100_000, reasoningTokens: 900_000, cacheCreationInputTokens: 8_000_000, cacheReadInputTokens: 40_000_000, cachedInputTokens: 0, webSearchRequests: 0 },
displayed: { inputTokens: 50_000_000, outputTokens: 4_000_000, cacheWriteTokens: 8_000_000, cacheReadTokens: 40_000_000 },
rates: { inputCostPerToken: 0.000003, outputCostPerToken: 0.000015, cacheWriteCostPerToken: 0.00000375, cacheReadCostPerToken: 0.0000003, webSearchCostPerRequest: 0.01, fastMultiplier: 1 },
cost: { input: 150, output: 60, cacheWrite: 30, cacheRead: 12, webSearch: 0, recomputedTotalUSD: 252 },
attributedCostUSD: 252,
},
{
provider: 'custom',
providerDisplayName: 'Custom',
model: 'my-proxy-model',
modelDisplayName: 'my-proxy-model',
calls: 90,
raw: { inputTokens: 4_800_000, outputTokens: 400_000, reasoningTokens: 0, cacheCreationInputTokens: 0, cacheReadInputTokens: 0, cachedInputTokens: 0, webSearchRequests: 0 },
displayed: { inputTokens: 4_800_000, outputTokens: 400_000, cacheWriteTokens: 0, cacheReadTokens: 0 },
rates: null,
cost: { input: 0, output: 0, cacheWrite: 0, cacheRead: 0, webSearch: 0, recomputedTotalUSD: 0 },
attributedCostUSD: 0,
},
]
describe('Models', () => {
beforeEach(() => {
getModels.mockReset()
getAudit.mockReset()
})
it('renders priced model rows with series dots, costs, and savings', async () => {
getModels.mockResolvedValue(rows)
const { container } = render(<Models period="30days" provider="all" />)
expect(await screen.findByText('Claude Opus 4.8')).toBeInTheDocument()
expect(screen.getByText('4,812')).toBeInTheDocument()
expect(screen.getByText('152.6M')).toBeInTheDocument()
expect(screen.getByText('9.6M')).toBeInTheDocument()
expect(screen.getByText('119.4M')).toBeInTheDocument()
expect(screen.getByText('$331.20')).toBeInTheDocument()
expect(screen.getByText('$86.40')).toHaveClass('pos')
expect(screen.getByText('GPT-5.5 Codex')).toBeInTheDocument()
expect(screen.getByText('$137.90')).toBeInTheDocument()
expect(screen.getByText('$35.10')).toHaveClass('pos')
const dots = [...container.querySelectorAll('.mdot')]
expect(dots[0]).toHaveAttribute('style', expect.stringContaining('var(--s-opus)'))
expect(dots[1]).toHaveAttribute('style', expect.stringContaining('var(--s-gpt)'))
})
it('renders codex rows with credits and real cost as priced', async () => {
getModels.mockResolvedValue([rows[1]])
render(<Models period="30days" provider="all" />)
expect(await screen.findByText('GPT-5.5 Codex')).not.toHaveClass('dim')
expect(screen.getByText('$137.90')).not.toHaveClass('dim')
expect(screen.getByText('$35.10')).toHaveClass('pos')
expect(screen.queryByText('add alias ')).not.toBeInTheDocument()
})
it('renders local saved-only rows as priced with real savings', async () => {
getModels.mockResolvedValue([rows[2]])
render(<Models period="30days" provider="all" />)
expect(await screen.findByText('Llama Local')).not.toHaveClass('dim')
expect(screen.getByText('750K')).toBeInTheDocument()
expect(screen.getByText('400K')).toBeInTheDocument()
expect(screen.getByText('$0.00')).not.toHaveClass('dim')
expect(screen.getByText('$12.34')).toHaveClass('pos')
expect(screen.queryByText('add alias ')).not.toBeInTheDocument()
})
it('renders unpriced proxy rows as dim with alias affordance and dashes', async () => {
getModels.mockResolvedValue([rows[3]])
render(<Models period="30days" provider="all" />)
expect(await screen.findByText('my-proxy-model')).toHaveClass('dim')
expect(screen.getByText('add alias ')).toHaveClass('alias')
expect(screen.getAllByText('—')).toHaveLength(5)
expect(screen.queryByText('$0.00')).not.toBeInTheDocument()
})
it('refetches with byTask=true and renders the task category', async () => {
getModels.mockResolvedValueOnce(rows).mockResolvedValueOnce(byTaskRows)
render(<Models period="week" provider="anthropic" />)
expect(await screen.findByText('Claude Opus 4.8')).toBeInTheDocument()
expect(getModels).toHaveBeenCalledWith('week', 'anthropic', false)
fireEvent.click(screen.getByRole('tab', { name: 'By task' }))
await waitFor(() => expect(getModels).toHaveBeenCalledWith('week', 'anthropic', true))
expect(await screen.findByText('coding')).toBeInTheDocument()
expect(screen.getByText('delegation')).toBeInTheDocument()
expect(screen.getByText('3,520')).toBeInTheDocument()
expect(screen.getByText('$265.00')).toBeInTheDocument()
expect(screen.getByText('$66.40')).toBeInTheDocument()
expect(screen.getByText('$244.12')).toBeInTheDocument()
expect(screen.getAllByText('Claude Opus 4.8')).toHaveLength(1)
expect(document.querySelectorAll('.model-task-group')).toHaveLength(1)
expect(document.querySelectorAll('.model-task-row')).toHaveLength(2)
})
it('renders the audit lens with raw vs normalized token columns and an estimated flag', async () => {
getModels.mockResolvedValue(rows)
getAudit.mockResolvedValue(auditRows)
render(<Models period="30days" provider="all" />)
expect(await screen.findByText('Claude Opus 4.8')).toBeInTheDocument()
fireEvent.click(screen.getByRole('tab', { name: 'Audit' }))
expect(await screen.findByText('my-proxy-model')).toBeInTheDocument()
expect(getAudit).toHaveBeenCalledWith('30days', 'all')
// Raw output (3.1M) and normalized output (4M = output + reasoning) both show.
expect(screen.getByText('3.1M')).toBeInTheDocument()
expect(screen.getByText('900K')).toBeInTheDocument()
expect(screen.getByText('4M')).toBeInTheDocument()
expect(screen.getByText('$252.00')).toBeInTheDocument()
// Only the unpriced row (rates: null) is flagged estimated.
expect(screen.getAllByText('est')).toHaveLength(1)
})
it('shows the audit empty state when there is nothing to audit', async () => {
getModels.mockResolvedValue(rows)
getAudit.mockResolvedValue([])
render(<Models period="week" provider="all" />)
expect(await screen.findByText('Claude Opus 4.8')).toBeInTheDocument()
fireEvent.click(screen.getByRole('tab', { name: 'Audit' }))
expect(await screen.findByText('No model usage to audit in this range yet.')).toBeInTheDocument()
})
})

View file

@ -0,0 +1,342 @@
import { useState } from 'react'
import { CliErrorPanel } from '../components/CliErrorPanel'
import { EmptyNote } from '../components/EmptyState'
import { seriesColorForModel } from '../components/ListRow'
import { Panel } from '../components/Panel'
import { SectionSkeleton } from '../components/Skeleton'
import { SegTabs } from '../components/SegTabs'
import { StaleBanner } from '../components/StaleBanner'
import type { Section } from '../components/Sidebar'
import { usePolled } from '../hooks/usePolled'
import { formatCompact, formatUsd } from '../lib/format'
import { codeburn } from '../lib/ipc'
import type { AuditRow, DateRange, ModelReportRow, Period } from '../lib/types'
import type { SettingsPane } from './Settings'
type ModelsLens = 'model' | 'task' | 'audit'
const LENSES = [
{ value: 'model', label: 'By model' },
{ value: 'task', label: 'By task' },
{ value: 'audit', label: 'Audit' },
]
function fmtInt(n: number): string {
return n.toLocaleString('en-US')
}
export function Models({
period,
provider,
range = null,
refreshToken = 0,
onNavigate,
}: {
period: Period
provider: string
range?: DateRange | null
refreshToken?: number
onNavigate?: (section: Section, pane?: SettingsPane) => void
}) {
const [lens, setLens] = useState<ModelsLens>('model')
const onAddAlias = () => onNavigate?.('settings', 'aliases')
return (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, alignSelf: 'flex-start' }}>
<SegTabs options={LENSES} value={lens} onChange={value => setLens(value as ModelsLens)} />
{lens !== 'audit' && (
<button type="button" className="btn btn-s" onClick={() => onNavigate?.('compare')}>
Compare
</button>
)}
</div>
{lens === 'audit' ? (
<AuditLens period={period} provider={provider} range={range} refreshToken={refreshToken} />
) : (
<ModelsUsage
period={period}
provider={provider}
range={range}
byTask={lens === 'task'}
refreshToken={refreshToken}
onAddAlias={onAddAlias}
/>
)}
</>
)
}
function ModelsUsage({
period,
provider,
range,
byTask,
refreshToken,
onAddAlias,
}: {
period: Period
provider: string
range: DateRange | null
byTask: boolean
refreshToken: number
onAddAlias: () => void
}) {
const report = usePolled<ModelReportRow[]>(
() => range ? codeburn.getModels(period, provider, byTask, range) : codeburn.getModels(period, provider, byTask),
[period, provider, byTask, range?.from, range?.to, refreshToken],
)
if (!report.data) {
if (report.error) return <CliErrorPanel error={report.error} subject="model usage" />
return <SectionSkeleton label="Scanning model usage…" rows={5} />
}
return (
<>
{report.error && <StaleBanner error={report.error} />}
<Panel className="scroll-x">
{report.data.length ? (
<ModelsTable rows={report.data} byTask={byTask} onAddAlias={onAddAlias} />
) : (
<EmptyNote>No model usage in this range yet.</EmptyNote>
)}
</Panel>
</>
)
}
// A row's cost is "estimated" when it has no live pricing entry, or when the
// attributed cost diverges from a straight rate x displayed-token recompute
// (fast-mode multipliers or the 1-hour cache rate that calculateCost applies).
function auditEstimated(row: AuditRow): boolean {
if (!row.rates) return true
return Math.abs(row.cost.recomputedTotalUSD - row.attributedCostUSD) > 0.005
}
function AuditLens({
period,
provider,
range,
refreshToken,
}: {
period: Period
provider: string
range: DateRange | null
refreshToken: number
}) {
const report = usePolled<AuditRow[]>(
() => range ? codeburn.getAudit(period, provider, range) : codeburn.getAudit(period, provider),
[period, provider, range?.from, range?.to, refreshToken],
)
if (!report.data) {
if (report.error) return <CliErrorPanel error={report.error} subject="the token audit" />
return <SectionSkeleton label="Auditing token usage…" rows={5} />
}
return (
<>
{report.error && <StaleBanner error={report.error} />}
<Panel className="scroll-x">
{report.data.length ? (
<AuditTable rows={report.data} />
) : (
<EmptyNote>No model usage to audit in this range yet.</EmptyNote>
)}
</Panel>
</>
)
}
function AuditTable({ rows }: { rows: AuditRow[] }) {
return (
<table className="audit-table">
<thead>
<tr>
<th>Model</th>
<th>Calls</th>
<th>Input</th>
<th>Output</th>
<th>Reasoning</th>
<th>Norm out</th>
<th>Cache wr</th>
<th>Cache rd</th>
<th>Cost</th>
</tr>
</thead>
<tbody>
{rows.map((row, i) => (
<AuditTableRow key={`${row.provider}-${row.model}-${i}`} row={row} />
))}
</tbody>
</table>
)
}
function AuditTableRow({ row }: { row: AuditRow }) {
const estimated = auditEstimated(row)
return (
<tr>
<td title={row.model}>
<span className="mdot" style={{ display: 'inline-block', background: seriesColorForModel(row.modelDisplayName || row.model), marginRight: 8 }} />
{row.modelDisplayName}
</td>
<td>{fmtInt(row.calls)}</td>
<td>{formatCompact(row.raw.inputTokens)}</td>
<td>{formatCompact(row.raw.outputTokens)}</td>
<td>{formatCompact(row.raw.reasoningTokens)}</td>
<td>{formatCompact(row.displayed.outputTokens)}</td>
<td>{formatCompact(row.displayed.cacheWriteTokens)}</td>
<td>{formatCompact(row.displayed.cacheReadTokens)}</td>
<td>
{formatUsd(row.attributedCostUSD)}
{estimated ? <span className="est" title="Cost is estimated (no live pricing or derived rate)"> est</span> : null}
</td>
</tr>
)
}
function ModelsTable({ rows, byTask, onAddAlias }: { rows: ModelReportRow[]; byTask: boolean; onAddAlias: () => void }) {
if (byTask) return <ModelsByTaskTable rows={rows} onAddAlias={onAddAlias} />
return (
<table>
<thead>
<tr>
<th>Model</th>
<th>Calls</th>
<th>Input</th>
<th>Output</th>
<th>Cache read</th>
<th>Cost</th>
<th>Saved</th>
</tr>
</thead>
<tbody>
{rows.map((row, i) => (
<ModelTableRow key={`${row.provider}-${row.model}-${i}`} row={row} onAddAlias={onAddAlias} />
))}
</tbody>
</table>
)
}
function ModelsByTaskTable({ rows, onAddAlias }: { rows: ModelReportRow[]; onAddAlias: () => void }) {
const groups = groupTaskRows(rows)
return (
<table className="models-by-task">
<thead>
<tr>
<th>Task</th>
<th>Calls</th>
<th>Input</th>
<th>Output</th>
<th>Cache read</th>
<th>Cost</th>
<th>Saved</th>
</tr>
</thead>
{groups.map(group => (
<tbody className="model-task-group" key={`${group.provider}-${group.model}`}>
<ModelGroupRow rows={group.rows} onAddAlias={onAddAlias} />
{group.rows.map((row, i) => (
<ModelTaskRow key={`${row.category ?? 'all'}-${i}`} row={row} />
))}
</tbody>
))}
</table>
)
}
function ModelTableRow({ row, onAddAlias }: { row: ModelReportRow; onAddAlias: () => void }) {
const unpriced = row.costUSD === 0 && row.savingsUSD === 0
const cellClass = unpriced ? 'dim' : undefined
const tokenValue = (value: number) => (unpriced ? '—' : formatCompact(value))
const dotStyle = {
display: 'inline-block',
background: seriesColorForModel(row.modelDisplayName || row.model),
marginRight: 8,
}
return (
<tr>
<td className={cellClass} title={row.model}>
<span className="mdot" style={dotStyle} />
{row.modelDisplayName}
{unpriced ? (
<>
{' '}
<button type="button" className="alias" onClick={onAddAlias}>add alias </button>
</>
) : null}
</td>
<td className={cellClass}>{fmtInt(row.calls)}</td>
<td className={cellClass}>{tokenValue(row.inputTokens)}</td>
<td className={cellClass}>{tokenValue(row.outputTokens)}</td>
<td className={cellClass}>{tokenValue(row.cacheReadTokens)}</td>
<td className={cellClass}>{unpriced ? '—' : formatUsd(row.costUSD)}</td>
<td className={unpriced ? 'dim' : row.savingsUSD > 0 ? 'pos' : undefined}>{unpriced ? '—' : formatUsd(row.savingsUSD)}</td>
</tr>
)
}
function ModelGroupRow({ rows, onAddAlias }: { rows: ModelReportRow[]; onAddAlias: () => void }) {
const model = rows[0]
const calls = rows.reduce((sum, row) => sum + row.calls, 0)
const costUSD = rows.reduce((sum, row) => sum + row.costUSD, 0)
const savingsUSD = rows.reduce((sum, row) => sum + row.savingsUSD, 0)
const unpriced = costUSD === 0 && savingsUSD === 0
return (
<tr className="model-group-row">
<td className={unpriced ? 'dim' : undefined} title={model.model}>
<span className="model-group-lead">
<span
className="mdot"
style={{ background: seriesColorForModel(model.modelDisplayName || model.model) }}
/>
<span className="model-group-name">{model.modelDisplayName}</span>
{unpriced ? <button type="button" className="alias" onClick={onAddAlias}>add alias </button> : null}
</span>
</td>
<td className={unpriced ? 'dim' : undefined}>{fmtInt(calls)}</td>
<td aria-label="No aggregate input" />
<td aria-label="No aggregate output" />
<td aria-label="No aggregate cache read" />
<td className={unpriced ? 'dim' : undefined}>{unpriced ? '—' : formatUsd(costUSD)}</td>
<td className={unpriced ? 'dim' : savingsUSD > 0 ? 'pos' : undefined}>{unpriced ? '—' : formatUsd(savingsUSD)}</td>
</tr>
)
}
function ModelTaskRow({ row }: { row: ModelReportRow }) {
const unpriced = row.costUSD === 0 && row.savingsUSD === 0
const cellClass = unpriced ? 'dim' : undefined
const tokenValue = (value: number) => (unpriced ? '—' : formatCompact(value))
return (
<tr className="model-task-row">
<td className={cellClass}>{row.category ?? 'general'}</td>
<td className={cellClass}>{fmtInt(row.calls)}</td>
<td className={cellClass}>{tokenValue(row.inputTokens)}</td>
<td className={cellClass}>{tokenValue(row.outputTokens)}</td>
<td className={cellClass}>{tokenValue(row.cacheReadTokens)}</td>
<td className={cellClass}>{unpriced ? '—' : formatUsd(row.costUSD)}</td>
<td className={unpriced ? 'dim' : row.savingsUSD > 0 ? 'pos' : undefined}>{unpriced ? '—' : formatUsd(row.savingsUSD)}</td>
</tr>
)
}
function groupTaskRows(rows: ModelReportRow[]) {
const groups = new Map<string, { provider: string; model: string; rows: ModelReportRow[] }>()
for (const row of rows) {
const key = `${row.provider}\u0000${row.model}`
const group = groups.get(key)
if (group) group.rows.push(row)
else groups.set(key, { provider: row.provider, model: row.model, rows: [row] })
}
return [...groups.values()]
}

View file

@ -0,0 +1,250 @@
// @vitest-environment jsdom
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { MenubarPayload, OptimizeJsonReport, YieldJsonReport } from '../lib/types'
import { Optimize, OptimizeContent } from './Optimize'
const { getOverview, getOptimizeReport, getYield } = vi.hoisted(() => ({
getOverview: vi.fn(),
getOptimizeReport: vi.fn(),
getYield: vi.fn(),
}))
vi.mock('../lib/ipc', async orig => {
const actual = await orig<typeof import('../lib/ipc')>()
return { ...actual, codeburn: { getOverview, getOptimizeReport, getYield } }
})
function makePayload(): MenubarPayload {
return {
generated: '2026-07-10T19:00:00.000Z',
current: {
label: 'Last 30 days', cost: 612.48, calls: 1220, sessions: 88, 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: [],
},
optimize: {
findingCount: 3,
savingsUSD: 94.4,
topFindings: [
{ title: 'Opus is doing your small talk', impact: 'high', savingsUSD: 9.1 },
{ title: 'Cache hit is low in agentseal-dash', impact: 'medium', savingsUSD: 8.7 },
{ title: 'Batch tiny requests', impact: 'low', savingsUSD: 2.4 },
],
},
history: { daily: [] },
}
}
function makeOptimizeReport(): OptimizeJsonReport {
return {
period: { label: 'Last 30 days', start: '2026-06-11', end: '2026-07-10' },
summary: {
healthScore: 72, healthGrade: 'C', findingCount: 3, periodCostUSD: 612.48,
sessions: 88, calls: 1220, potentialSavingsTokens: 184_000,
potentialSavingsCostUSD: 94.4, potentialSavingsPercent: 15.4, costRateUSD: 0.0005,
},
findings: [
{
id: 'cost-outliers', title: 'Opus is doing your small talk',
explanation: 'Small conversational requests are running on an expensive model.',
severity: 'high', trend: 'active', tokensSaved: 18_200, estimatedSavingsUSD: 9.1,
fix: { type: 'paste', label: 'Paste into CLAUDE.md', text: 'Use Sonnet for routine questions.', destination: 'claude-md' },
},
{
id: 'context-heavy-sessions', title: 'Cache hit is low in agentseal-dash',
explanation: 'Repeated context is not being served from cache.', severity: 'medium',
trend: null, tokensSaved: 17_400, estimatedSavingsUSD: 8.7,
fix: { type: 'command', label: 'Run this command', text: 'codeburn cache inspect' },
},
{
id: 'warmup-heavy', title: 'Batch tiny requests', explanation: 'Many short sessions repeat setup work.',
severity: 'low', trend: 'improving', tokensSaved: 4_800, estimatedSavingsUSD: 2.4,
fix: { type: 'file-content', label: 'Create configuration', path: '~/.codeburn/config.json', content: '{"batch":true}' },
},
],
}
}
function makeYield(): YieldJsonReport {
return {
period: { label: 'Last 30 days', start: '2026-06-11', end: '2026-07-10' },
summary: {
productive: { costUSD: 440, sessions: 19, costPercent: 72, sessionPercent: 70 },
reverted: { costUSD: 107, sessions: 4, costPercent: 17, sessionPercent: 15 },
abandoned: { costUSD: 65.4, sessions: 3, costPercent: 11, sessionPercent: 15 },
total: { costUSD: 612.4, sessions: 26 }, productiveToRevertedCostRatio: 4.1,
},
details: [
{ sessionId: 'rev-1', project: 'codeburn', category: 'reverted', commitCount: 2, costUSD: 55 },
{ sessionId: 'rev-2', project: 'agentseal-dash', category: 'reverted', commitCount: 1, costUSD: 52 },
{ sessionId: 'abn-1', project: 'sandbox-spike', category: 'abandoned', commitCount: 0, costUSD: 65.4 },
{ sessionId: 'prod-1', project: 'desktop-app', category: 'productive', commitCount: 5, costUSD: 440 },
],
}
}
function emptyPayload(): MenubarPayload {
const payload = makePayload()
payload.optimize = { findingCount: 0, savingsUSD: 0, topFindings: [] }
return payload
}
function emptyOptimizeReport(): OptimizeJsonReport {
const report = makeOptimizeReport()
report.summary = { ...report.summary, findingCount: 0, potentialSavingsTokens: 0, potentialSavingsCostUSD: 0, potentialSavingsPercent: 0 }
report.findings = []
return report
}
function emptyYield(): YieldJsonReport {
const report = makeYield()
report.summary.reverted = { costUSD: 0, sessions: 0, costPercent: 0, sessionPercent: 0 }
report.summary.abandoned = { costUSD: 0, sessions: 0, costPercent: 0, sessionPercent: 0 }
report.details = []
return report
}
describe('Optimize', () => {
const writeText = vi.fn<(text: string) => Promise<void>>()
beforeEach(() => {
getOverview.mockReset().mockResolvedValue(makePayload())
getOptimizeReport.mockReset().mockResolvedValue(makeOptimizeReport())
getYield.mockReset().mockResolvedValue(makeYield())
writeText.mockReset().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
})
it('renders tabs and actionable Waste findings with impact, savings, explanation, and copy-paste fix', async () => {
render(<Optimize period="30days" provider="all" />)
expect(await screen.findByText('Opus is doing your small talk')).toBeInTheDocument()
expect(screen.getByText('3 findings · $94.40 potential · health 72/100')).toBeInTheDocument()
expect(screen.getByText('High')).toHaveClass('opt-impact-high')
expect(screen.getByText('Medium')).toHaveClass('opt-impact-medium')
expect(screen.getByText('Low')).toHaveClass('opt-impact-low')
expect(screen.getByText('$9.10')).toHaveClass('opt-finding-savings')
expect(screen.getByText('18.2K tokens')).toBeInTheDocument()
expect(screen.getByRole('tab', { name: 'Waste $94.40' })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: 'Reverts $107.00' })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: 'Abandoned $65.40' })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: 'Fixes 3' })).toBeInTheDocument()
const row = screen.getByRole('button', { name: /Opus is doing your small talk/ })
expect(row).toHaveAttribute('aria-expanded', 'false')
fireEvent.click(row)
expect(row).toHaveAttribute('aria-expanded', 'true')
expect(screen.getByText('Small conversational requests are running on an expensive model.')).toBeInTheDocument()
expect(screen.getByText('Use Sonnet for routine questions.')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Copy' }))
await waitFor(() => expect(writeText).toHaveBeenCalledWith('Use Sonnet for routine questions.'))
expect(screen.getByRole('button', { name: 'Copied' })).toBeInTheDocument()
})
it('keeps only one finding expanded and renders file-content path and content', async () => {
render(<Optimize period="30days" provider="all" />)
const first = await screen.findByRole('button', { name: /Opus is doing your small talk/ })
fireEvent.click(first)
fireEvent.click(screen.getByRole('button', { name: /Batch tiny requests/ }))
expect(first).toHaveAttribute('aria-expanded', 'false')
expect(screen.queryByText('Small conversational requests are running on an expensive model.')).not.toBeInTheDocument()
expect(screen.getByText('~/.codeburn/config.json')).toBeInTheDocument()
expect(screen.getByText('{"batch":true}')).toBeInTheDocument()
})
it('switches to Reverts and Abandoned and shows only the matching yield details', async () => {
render(<Optimize period="30days" provider="all" />)
await screen.findByText('Opus is doing your small talk')
fireEvent.click(screen.getByRole('tab', { name: 'Reverts $107.00' }))
expect(screen.getByText('codeburn')).toBeInTheDocument()
expect(screen.getByText('2 commits · rev-1')).toBeInTheDocument()
expect(screen.queryByText('sandbox-spike')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('tab', { name: 'Abandoned $65.40' }))
expect(screen.getByText('sandbox-spike')).toBeInTheDocument()
expect(screen.getByText('0 commits · abn-1')).toBeInTheDocument()
expect(screen.getByText('$65.40')).toHaveClass('val')
expect(screen.queryByText('codeburn')).not.toBeInTheDocument()
expect(screen.queryByText('desktop-app')).not.toBeInTheDocument()
})
it('renders honest placeholders for unavailable yield totals and tab bodies', async () => {
getYield.mockReset().mockRejectedValue(new Error('yield failed'))
render(<Optimize period="30days" provider="all" />)
expect(await screen.findByRole('tab', { name: 'Reverts —' })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: 'Abandoned —' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('tab', { name: 'Reverts —' }))
expect(screen.getByText('Yield data is unavailable right now.')).toBeInTheDocument()
})
it('keeps the Fixes tab populated and preserves all four empty tab states', async () => {
const { rerender } = render(<Optimize period="30days" provider="all" />)
await screen.findByText('Opus is doing your small talk')
fireEvent.click(screen.getByRole('tab', { name: 'Fixes 3' }))
expect(screen.getByText('Opus is doing your small talk')).toBeInTheDocument()
getOverview.mockResolvedValue(emptyPayload())
getOptimizeReport.mockResolvedValue(emptyOptimizeReport())
getYield.mockResolvedValue(emptyYield())
rerender(<Optimize period="week" provider="all" />)
expect(await screen.findByText('No fixes in this range yet.')).toBeInTheDocument()
expect(screen.getByRole('tab', { name: 'Fixes 0' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('tab', { name: 'Waste $0.00' }))
expect(screen.getByText('No waste findings in this range yet.')).toBeInTheDocument()
fireEvent.click(screen.getByRole('tab', { name: 'Reverts $0.00' }))
expect(screen.getByText('No reverted sessions in this range yet.')).toBeInTheDocument()
fireEvent.click(screen.getByRole('tab', { name: 'Abandoned $0.00' }))
expect(screen.getByText('No abandoned sessions in this range yet.')).toBeInTheDocument()
})
it('labels the Fixes tab with the rendered list length, not the menubar-wide findingCount', async () => {
const payload = makePayload()
// The menubar counts 25 findings, but the Fixes tab only renders topFindings.
payload.optimize = {
findingCount: 25,
savingsUSD: 94.4,
topFindings: [
{ title: 'A', impact: 'high', savingsUSD: 1 },
{ title: 'B', impact: 'low', savingsUSD: 1 },
],
}
getOverview.mockResolvedValue(payload)
render(<Optimize period="30days" provider="all" />)
expect(await screen.findByRole('tab', { name: 'Fixes 2' })).toBeInTheDocument()
expect(screen.queryByRole('tab', { name: 'Fixes 25' })).not.toBeInTheDocument()
})
it('passes provider and custom range to the optimize report and yield bridges', async () => {
render(<Optimize period="30days" provider="claude" range={{ from: '2026-07-01', to: '2026-07-11' }} />)
await screen.findByText('Opus is doing your small talk')
expect(getOptimizeReport).toHaveBeenCalledWith('30days', 'claude', { from: '2026-07-01', to: '2026-07-11' })
expect(getYield).toHaveBeenCalledWith('30days', 'claude', { from: '2026-07-01', to: '2026-07-11' })
})
it('keeps last-good yield totals and rows visible during revalidation', async () => {
getYield.mockReset().mockResolvedValueOnce(makeYield()).mockImplementation(() => new Promise<YieldJsonReport>(() => {}))
const overview = { data: makePayload(), error: null, loading: false, lastSuccessAt: Date.now(), refresh: vi.fn() }
const { rerender } = render(<OptimizeContent period="30days" overview={overview} refreshToken={0} />)
expect(await screen.findByRole('tab', { name: 'Reverts $107.00' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('tab', { name: 'Reverts $107.00' }))
expect(screen.getByText('codeburn')).toBeInTheDocument()
rerender(<OptimizeContent period="30days" overview={overview} refreshToken={1} />)
await waitFor(() => expect(getYield).toHaveBeenCalledTimes(2))
expect(screen.getByRole('tab', { name: 'Reverts $107.00' })).toBeInTheDocument()
expect(screen.getByText('codeburn')).toBeInTheDocument()
})
})

View file

@ -0,0 +1,233 @@
import { Fragment, useState } from 'react'
import { CliErrorPanel } from '../components/CliErrorPanel'
import { EmptyNote } from '../components/EmptyState'
import { Panel } from '../components/Panel'
import { SectionSkeleton } from '../components/Skeleton'
import { SegTabs } from '../components/SegTabs'
import { StaleBanner } from '../components/StaleBanner'
import { type Polled, usePolled } from '../hooks/usePolled'
import { formatCompact, formatUsd } from '../lib/format'
import { codeburn } from '../lib/ipc'
import type { DateRange, MenubarPayload, OptimizeJsonReport, Period, SessionYieldJson, WasteAction, YieldJsonReport } from '../lib/types'
type OptimizeTab = 'waste' | 'reverts' | 'abandoned' | 'fixes'
export function Optimize({ 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],
)
return <OptimizeContent period={period} provider={provider} range={range} overview={overview} />
}
export function OptimizeContent({
period,
provider = 'all',
range = null,
overview,
refreshToken = 0,
}: {
period: Period
provider?: string
range?: DateRange | null
overview: Polled<MenubarPayload>
refreshToken?: number
}) {
const optimizeReport = usePolled<OptimizeJsonReport>(
() => range ? codeburn.getOptimizeReport(period, provider, range) : codeburn.getOptimizeReport(period, provider),
[period, provider, range?.from, range?.to, refreshToken],
)
const yieldReport = usePolled<YieldJsonReport>(
() => range ? codeburn.getYield(period, provider, range) : codeburn.getYield(period, provider),
[period, provider, range?.from, range?.to, refreshToken],
)
const [tab, setTab] = useState<OptimizeTab>('waste')
if (!overview.data) {
if (overview.error) return <CliErrorPanel error={overview.error} subject="optimize findings" />
return <SectionSkeleton label="Scanning optimize findings…" rows={5} />
}
const yieldData = yieldReport.error ? null : yieldReport.data
const revertedTotal = yieldData ? formatUsd(yieldData.summary.reverted.costUSD) : '—'
const abandonedTotal = yieldData ? formatUsd(yieldData.summary.abandoned.costUSD) : '—'
const options = [
{ value: 'waste', label: `Waste ${formatUsd(overview.data.optimize.savingsUSD)}` },
{ value: 'reverts', label: `Reverts ${revertedTotal}` },
{ value: 'abandoned', label: `Abandoned ${abandonedTotal}` },
// The Fixes tab renders topFindings (capped list), so label the count that shows.
{ value: 'fixes', label: `Fixes ${overview.data.optimize.topFindings.length.toLocaleString('en-US')}` },
]
return (
<>
{overview.error && <StaleBanner error={overview.error} />}
<SegTabs
options={options}
value={tab}
onChange={value => setTab(value as OptimizeTab)}
style={{ alignSelf: 'flex-start' }}
/>
<Panel>
{tab === 'waste' ? (
<WasteRows report={optimizeReport} />
) : tab === 'reverts' ? (
<YieldRows report={yieldReport} category="reverted" empty="No reverted sessions in this range yet." />
) : tab === 'abandoned' ? (
<YieldRows report={yieldReport} category="abandoned" empty="No abandoned sessions in this range yet." />
) : (
<FixesRows data={overview.data} />
)}
</Panel>
</>
)
}
function WasteRows({ report }: { report: Polled<OptimizeJsonReport> }) {
if (!report.data) {
if (report.error) return <CliErrorPanel error={report.error} subject="optimize findings" />
return <EmptyNote>Scanning optimize findings</EmptyNote>
}
return (
<div className="opt-waste">
<div className="opt-summary">
{report.data.summary.findingCount.toLocaleString('en-US')} findings · {formatUsd(report.data.summary.potentialSavingsCostUSD)} potential · health {report.data.summary.healthScore}/100
</div>
<ActionableFindingRows findings={report.data.findings} />
</div>
)
}
type OptimizeFinding = OptimizeJsonReport['findings'][number]
const IMPACT_ICON: Record<'high' | 'medium' | 'low', string> = {
high: '↑',
medium: '→',
low: '↓',
}
function actionText(fix: WasteAction): string {
return fix.type === 'file-content' ? fix.content : fix.text
}
function ActionableFindingRows({ findings }: { findings: OptimizeFinding[] }) {
const [expandedId, setExpandedId] = useState<string | null>(null)
const [copiedId, setCopiedId] = useState<string | null>(null)
if (!findings.length) return <EmptyNote>No waste findings in this range yet.</EmptyNote>
const copyFix = async (finding: OptimizeFinding) => {
await navigator.clipboard.writeText(actionText(finding.fix))
setCopiedId(finding.id)
window.setTimeout(() => setCopiedId(current => current === finding.id ? null : current), 1_500)
}
return (
<div className="opt-findings">
{findings.map(finding => {
const expanded = expandedId === finding.id
return (
<Fragment key={finding.id}>
<button
className="opt-finding opt-finding-toggle"
type="button"
aria-expanded={expanded}
onClick={() => setExpandedId(current => current === finding.id ? null : finding.id)}
>
<span className={`opt-impact opt-impact-${finding.severity}`}>
<span aria-hidden="true">{IMPACT_ICON[finding.severity]}</span>
{finding.severity.charAt(0).toUpperCase() + finding.severity.slice(1)}
</span>
<span className="opt-finding-titlewrap">
<b className="opt-finding-title">{finding.title}</b>
{finding.trend === 'improving' && (
<span className="opt-trend opt-trend-improving">improving<span aria-hidden="true"> </span></span>
)}
</span>
<span className="opt-finding-savings">{formatUsd(finding.estimatedSavingsUSD)}</span>
<span className="opt-finding-tokens">{formatCompact(finding.tokensSaved)} tokens</span>
<span className="opt-finding-chevron" aria-hidden="true"></span>
</button>
{expanded && (
<div className="opt-finding-detail" role="region" aria-label={`${finding.title} details`}>
<p className="opt-explanation">{finding.explanation}</p>
<div className={`opt-fix opt-fix-${finding.fix.type}`}>
<div className="opt-fix-head">
<div>
<b>{finding.fix.label}</b>
{finding.fix.type === 'file-content' && <span className="opt-fix-path">{finding.fix.path}</span>}
</div>
<button className="opt-copy" type="button" onClick={() => void copyFix(finding)}>
{copiedId === finding.id ? 'Copied' : 'Copy'}
</button>
</div>
<pre className="opt-fix-code"><code>{actionText(finding.fix)}</code></pre>
</div>
</div>
)}
</Fragment>
)
})}
</div>
)
}
type Finding = MenubarPayload['optimize']['topFindings'][number]
function FindingRows({ findings, empty }: { findings: Finding[]; empty: string }) {
if (!findings.length) return <EmptyNote>{empty}</EmptyNote>
return (
<div className="opt-findings">
{findings.map((finding, i) => (
<div className="opt-finding opt-finding-legacy" key={`${finding.title}-${i}`}>
<span className="opt-finding-rank">{String(i + 1).padStart(2, '0')}</span>
<b className="opt-finding-title">{finding.title}</b>
<span className={`opt-impact opt-impact-${finding.impact}`}>
<span aria-hidden="true">{IMPACT_ICON[finding.impact]}</span>
{finding.impact.charAt(0).toUpperCase() + finding.impact.slice(1)}
</span>
<span className="opt-finding-savings">{formatUsd(finding.savingsUSD)}</span>
</div>
))}
</div>
)
}
function YieldRows({
report,
category,
empty,
}: {
report: Polled<YieldJsonReport>
category: SessionYieldJson['category']
empty: string
}) {
if (report.error || !report.data) return <EmptyNote>Yield data is unavailable right now.</EmptyNote>
const rows = report.data.details.filter(row => row.category === category)
if (!rows.length) return <EmptyNote>{empty}</EmptyNote>
return (
<>
{rows.map((row, i) => (
<div className="li" style={{ alignItems: 'flex-start' }} key={row.sessionId}>
<span className="no">{String(i + 1).padStart(2, '0')}</span>
<div className="lx">
<b>{row.project}</b>
<span>
{row.commitCount.toLocaleString('en-US')} {row.commitCount === 1 ? 'commit' : 'commits'} · {row.sessionId}
</span>
</div>
<span className="val">{formatUsd(row.costUSD)}</span>
</div>
))}
</>
)
}
function FixesRows({ data }: { data: MenubarPayload }) {
return <FindingRows findings={data.optimize.topFindings} empty="No fixes in this range yet." />
}

View file

@ -0,0 +1,680 @@
// @vitest-environment jsdom
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { Polled } from '../hooks/usePolled'
import { setActiveCurrency } from '../lib/format'
import type { ActReportJson, DailyHistoryEntry, MenubarPayload, YieldJsonReport } from '../lib/types'
import { Overview, OverviewContent, deriveSignals, localDateKey } from './Overview'
function polled(data: MenubarPayload): Polled<MenubarPayload> {
return { data, error: null, loading: false, lastSuccessAt: Date.now(), refresh: vi.fn() }
}
// Mock the typed bridge so the section fetches our payload instead of spawning
// the CLI. `normalizeCliError` (used by usePolled) is kept from the real module.
// `vi.hoisted` lets the hoisted `vi.mock` factory reference the spy safely.
const { getOverview, getActReport, getYield } = vi.hoisted(() => ({
getOverview: vi.fn<(period: string, provider: string) => Promise<MenubarPayload>>(),
getActReport: vi.fn<() => Promise<ActReportJson>>(),
getYield: vi.fn<(period: string, provider: string) => Promise<YieldJsonReport>>(),
}))
vi.mock('../lib/ipc', async orig => {
const actual = await orig<typeof import('../lib/ipc')>()
return { ...actual, codeburn: { getOverview, getActReport, getYield } }
})
function makeYieldReport(): YieldJsonReport {
return {
period: { label: 'Last 30 days', start: '2026-06-12', end: '2026-07-11' },
summary: {
productive: { costUSD: 120, sessions: 3, costPercent: 80, sessionPercent: 60 },
reverted: { costUSD: 20, sessions: 1, costPercent: 13, sessionPercent: 20 },
abandoned: { costUSD: 10, sessions: 1, costPercent: 7, sessionPercent: 20 },
total: { costUSD: 150, sessions: 5 },
productiveToRevertedCostRatio: 6,
},
details: [
{ sessionId: 's1', project: 'parser-service', category: 'productive', commitCount: 4, costUSD: 80 },
{ sessionId: 's2', project: 'pairing-svc', category: 'productive', commitCount: 2, costUSD: 40 },
{ sessionId: 's3', project: 'parser-service', category: 'reverted', commitCount: 0, costUSD: 20 },
{ sessionId: 's4', project: 'scratch', category: 'abandoned', commitCount: 0, costUSD: 10 },
],
}
}
/**
* A fully-typed payload anchored to `now` so today/MTD/projected are stable.
* `history.daily` deliberately spans 30 backfill days (the real CLI emits up to
* 365 regardless of period) so tests can prove period aggregation while the
* trend chart keeps the real last 30 entries.
*/
function makePayload(now: Date): MenubarPayload {
const DAYS = 30
// 30 daily entries ending today. Base $5, a clear peak ($32) and runner-up
// ($28), and today's entry (last) at $6.20 — the Today card's source value.
const daily = Array.from({ length: DAYS }, (_, i) => {
const d = new Date(now.getFullYear(), now.getMonth(), now.getDate() - (DAYS - 1 - i))
let cost = 5
if (i === 10) cost = 32 // peak → .c.hi
else if (i === 20) cost = 28 // runner-up → .c.hi2
else if (i === DAYS - 1) cost = 6.2 // today
return {
date: localDateKey(d),
cost,
savingsUSD: 0,
calls: 40,
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
topModels: [
{
name: 'claude-opus-4',
cost: cost - 1,
savingsUSD: 0,
calls: 30,
inputTokens: 40_000_000,
outputTokens: 2_000_000,
},
{
name: 'claude-haiku-4',
cost: 1,
savingsUSD: 0,
calls: 10,
inputTokens: 1_000,
outputTokens: 500,
},
],
}
})
return {
generated: now.toISOString(),
current: {
label: 'Last 30 days',
cost: 312.4,
calls: 4200,
sessions: 88,
oneShotRate: 0.74,
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
cacheHitPercent: 63.4,
codexCredits: 0,
topActivities: [
{ name: 'coding', cost: 92.5, savingsUSD: 7.2, turns: 120, oneShotRate: 0.8 },
{ name: 'debugging', cost: 41.25, savingsUSD: 2.1, turns: 64, oneShotRate: null },
],
topModels: [{ name: 'claude-opus-4', cost: 200, savingsUSD: 0, savingsBaselineModel: '', calls: 100 }],
localModelSavings: { totalUSD: 0, calls: 0, byModel: [], byProvider: [] },
providers: {},
topProjects: [
{
name: 'parser-service',
cost: 8.41,
savingsUSD: 0,
sessions: 1,
avgCostPerSession: 8.41,
sessionDetails: [
{
cost: 8.41,
savingsUSD: 0,
calls: 41,
inputTokens: 0,
outputTokens: 0,
date: '2026-07-08',
models: [{ name: 'claude-opus-4', cost: 8.41, savingsUSD: 0 }],
},
],
},
],
modelEfficiency: [],
topSessions: [
{ project: 'parser-service', cost: 8.41, savingsUSD: 0, calls: 41, date: '2026-07-08' },
{ project: 'pairing-svc', cost: 6.12, savingsUSD: 0, calls: 33, date: '2026-07-07' },
],
retryTax: { totalUSD: 0, retries: 0, editTurns: 0, byModel: [] },
routingWaste: { totalSavingsUSD: 0, baselineModel: '', baselineCostPerEdit: 0, byModel: [] },
tools: [],
skills: [],
subagents: [],
mcpServers: [],
},
optimize: { findingCount: 3, savingsUSD: 23.6, topFindings: [] },
history: { daily },
}
}
function mkDay(date: string, cost: number): DailyHistoryEntry {
return { date, cost, savingsUSD: 0, calls: 10, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, topModels: [] }
}
/** A payload with an explicit history + current overrides for deriveSignals tests. */
function signalsPayload(now: Date, over: {
current?: Partial<MenubarPayload['current']>
daily?: DailyHistoryEntry[]
optimize?: MenubarPayload['optimize']
}): MenubarPayload {
const base = makePayload(now)
return {
...base,
current: { ...base.current, ...over.current },
optimize: over.optimize ?? { findingCount: 0, savingsUSD: 0, topFindings: [] },
history: { daily: over.daily ?? [] },
}
}
/** N consecutive days ending today; `cost(i)` sets each day's spend (i = oldest→0). */
function consecutiveDays(now: Date, count: number, cost: (index: number) => number): DailyHistoryEntry[] {
return Array.from({ length: count }, (_, i) => mkDay(localDateKey(new Date(now.getFullYear(), now.getMonth(), now.getDate() - (count - 1 - i))), cost(i)))
}
describe('Overview', () => {
beforeEach(() => {
setActiveCurrency({ code: 'USD', symbol: '$', rate: 1 })
getOverview.mockReset()
getActReport.mockReset()
getYield.mockReset()
getActReport.mockResolvedValue({ totals: { realizedCostUSD: 84.2, measuredActions: 11 } })
getYield.mockResolvedValue(makeYieldReport())
})
afterEach(() => {
vi.useRealTimers()
})
it("renders real hero, stats, model, saved, session, and daily-chart data", async () => {
const now = new Date()
getOverview.mockResolvedValue(makePayload(now))
const { container } = render(<Overview period="30days" provider="all" />)
// The hero shows the SELECTED PERIOD's total (current.cost) + label, not
// just today — so a 30-day view reads $312.40 under "Last 30 days".
expect(await screen.findByText('$312.40')).toBeInTheDocument()
expect(screen.getByText('Last 30 days')).toBeInTheDocument()
expect(container.querySelector('.ov-streak')).toHaveTextContent('30-day streak')
// The unified hero keeps spend/savings, activity, and efficiency in one
// divided card. Success/cache now live only in the scorecard column.
const kpis = screen.getByLabelText('Key performance indicators')
expect(within(kpis).getAllByText('74%')).toHaveLength(1)
expect(within(kpis).getAllByText('63%')).toHaveLength(1)
expect(within(kpis).getByText('$84.20')).toBeInTheDocument()
expect(within(kpis).getByLabelText('Efficiency grade B')).toHaveTextContent('B')
expect(kpis.querySelector(':scope > .ov-efficiency')).toBeInTheDocument()
expect(kpis.querySelector('.ov-efficiency.ov-card')).not.toBeInTheDocument()
// The contribution grid contains the real active history days, and the
// right rail renders real, cost-sorted activity data including one-shot.
const heatmap = screen.getByRole('grid', { name: 'Daily activity contribution heatmap' })
expect(heatmap.querySelectorAll('[data-active="true"]')).toHaveLength(30)
expect(screen.getByText('30 active days')).toBeInTheDocument()
expect(screen.getByText('coding')).toBeInTheDocument()
expect(screen.getByText('$92.50')).toBeInTheDocument()
expect(screen.getByText('120 turns')).toBeInTheDocument()
expect(screen.getByText('80% one-shot')).toBeInTheDocument()
// Session row title = the session's project (topSessions has no title field).
expect(screen.getByText('parser-service')).toBeInTheDocument()
// The selected range produces one real bar per day and only its peak is highlighted.
const bars = container.querySelectorAll('.chart .col')
expect(bars).toHaveLength(30)
expect(bars[10].classList.contains('hi')).toBe(true)
expect(container.querySelectorAll('.chart .col.hi')).toHaveLength(1)
expect(bars[29]).toHaveAttribute('data-cost', '6.2')
expect(bars[29]).toHaveAttribute('data-calls', '40')
expect(bars[29]).toHaveAttribute('data-led', 'claude-opus-4')
fireEvent.mouseEnter(bars[29], { clientX: 100, clientY: 80 })
expect(screen.getByText('40 calls · claude-opus-4 led')).toBeInTheDocument()
const tooltip = screen.getByRole('tooltip')
expect(tooltip.parentElement).toBe(document.body)
expect(tooltip).toHaveStyle({ position: 'fixed' })
// Daily model breakdowns are summed over the selected period and sorted by
// cost. Token values use the same compact B/M/K notation as the menubar.
const modelsTable = screen.getByRole('table', { name: 'Models this period' })
expect(within(modelsTable).queryByRole('columnheader', { name: 'Relative cost' })).not.toBeInTheDocument()
const modelRows = within(modelsTable).getAllByRole('row')
expect(modelRows[1]).toHaveTextContent('claude-opus-4')
expect(modelRows[1]).toHaveTextContent('1.2B')
// Tokens now use the shared formatCompact helper (drops a trailing .0).
expect(modelRows[1]).toHaveTextContent('60M')
expect(modelRows[1]).toHaveTextContent('$171.20')
expect(modelRows[1]).toHaveTextContent('900')
expect(modelRows[2]).toHaveTextContent('claude-haiku-4')
expect(modelRows[2]).toHaveTextContent('30K')
expect(modelRows[2]).toHaveTextContent('15K')
// Weekly labels align to every seventh bar, and all three menubar-style
// daily summaries are derived from the displayed 30-day chart window.
const ticks = container.querySelectorAll('.ov-xax span')
expect(ticks).toHaveLength(5)
expect(ticks[0]).toHaveTextContent(new Date(
now.getFullYear(), now.getMonth(), now.getDate() - 29,
).toLocaleString('en-US', { month: 'short', day: 'numeric' }))
const summaries = screen.getByLabelText('Daily spend summary')
expect(within(summaries).getByText('Avg/day')).toBeInTheDocument()
expect(within(summaries).getByText('$6.71')).toBeInTheDocument()
expect(within(summaries).getByText('Peak')).toBeInTheDocument()
expect(within(summaries).getByText(/\$32\.00 · \d{1,2}\/\d{1,2}/)).toBeInTheDocument()
expect(within(summaries).getByText('Yesterday')).toBeInTheDocument()
expect(within(summaries).getByText('$5.00')).toBeInTheDocument()
expect(container.querySelector('.ov-spark')).not.toBeInTheDocument()
// Scope to the KPI card (kpis, declared above): month-to-date spend can
// equal the saved figure on some dates, so an unscoped getByText('$84.20')
// would match two cards.
expect(within(kpis).getByText('$84.20')).toBeInTheDocument()
expect(within(kpis).getByText('across 11 fixes')).toBeInTheDocument()
const statsCard = screen.getByText('Month to date').closest('.ov-stats3')
expect(statsCard).toHaveClass('ov-card')
expect(statsCard?.children).toHaveLength(2)
expect(within(statsCard as HTMLElement).getByText('Projected month')).toBeInTheDocument()
expect(screen.queryByText('Nearest limit')).not.toBeInTheDocument()
})
it('renders efficiency, cost-per-outcome, and the weekday-spike risk signal', async () => {
const now = new Date()
const payload = makePayload(now)
const today = payload.history.daily.at(-1)
if (!today) throw new Error('fixture must contain today')
today.cost = 50 // Prior same weekdays are $5, so the real detector reports 10×.
getOverview.mockResolvedValue(payload)
render(<Overview period="30days" provider="all" />)
expect(await screen.findByLabelText('Efficiency grade B')).toHaveTextContent('B')
const outcome = screen.getByText('Cost per outcome').closest('.ov-panel')
expect(outcome).not.toBeNull()
expect(within(outcome as HTMLElement).getByText('$25.00')).toBeInTheDocument()
expect(within(outcome as HTMLElement).getByText('$40.00')).toBeInTheDocument()
// The weekday-spike anomaly is absorbed into the Signals card as a risk.
const signals = screen.getByLabelText('Coaching signals')
const risks = within(signals).getByText('Risks').closest('.ov-signal-group') as HTMLElement
expect(within(risks).getByText(/Today's spend is 10× your typical/)).toBeInTheDocument()
})
it('uses an honest empty state when no realized savings exist', async () => {
const now = new Date()
getOverview.mockResolvedValue(makePayload(now))
getActReport.mockResolvedValue({ totals: { realizedCostUSD: 0, measuredActions: 7 } })
render(<Overview period="30days" provider="all" />)
expect(await screen.findByText('across 0 fixes')).toBeInTheDocument()
await waitFor(() => expect(getActReport).toHaveBeenCalled())
expect(screen.getByText('$0.00')).toBeInTheDocument()
})
it('zero-fills a contiguous 30-day window from sparse history', async () => {
const now = new Date()
const payload = makePayload(now)
payload.history.daily = payload.history.daily.slice(-5)
getOverview.mockResolvedValue(payload)
// history.daily is sparse (active days only). The daily chart zero-fills a
// contiguous calendar window (at least 30 days) so gaps read as real
// calendar time instead of compressed bars — the date keys match localDateKey.
const { container } = render(<Overview period="week" provider="all" />)
expect(await screen.findByText('parser-service')).toBeInTheDocument()
const bars = container.querySelectorAll('.chart .col')
expect(bars).toHaveLength(30)
// The five real days keep their cost at the end; the leading 25 days are zeros.
expect([...bars].slice(-5).map(bar => bar.getAttribute('data-cost'))).toEqual(['5', '5', '5', '5', '6.2'])
expect([...bars].slice(0, 25).every(bar => bar.getAttribute('data-cost') === '0')).toBe(true)
})
it('computes month-to-date, projection, and previous-month pace', async () => {
const now = new Date(2026, 6, 15, 12, 0, 0) // Wed Jul 15 2026, local
vi.useFakeTimers({ toFake: ['Date'] })
vi.setSystemTime(now)
const payload = makePayload(now)
// Prepend three high-spend May days. The pace comparator must be the PREVIOUS
// calendar month (June) only — averaging all prior months (incl. May) would
// skew the % and mislabel it, which is the bug this guards.
const mkMay = (date: string) => ({
date,
cost: 50,
savingsUSD: 0,
calls: 40,
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
topModels: [],
})
payload.history.daily = [mkMay('2026-05-10'), mkMay('2026-05-11'), mkMay('2026-05-12'), ...payload.history.daily]
getOverview.mockResolvedValue(payload)
render(<Overview period="30days" provider="all" />)
// MTD = sum of July daily costs = 13×$5 + $28 + $6.20 = $99.20.
expect(await screen.findByText('$99.20')).toBeInTheDocument()
// Projected = MTD + median(trailing-7 = $5) × 16 days left = $179.20.
expect(screen.getByText('$179.20')).toBeInTheDocument()
expect(screen.getByText('$80.00 to go')).toBeInTheDocument()
// Pace compares July's daily avg (6.613) to the PREVIOUS calendar month's
// (June: 14×$5 + $32 = $102 / 15 = 6.8) → -3%, and the label names June.
expect(screen.getByText('-3% vs June pace')).toBeInTheDocument()
})
it('recovers a matched session model for the sub-line without a series dot', async () => {
const now = new Date()
getOverview.mockResolvedValue(makePayload(now))
const { container } = render(<Overview period="30days" provider="all" />)
// parser-service session joins topProjects sessionDetails on
// (project|date|calls|cost) → claude-opus-4 in the sub-line.
expect(await screen.findByText('Jul 8 · claude-opus-4 · 41 calls')).toBeInTheDocument()
// pairing-svc has no matching project → model omitted from sub.
expect(screen.getByText('Jul 7 · 33 calls')).toBeInTheDocument()
expect(container.querySelectorAll('.ov-sessions-widget .mdot')).toHaveLength(0)
})
it('disambiguates two same-project/same-day/same-calls sessions by cost', async () => {
const now = new Date()
const base = makePayload(now)
// Two sessions identical on (project, date, calls) but differing in cost and
// model. Without cost in the join key they collide; with it they resolve.
const payload: MenubarPayload = {
...base,
current: {
...base.current,
topProjects: [
{
name: 'svc',
cost: 12,
savingsUSD: 0,
sessions: 2,
avgCostPerSession: 6,
sessionDetails: [
{
cost: 10,
savingsUSD: 0,
calls: 20,
inputTokens: 0,
outputTokens: 0,
date: '2026-07-08',
models: [{ name: 'claude-opus-4', cost: 10, savingsUSD: 0 }],
},
{
cost: 2,
savingsUSD: 0,
calls: 20,
inputTokens: 0,
outputTokens: 0,
date: '2026-07-08',
models: [{ name: 'claude-haiku-4', cost: 2, savingsUSD: 0 }],
},
],
},
],
topSessions: [
{ project: 'svc', cost: 10, savingsUSD: 0, calls: 20, date: '2026-07-08' },
{ project: 'svc', cost: 2, savingsUSD: 0, calls: 20, date: '2026-07-08' },
],
},
}
getOverview.mockResolvedValue(payload)
const { container } = render(<Overview period="30days" provider="all" />)
expect(await screen.findByText('Jul 8 · claude-opus-4 · 20 calls')).toBeInTheDocument()
expect(screen.getByText('Jul 8 · claude-haiku-4 · 20 calls')).toBeInTheDocument()
expect(container.querySelectorAll('.ov-sessions-widget .mdot')).toHaveLength(0)
})
it('shows the first-run locate-CLI state when the binary is missing', async () => {
getOverview.mockRejectedValue({ kind: 'not-found', message: 'codeburn not found' })
render(<Overview period="30days" provider="all" />)
expect(await screen.findByText(/Locate the codeburn CLI/i)).toBeInTheDocument()
})
it('sources Models this period from current.topModels when a provider filter is active', async () => {
const now = new Date()
const payload = makePayload(now)
// Provider-filtered CLI output: history.daily loses its per-model breakdown,
// but current is already provider-scoped.
payload.history.daily = payload.history.daily.map(day => ({ ...day, topModels: [] }))
payload.current.topModels = [
{ name: 'gpt-5.5-codex', cost: 120, savingsUSD: 0, savingsBaselineModel: '', calls: 240 },
{ name: 'claude-opus-4', cost: 60, savingsUSD: 0, savingsBaselineModel: '', calls: 90 },
]
render(<OverviewContent period="30days" provider="codex" overview={polled(payload)} />)
const modelsTable = await screen.findByRole('table', { name: 'Models this period' })
const rows = within(modelsTable).getAllByRole('row')
// Sourced from current.topModels (cost-sorted), not the now-empty daily aggregation.
expect(rows[1]).toHaveTextContent('gpt-5.5-codex')
expect(rows[1]).toHaveTextContent('$120.00')
expect(rows[1]).toHaveTextContent('240')
expect(rows[2]).toHaveTextContent('claude-opus-4')
// current.topModels carries no per-model tokens → both token cells show a dash.
expect(within(rows[1] as HTMLElement).getAllByText('—')).toHaveLength(2)
})
it('suppresses the week-over-week signal and MTD card for a custom range', async () => {
const now = new Date()
const overview = polled(makePayload(now))
const { rerender } = render(<OverviewContent period="30days" provider="all" overview={overview} />)
// Baseline (no range): the MTD card, the coach pacing line, and the
// week-over-week Signals entry are all present.
expect(await screen.findByText('Month to date')).toBeInTheDocument()
expect(screen.getAllByText(/than last week/).length).toBeGreaterThan(0)
expect(screen.getByText(/vs last 7 days/)).toBeInTheDocument()
const from = localDateKey(new Date(now.getFullYear(), now.getMonth(), now.getDate() - 2))
const to = localDateKey(now)
rerender(<OverviewContent period="30days" provider="all" range={{ from, to }} overview={overview} />)
expect(screen.queryByText('Month to date')).not.toBeInTheDocument()
expect(screen.queryByText('Projected month')).not.toBeInTheDocument()
expect(screen.queryByText(/than last week/)).toBeNull()
// The week-over-week Signals entry is suppressed under a custom range too.
expect(screen.queryByText(/vs last 7 days|vs prior 7 days/)).toBeNull()
expect(screen.getByText(/is the biggest driver in this range/)).toBeInTheDocument()
})
it('renders local-model savings in the hero only when present', async () => {
const now = new Date()
const payload = makePayload(now)
payload.current.localModelSavings = { totalUSD: 42.5, calls: 10, byModel: [], byProvider: [] }
render(<OverviewContent period="30days" provider="all" overview={polled(payload)} />)
expect(await screen.findByText('Saved by applied fixes')).toBeInTheDocument()
expect(screen.getByText('Saved via local models')).toBeInTheDocument()
expect(screen.getByText('$42.50')).toBeInTheDocument()
expect(screen.queryByText('Saved to date')).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> = {
data: makePayload(now),
error: { kind: 'nonzero', message: 'codeburn exited 1' },
loading: false,
lastSuccessAt: Date.now(),
refresh: vi.fn(),
}
render(<OverviewContent period="30days" provider="all" overview={overview} />)
expect(await screen.findByRole('status')).toHaveTextContent('Refresh failed, showing last good data · codeburn exited 1')
})
it('groups current-driven signals into wins and improvements', () => {
const now = new Date()
const wins = deriveSignals(signalsPayload(now, {
current: {
cacheHitPercent: 85,
oneShotRate: 0.82,
localModelSavings: { totalUSD: 15, calls: 4, byModel: [], byProvider: [] },
},
optimize: {
findingCount: 2,
savingsUSD: 30,
topFindings: [
{ title: 'Trim CLAUDE.md preamble', impact: 'high', savingsUSD: 12 },
{ title: 'Route trivial edits to Haiku', impact: 'medium', savingsUSD: 8 },
],
},
}), now, false)
expect(wins.wins.map(s => s.text)).toEqual([
'Cache hit at 85%, most prompts reuse cache',
'82% one-shot, edits land first try',
'$15.00 saved via local models',
])
expect(wins.improvements).toEqual([
{ text: 'Trim CLAUDE.md preamble', trailing: '$12.00' },
{ text: 'Route trivial edits to Haiku', trailing: '$8.00' },
])
expect(wins.risks).toEqual([])
})
it('flags low cache-hit, low one-shot, and heavy retry tax as improvements', () => {
const now = new Date()
const { improvements } = deriveSignals(signalsPayload(now, {
current: {
cacheHitPercent: 40,
oneShotRate: 0.4,
cost: 100,
retryTax: { totalUSD: 30, retries: 5, editTurns: 10, byModel: [] },
},
}), now, false)
expect(improvements.map(s => s.text)).toEqual([
'Cache hit only 40%, paying for cold prompts',
'40% one-shot, lots of iteration',
'Retry tax is 30% of spend',
])
})
it('does not treat a zero cache-hit (no data) as a cold-prompt improvement', () => {
const now = new Date()
const { improvements } = deriveSignals(signalsPayload(now, {
current: { cacheHitPercent: 0, oneShotRate: 0.6 },
}), now, false)
expect(improvements).toEqual([])
})
it('derives streak and a week-over-week drop as wins from history', () => {
const now = new Date()
// 14 consecutive active days; prior 7 at $20, recent 7 at $5 → spend down 75%.
const daily = consecutiveDays(now, 14, i => (i < 7 ? 20 : 5))
const { wins } = deriveSignals(signalsPayload(now, {
current: { cacheHitPercent: 60, oneShotRate: 0.6 },
daily,
}), now, false)
expect(wins.map(s => s.text)).toEqual([
'Spend down 75% vs last 7 days',
'14-day usage streak',
])
})
it('reports weekday spike, week-over-week rise, and month overrun as risks', () => {
const now = new Date(2026, 6, 15) // Jul 15 2026
// June total = $5 (prior-month baseline); July: prior 7 low, recent 7 high.
const daily = [mkDay('2026-06-11', 5), ...consecutiveDays(now, 14, i => (i < 7 ? 2 : 20))]
const { risks } = deriveSignals(signalsPayload(now, {
current: { cacheHitPercent: 60, oneShotRate: 0.6 },
daily,
}), now, false)
expect(risks).toHaveLength(3)
expect(risks[0].text).toMatch(/Today's spend is 10× your typical/)
expect(risks[1].text).toBe('Spend up 900% vs prior 7 days')
expect(risks[2].text).toMatch(/^On pace for .* this month, \+\d+% vs last$/)
})
it('suppresses week-over-week and projection risks under a custom range, keeping the weekday spike', () => {
const now = new Date(2026, 6, 15)
const daily = [mkDay('2026-06-11', 5), ...consecutiveDays(now, 14, i => (i < 7 ? 2 : 20))]
const payload = signalsPayload(now, { current: { cacheHitPercent: 60, oneShotRate: 0.6 }, daily })
const { risks } = deriveSignals(payload, now, true)
expect(risks).toHaveLength(1)
expect(risks[0].text).toMatch(/Today's spend is 10× your typical/)
})
it('caps each group at three signals', () => {
const now = new Date()
// Five wins would qualify (cache, one-shot, week-down, streak, local); cap keeps 3.
const daily = consecutiveDays(now, 14, i => (i < 7 ? 20 : 5))
const { wins } = deriveSignals(signalsPayload(now, {
current: {
cacheHitPercent: 85,
oneShotRate: 0.82,
localModelSavings: { totalUSD: 15, calls: 4, byModel: [], byProvider: [] },
},
daily,
}), now, false)
expect(wins).toHaveLength(3)
expect(wins.map(s => s.text)).toEqual([
'Cache hit at 85%, most prompts reuse cache',
'82% one-shot, edits land first try',
'Spend down 75% vs last 7 days',
])
})
it('renders the three-column Signals card with optimize findings under Improvements', async () => {
const now = new Date()
const payload = signalsPayload(now, {
current: {
cacheHitPercent: 85,
oneShotRate: 0.82,
localModelSavings: { totalUSD: 15, calls: 4, byModel: [], byProvider: [] },
},
optimize: {
findingCount: 1,
savingsUSD: 12,
topFindings: [{ title: 'Trim CLAUDE.md preamble', impact: 'high', savingsUSD: 12 }],
},
})
render(<OverviewContent period="30days" provider="all" overview={polled(payload)} />)
const signals = await screen.findByLabelText('Coaching signals')
const wins = within(signals).getByText('Wins').closest('.ov-signal-group') as HTMLElement
expect(within(wins).getByText(/Cache hit at 85%/)).toBeInTheDocument()
const improvements = within(signals).getByText('Improvements').closest('.ov-signal-group') as HTMLElement
expect(within(improvements).getByText('Trim CLAUDE.md preamble')).toBeInTheDocument()
expect(within(improvements).getByText('$12.00')).toBeInTheDocument()
})
it('renders no Signals card when every group is empty', async () => {
const now = new Date()
const payload = signalsPayload(now, {
current: { cacheHitPercent: 60, oneShotRate: 0.6 },
daily: [],
optimize: { findingCount: 0, savingsUSD: 0, topFindings: [] },
})
render(<OverviewContent period="30days" provider="all" overview={polled(payload)} />)
expect(await screen.findByText('Daily spend')).toBeInTheDocument()
expect(screen.queryByLabelText('Coaching signals')).not.toBeInTheDocument()
})
it('renders section costs in the active non-USD currency (rate applied once, symbol swapped)', async () => {
setActiveCurrency({ code: 'EUR', symbol: '€', rate: 0.9 })
const now = new Date()
getOverview.mockResolvedValue(makePayload(now))
render(<Overview period="30days" provider="all" />)
// Cost per outcome sources raw-USD yield values: $/commit = 150/6 = 25 → €22.50,
// $/productive session = 120/3 = 40 → €36.00 (rate applied exactly once).
const outcome = (await screen.findByText('Cost per outcome')).closest('.ov-panel') as HTMLElement
expect(within(outcome).getByText('€22.50')).toBeInTheDocument()
expect(within(outcome).getByText('€36.00')).toBeInTheDocument()
})
})

View file

@ -0,0 +1,691 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import gsap from 'gsap'
import { CliErrorPanel } from '../components/CliErrorPanel'
import { ActivityHeatmap } from '../components/ActivityHeatmap'
import { EmptyNote } from '../components/EmptyState'
import { ListRow } from '../components/ListRow'
import { SectionSkeleton } from '../components/Skeleton'
import { StaleBanner } from '../components/StaleBanner'
import { motionEnabled, useBarGrowIn } from '../lib/motion'
import { type Polled, usePolled } from '../hooks/usePolled'
import { formatCompact, formatUsd } from '../lib/format'
import { codeburn } from '../lib/ipc'
import { contiguousDailyWindow, formatChartDate, localDateKey, sliceDailyToPeriod, sliceDailyToRange } from '../lib/period'
import type {
ActReportJson,
DailyHistoryEntry,
DateRange,
MenubarPayload,
Period,
YieldJsonReport,
} from '../lib/types'
export { localDateKey } from '../lib/period'
function median(values: number[]): number {
if (!values.length) return 0
const sorted = [...values].sort((a, b) => a - b)
const mid = Math.floor(sorted.length / 2)
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2
}
function mean(values: number[]): number {
return values.length ? values.reduce((sum, value) => sum + value, 0) / values.length : 0
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value))
}
type EfficiencyGrade = 'A+' | 'A' | 'B' | 'C' | 'D' | 'F'
function efficiencyGrade(score: number): EfficiencyGrade {
if (score >= 93) return 'A+'
if (score >= 85) return 'A'
if (score >= 75) return 'B'
if (score >= 65) return 'C'
if (score >= 55) return 'D'
return 'F'
}
function EfficiencyScorecard({ current, bare = false }: { current: MenubarPayload['current']; bare?: boolean }) {
const oneShot = current.oneShotRate ?? 0.6
const cacheFrac = clamp(current.cacheHitPercent / 100, 0, 1)
const retrySpendFraction = current.retryTax.totalUSD / Math.max(current.cost, 1e-9)
const retryPenalty = clamp(retrySpendFraction * 4, 0, 1)
// score = 100 * (0.45*oneShot + 0.30*cacheFrac + 0.25*(1-retryPenalty))
// Missing one-shot data uses the specified neutral 0.6 and is disclosed below.
const score = 100 * (0.45 * oneShot + 0.30 * cacheFrac + 0.25 * (1 - retryPenalty))
const grade = efficiencyGrade(score)
const gradeTone = grade === 'A+' || grade === 'A'
? 'grade-a'
: grade === 'D'
? 'grade-d'
: grade === 'F'
? 'grade-f'
: 'grade-bc'
return (
<div className={`${bare ? '' : 'ov-card '}ov-efficiency`}>
<div className="ov-efficiency-head">
<div><div className="ov-label">Efficiency</div><div className="ov-efficiency-score">{Math.round(score)} / 100</div></div>
<div className={`ov-grade ${gradeTone}`} aria-label={`Efficiency grade ${grade}`}>{grade}</div>
</div>
<div className="ov-component-list">
<div className="ov-component-row">
<div><span>One-shot</span><strong>{formatRate(current.oneShotRate)}</strong></div>
<div className="ov-component-track"><span style={{ width: `${oneShot * 100}%` }} /></div>
</div>
<div className="ov-component-row">
<div><span>Cache hit</span><strong>{Math.round(current.cacheHitPercent)}%</strong></div>
<div className="ov-component-track"><span style={{ width: `${cacheFrac * 100}%` }} /></div>
</div>
<div className="ov-component-row">
<div><span>Retry tax</span><strong>{formatUsd(current.retryTax.totalUSD)} · {(retrySpendFraction * 100).toFixed(1)}% of spend</strong></div>
<div className="ov-component-track adverse"><span style={{ width: `${retryPenalty * 100}%` }} /></div>
</div>
</div>
<p className="ov-widget-caption">Composite of one-shot, cache hit, and retry tax.{current.oneShotRate === null ? ' Partial grade: one-shot is unavailable.' : ''}</p>
</div>
)
}
function CostPerOutcome({ outcome }: { outcome: Polled<YieldJsonReport> }) {
const report = outcome.data
let body: React.ReactNode
if (!report) {
body = <EmptyNote>{outcome.error ? 'Yield data is unavailable for this period.' : 'Correlating sessions with git…'}</EmptyNote>
} else if (report.summary.total.sessions === 0 && report.details.length === 0) {
body = <EmptyNote>No git-correlated outcomes in this period.</EmptyNote>
} else {
const commits = report.details.reduce((sum, detail) => sum + detail.commitCount, 0)
const costPerCommit = commits > 0 ? report.summary.total.costUSD / commits : null
const productive = report.summary.productive
const costPerProductiveSession = productive.sessions > 0 ? productive.costUSD / productive.sessions : null
body = (
<>
<div className="ov-outcome-metrics">
<div><span>$ / commit</span><strong>{costPerCommit === null ? '—' : formatUsd(costPerCommit)}</strong></div>
<div><span>$ / productive session</span><strong>{costPerProductiveSession === null ? '—' : formatUsd(costPerProductiveSession)}</strong></div>
</div>
<div className="ov-outcome-split">
productive {Math.round(productive.costPercent)}% · reverted {Math.round(report.summary.reverted.costPercent)}% · abandoned {Math.round(report.summary.abandoned.costPercent)}%
</div>
</>
)
}
return (
<div className="ov-card ov-panel">
<div className="ov-panel-head"><h3>Cost per outcome</h3><span className="r">Yield</span></div>
<div className="ov-panel-body">
{body}
<p className="ov-widget-caption">Git-correlated. Reverted/abandoned = spend that didn't ship.</p>
</div>
</div>
)
}
export type Signal = { text: string; trailing?: string }
export type SignalGroups = { wins: Signal[]; improvements: Signal[]; risks: Signal[] }
/**
* Client-side port of the menubar's FindingsSection rule set
* (mac/Sources/CodeBurnMenubar/Views/FindingsSection.swift:133-205). Thresholds
* mirror the Swift; the desktop-only weekday-spike anomaly is absorbed as a risk.
* Week-over-week and month-projection rules are suppressed for a custom range.
*/
export function deriveSignals(data: MenubarPayload, now: Date, rangeActive: boolean): SignalGroups {
const daily = data.history.daily
const current = data.current
const wins: Signal[] = []
const improvements: Signal[] = []
const risks: Signal[] = []
const streak = streakDays(daily, now)
// Week-over-week: mean of the last 7 active entries vs the prior 7 (matches the
// coach's pacing line). Needs >= 14 entries for both windows to exist.
let weekDelta: number | null = null
if (daily.length >= 14) {
const recent14 = daily.slice(-14)
const weekNow = mean(recent14.slice(-7).map(day => day.cost))
const weekPrior = mean(recent14.slice(0, 7).map(day => day.cost))
if (weekPrior > 0) weekDelta = (weekNow - weekPrior) / weekPrior * 100
}
// Month projection vs previous calendar month's total.
const todayKey = localDateKey(now)
const monthPrefix = todayKey.slice(0, 7)
const mtd = daily.filter(day => day.date.startsWith(monthPrefix)).reduce((sum, day) => sum + day.cost, 0)
const medianDaily = median(daily.slice(-7).map(day => day.cost))
const daysInMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate()
const projectedMonth = mtd + medianDaily * Math.max(0, daysInMonth - now.getDate())
const prevPrefix = localDateKey(new Date(now.getFullYear(), now.getMonth() - 1, 1)).slice(0, 7)
const prevMonthTotal = daily.filter(day => day.date.startsWith(prevPrefix)).reduce((sum, day) => sum + day.cost, 0)
// Weekday spike: today vs the mean of prior same-weekday entries.
const today = daily.find(day => day.date === todayKey)
const sameWeekdayCosts = daily
.filter(day => {
if (day.date === todayKey) return false
const [year, month, date] = day.date.split('-').map(Number)
return new Date(year, month - 1, date).getDay() === now.getDay()
})
.map(day => day.cost)
const typicalWeekday = mean(sameWeekdayCosts)
// ————— Wins —————
if (current.cacheHitPercent >= 80) {
wins.push({ text: `Cache hit at ${Math.round(current.cacheHitPercent)}%, most prompts reuse cache` })
}
if (current.oneShotRate !== null && current.oneShotRate >= 0.75) {
wins.push({ text: `${Math.round(current.oneShotRate * 100)}% one-shot, edits land first try` })
}
if (!rangeActive && weekDelta !== null && weekDelta < -10) {
wins.push({ text: `Spend down ${Math.round(Math.abs(weekDelta))}% vs last 7 days` })
}
if (streak >= 5) {
wins.push({ text: `${streak}-day usage streak` })
}
if (current.localModelSavings.totalUSD > 0) {
wins.push({ text: `${formatUsd(current.localModelSavings.totalUSD)} saved via local models` })
}
// ————— Improvements —————
for (const finding of data.optimize.topFindings.slice(0, 3)) {
improvements.push({ text: finding.title, trailing: formatUsd(finding.savingsUSD) })
}
if (current.cacheHitPercent > 0 && current.cacheHitPercent < 50) {
improvements.push({ text: `Cache hit only ${Math.round(current.cacheHitPercent)}%, paying for cold prompts` })
}
if (current.oneShotRate !== null && current.oneShotRate < 0.5) {
improvements.push({ text: `${Math.round(current.oneShotRate * 100)}% one-shot, lots of iteration` })
}
// Retry-tax share is not a menubar rule; the threshold is the point where the
// efficiency scorecard's retry penalty saturates (retrySpendFraction * 4 == 1).
const retryShare = current.retryTax.totalUSD / Math.max(current.cost, 1e-9)
if (retryShare >= 0.25) {
improvements.push({ text: `Retry tax is ${Math.round(retryShare * 100)}% of spend` })
}
// ————— Risks —————
if (today && typicalWeekday > 0 && today.cost > typicalWeekday * 1.8) {
const ratio = today.cost / typicalWeekday
const weekday = now.toLocaleString('en-US', { weekday: 'long' })
risks.push({ text: `Today's spend is ${ratio.toFixed(1).replace(/\.0$/, '')}× your typical ${weekday}` })
}
if (!rangeActive && weekDelta !== null && weekDelta > 25) {
risks.push({ text: `Spend up ${Math.round(weekDelta)}% vs prior 7 days` })
}
if (!rangeActive && prevMonthTotal > 0 && projectedMonth > prevMonthTotal * 1.3) {
const overPct = Math.round((projectedMonth - prevMonthTotal) / prevMonthTotal * 100)
risks.push({ text: `On pace for ${formatUsd(projectedMonth)} this month, +${overPct}% vs last` })
}
return { wins: wins.slice(0, 3), improvements: improvements.slice(0, 3), risks: risks.slice(0, 3) }
}
const SIGNAL_GROUPS = [
{
key: 'wins' as const,
label: 'Wins',
icon: <><circle cx="12" cy="12" r="9" /><polyline points="8 12 11 15 16 9" /></>,
},
{
key: 'improvements' as const,
label: 'Improvements',
icon: <><polyline points="7 17 17 7" /><polyline points="9 7 17 7 17 15" /></>,
},
{
key: 'risks' as const,
label: 'Risks',
icon: <><path d="M12 4 21 19 3 19Z" /><line x1="12" y1="10" x2="12" y2="14" /><line x1="12" y1="16.5" x2="12" y2="16.6" /></>,
},
]
function SignalsCard({ signals }: { signals: SignalGroups }) {
const groups = SIGNAL_GROUPS.filter(group => signals[group.key].length > 0)
if (!groups.length) return null
return (
<div className="ov-card ov-signals" aria-label="Coaching signals">
{groups.map(group => (
<div className={`ov-signal-group ${group.key}`} key={group.key}>
<div className="ov-signal-head">
<svg viewBox="0 0 24 24" aria-hidden="true">{group.icon}</svg>
<span>{group.label}</span>
</div>
<ul className="ov-signal-list">
{signals[group.key].map((signal, index) => (
<li className="ov-signal" key={`${signal.text}-${index}`}>
<span title={signal.text}>{signal.text}</span>
{signal.trailing && <span className="ov-signal-trailing">{signal.trailing}</span>}
</li>
))}
</ul>
</div>
))}
</div>
)
}
function RoutingWhatIf({ routing, onNavigate }: {
routing: MenubarPayload['current']['routingWaste']
onNavigate?: (section: 'optimize') => void
}) {
if (routing.totalSavingsUSD <= 0 || !routing.baselineModel) return null
return (
<div className="ov-card ov-routing">
<div><span className="ov-label">Routing what-if</span><p>Routing to <strong>{routing.baselineModel}</strong> could save ~<strong>{formatUsd(routing.totalSavingsUSD)}</strong> this period.</p></div>
<button className="ov-link" type="button" onClick={() => onNavigate?.('optimize')}>Optimize </button>
</div>
)
}
function deriveStats(data: MenubarPayload, now: Date) {
const daily = data.history.daily
const todayKey = localDateKey(now)
const todayEntry = daily.find(day => day.date === todayKey)
const monthPrefix = todayKey.slice(0, 7)
const mtdEntries = daily.filter(day => day.date.startsWith(monthPrefix))
const mtd = mtdEntries.reduce((sum, day) => sum + day.cost, 0)
const medianDaily = median(daily.slice(-7).map(day => day.cost))
const daysInMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate()
const projected = mtd + medianDaily * Math.max(0, daysInMonth - now.getDate())
const prevMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1)
const prevPrefix = localDateKey(prevMonth).slice(0, 7)
const priorEntries = daily.filter(day => day.date.startsWith(prevPrefix))
const priorAverage = mean(priorEntries.map(day => day.cost))
const currentAverage = mean(mtdEntries.map(day => day.cost))
const pacePct = priorAverage > 0 ? ((currentAverage - priorAverage) / priorAverage) * 100 : null
return {
todayEntry,
todayCost: todayEntry?.cost ?? 0,
mtd,
projected,
pacePct,
prevMonthName: prevMonth.toLocaleString('en-US', { month: 'long' }),
}
}
export function sessionModelKey(project: string, date: string, calls: number, cost: number): string {
return `${project}|${date}|${calls}|${cost}`
}
function buildModelIndex(data: MenubarPayload): Map<string, string> {
const index = new Map<string, string>()
for (const project of data.current.topProjects) {
for (const session of project.sessionDetails) {
const dominant = [...session.models].sort((a, b) => b.cost - a.cost)[0]
if (dominant) index.set(sessionModelKey(project.name, session.date, session.calls, session.cost), dominant.name)
}
}
return index
}
function streakDays(daily: DailyHistoryEntry[], now: Date): number {
const byDate = new Map(daily.map(day => [day.date, day.cost]))
let streak = 0
for (let offset = 0; ; offset++) {
const date = new Date(now.getFullYear(), now.getMonth(), now.getDate() - offset)
if ((byDate.get(localDateKey(date)) ?? 0) <= 0) break
streak++
}
return streak
}
/**
* Hero cost with a count-up that fires on mount and whenever the filter key
* changes (a user action), but never on the 30s poll: a value that arrives
* under the same `animateKey` snaps in place instead of re-animating.
*/
function CountUp({ value, animateKey }: { value: number; animateKey: string }) {
const ref = useRef<HTMLDivElement>(null)
const keyRef = useRef<string | null>(null)
useEffect(() => {
const element = ref.current
if (!element) return
const keyChanged = keyRef.current !== animateKey
keyRef.current = animateKey
if (!keyChanged || !motionEnabled()) {
element.textContent = formatUsd(value)
return
}
const counter = { n: 0 }
const tween = gsap.to(counter, {
n: value,
duration: 0.7,
ease: 'power2.out',
onUpdate: () => { element.textContent = formatUsd(counter.n) },
})
return () => { tween.kill() }
}, [value, animateKey])
return <div ref={ref} className="ov-hero-num" data-countup={value}>{formatUsd(value)}</div>
}
function formatShortDay(date: string): string {
const [, month, day] = date.split('-').map(Number)
return `${month}/${day}`
}
type AggregatedModel = {
name: string
cost: number
calls: number
// Absent in provider-filtered mode: `current.topModels` carries no per-model
// token counts, so the table shows "—" rather than a misleading zero.
inputTokens?: number
outputTokens?: number
}
/** Provider-filtered source: `current.topModels` is already period/range/provider-scoped by the CLI. */
function topModelsToAggregated(models: MenubarPayload['current']['topModels']): AggregatedModel[] {
return models
.map(model => ({ name: model.name, cost: model.cost, calls: model.calls }))
.sort((a, b) => b.cost - a.cost)
}
function aggregateModels(daily: DailyHistoryEntry[]): AggregatedModel[] {
const byName = new Map<string, AggregatedModel>()
for (const day of daily) {
for (const model of day.topModels) {
const row = byName.get(model.name) ?? {
name: model.name,
cost: 0,
calls: 0,
inputTokens: 0,
outputTokens: 0,
}
row.cost += model.cost
row.calls += model.calls
row.inputTokens = (row.inputTokens ?? 0) + model.inputTokens
row.outputTokens = (row.outputTokens ?? 0) + model.outputTokens
byName.set(model.name, row)
}
}
return [...byName.values()].sort((a, b) => b.cost - a.cost)
}
function ModelsTable({ models }: { models: AggregatedModel[] }) {
if (!models.length) return <EmptyNote>No model usage in this range yet.</EmptyNote>
return (
<div className="ov-model-scroll">
<table className="ov-models" aria-label="Models this period">
<thead>
<tr>
<th>Model</th>
<th className="num">Input tok</th>
<th className="num">Output tok</th>
<th className="num">Cost</th>
<th className="num">Calls</th>
</tr>
</thead>
<tbody>
{models.map(model => (
<tr key={model.name}>
<td className="ov-model-name">{model.name}</td>
<td className="num mono">{model.inputTokens === undefined ? '—' : formatCompact(model.inputTokens)}</td>
<td className="num mono">{model.outputTokens === undefined ? '—' : formatCompact(model.outputTokens)}</td>
<td className="num mono">{formatUsd(model.cost)}</td>
<td className="num">{model.calls.toLocaleString('en-US')}</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
function DailyChart({ daily, animateKey = '' }: { daily: DailyHistoryEntry[]; animateKey?: string }) {
const max = Math.max(...daily.map(day => day.cost), 0)
const peakIndex = daily.reduce((peak, day, index) => day.cost > (daily[peak]?.cost ?? -1) ? index : peak, 0)
const peak = daily[peakIndex]
const yesterday = daily.at(-2)
const average = mean(daily.map(day => day.cost))
const ticks = daily.filter((_, index) => index % 7 === 0)
const [tip, setTip] = useState<{ day: DailyHistoryEntry; x: number; y: number } | null>(null)
const [tipPosition, setTipPosition] = useState<{ left: number; top: number } | null>(null)
const tipRef = useRef<HTMLDivElement>(null)
const chartRef = useRef<HTMLDivElement>(null)
useBarGrowIn(chartRef, '.col', [animateKey])
useLayoutEffect(() => {
if (!tip) {
setTipPosition(null)
return
}
const width = tipRef.current?.offsetWidth ?? 220
const height = tipRef.current?.offsetHeight ?? 62
const gutter = 8
const cursorGap = 12
let left = tip.x + cursorGap
if (left + width > window.innerWidth - gutter) left = tip.x - width - cursorGap
left = Math.max(gutter, Math.min(left, window.innerWidth - width - gutter))
let top = tip.y - height - cursorGap
if (top < gutter) top = tip.y + cursorGap
top = Math.max(gutter, Math.min(top, window.innerHeight - height - gutter))
setTipPosition({ left, top })
}, [tip])
return (
<>
<div className="chart" ref={chartRef}>
{daily.map((day, index) => (
<button
type="button"
aria-label={`${day.date}: ${formatUsd(day.cost)}`}
className={`col${index === peakIndex ? ' hi' : ''}`}
key={day.date}
style={{ height: `${max > 0 ? Math.max(2, day.cost / max * 100) : 2}%` }}
data-date={day.date}
data-cost={day.cost}
data-calls={day.calls}
data-led={day.topModels[0]?.name ?? ''}
onMouseEnter={event => setTip({ day, x: event.clientX, y: event.clientY })}
onMouseMove={event => setTip({ day, x: event.clientX, y: event.clientY })}
onMouseLeave={() => setTip(null)}
/>
))}
</div>
<div className="ov-xax">
{ticks.map(day => {
const index = daily.indexOf(day)
return <span key={day.date} style={{ left: `${daily.length > 1 ? index / (daily.length - 1) * 100 : 0}%` }}>{formatChartDate(day.date)}</span>
})}
</div>
<div className="ov-chart-summaries" aria-label="Daily spend summary">
<div className="ov-summary-chip"><span>Avg/day</span><strong>{formatUsd(average)}</strong></div>
<div className="ov-summary-chip"><span>Peak</span><strong>{peak ? `${formatUsd(peak.cost)} · ${formatShortDay(peak.date)}` : '$0.00'}</strong></div>
<div className="ov-summary-chip"><span>Yesterday</span><strong>{formatUsd(yesterday?.cost ?? 0)}</strong></div>
</div>
{tip && createPortal(
<div
ref={tipRef}
className={`chart-tip${tipPosition ? ' on' : ''}`}
style={{ position: 'fixed', ...(tipPosition ?? { left: 0, top: 0 }) }}
role="tooltip"
>
<div className="chart-tip-d">{formatChartDate(tip.day.date)}</div>
<div className="chart-tip-v">{formatUsd(tip.day.cost)}</div>
<div className="chart-tip-s">{tip.day.calls} calls · {tip.day.topModels[0]?.name ?? 'No model'} led</div>
</div>,
document.body,
)}
</>
)
}
function formatRate(rate: number | null): string {
return rate === null ? '—' : `${Math.round(rate * 100)}%`
}
function TopActivities({ activities }: { activities: MenubarPayload['current']['topActivities'] }) {
const rows = [...activities].sort((a, b) => b.cost - a.cost).slice(0, 6)
if (!rows.length) return <EmptyNote>No activity in this range yet.</EmptyNote>
const maxCost = rows[0].cost
return (
<div className="ov-activities">
{rows.map(activity => (
<div className="ov-activity" key={activity.name}>
<div className="ov-activity-bar" aria-hidden="true">
<span style={{ width: `${maxCost > 0 ? activity.cost / maxCost * 100 : 0}%` }} />
</div>
<div className="ov-activity-main">
<span className="ov-activity-name">{activity.name}</span>
<strong>{formatUsd(activity.cost)}</strong>
</div>
<div className="ov-activity-meta">
<span>{activity.turns.toLocaleString('en-US')} turns</span>
<span>{formatRate(activity.oneShotRate)} one-shot</span>
</div>
</div>
))}
</div>
)
}
export function Overview({ period, provider }: { period: Period; provider: string }) {
const overview = usePolled<MenubarPayload>(() => codeburn.getOverview(period, provider), [period, provider])
return <OverviewContent period={period} provider={provider} overview={overview} />
}
export function OverviewContent({
period,
provider = 'all',
range = null,
overview,
onNavigate,
}: {
period: Period
provider?: string
range?: DateRange | null
overview: Polled<MenubarPayload>
onNavigate?: (section: 'optimize' | 'sessions') => void
}) {
const actReport = usePolled<ActReportJson>(() => codeburn.getActReport(), [])
const yieldReport = usePolled<YieldJsonReport>(() => codeburn.getYield(period, provider), [period, provider])
const { data, error } = overview
const modelIndex = useMemo(() => data ? buildModelIndex(data) : new Map<string, string>(), [data])
if (!data) {
if (error) return <CliErrorPanel error={error} subject="your usage" />
return <SectionSkeleton label="Scanning sessions…" rows={3} chart />
}
const now = new Date()
const rangeActive = !!range
const animateKey = `${period}|${provider}|${range?.from ?? ''}|${range?.to ?? ''}`
const stats = deriveStats(data, now)
const periodDaily = sliceDailyToPeriod(data.history.daily, period, now)
// Daily chart: contiguous zero-filled calendar window. A custom range spans
// [from..to]; otherwise the trend covers at least the last 30 days, extended
// back to the earliest active day already in the period window.
const defaultChartStart = localDateKey(new Date(now.getFullYear(), now.getMonth(), now.getDate() - 29))
const chartDaily = rangeActive
? contiguousDailyWindow(data.history.daily, range.from, range.to)
: contiguousDailyWindow(
data.history.daily,
periodDaily[0] && periodDaily[0].date < defaultChartStart ? periodDaily[0].date : defaultChartStart,
localDateKey(now),
)
// Provider-filtered history.daily has empty topModels, so source the models
// table from current.topModels (already period/range/provider-scoped) instead.
const models = provider !== 'all'
? topModelsToAggregated(data.current.topModels)
: aggregateModels(rangeActive ? sliceDailyToRange(data.history.daily, range.from, range.to) : periodDaily)
const recent14 = data.history.daily.slice(-14)
const weekNow = mean(recent14.slice(-7).map(day => day.cost))
const weekPrior = mean(recent14.slice(-14, -7).map(day => day.cost))
const weeklyPct = weekPrior > 0 ? Math.round(Math.abs((weekNow - weekPrior) / weekPrior * 100)) : null
const weeklyDirection = weekNow >= weekPrior ? 'higher' : 'lower'
const topModel = data.current.topModels[0]
const saved = actReport.data?.totals.realizedCostUSD ?? 0
const applied = saved > 0 ? (actReport.data?.totals.measuredActions ?? 0) : 0
const localSaved = data.current.localModelSavings.totalUSD
// A custom range has no meaningful "vs last week" or month-to-date baseline.
const signals = deriveSignals(data, now, rangeActive)
return (
<div className="ov-dashboard">
{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>
<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 />
</div>
{!rangeActive && (
<div className="ov-card ov-stats3">
<div className="ov-stat"><div className="ov-label">Month to date</div><div className="v">{formatUsd(stats.mtd)}</div><div className="d">{stats.pacePct === null ? `No ${stats.prevMonthName} pace yet` : `${stats.pacePct >= 0 ? '+' : ''}${Math.round(stats.pacePct)}% vs ${stats.prevMonthName} pace`}</div></div>
<div className="ov-stat"><div className="ov-label">Projected month</div><div className="v">{formatUsd(stats.projected)} <small>est</small></div><div className="d warn">{formatUsd(Math.max(0, stats.projected - stats.mtd))} to go</div></div>
</div>
)}
<div className="ov-card ov-panel ov-chart-widget">
<div className="ov-panel-head"><h3>Daily spend</h3><span className="r">{topModel ? `Biggest driver: ${topModel.name}` : 'No model driver yet'}</span></div>
<div className="ov-panel-body">{data.history.daily.length ? <DailyChart daily={chartDaily} animateKey={animateKey} /> : <EmptyNote>No spend yet.</EmptyNote>}</div>
</div>
<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>
<div className="ov-coach-tx">
{rangeActive
? <>{topModel ? <><span className="num">{topModel.name}</span> is the biggest driver in this range</> : 'No single model dominates this range'}. <span className="num">{formatUsd(data.optimize.savingsUSD)}</span> is recoverable.</>
: <>{weeklyPct === null ? <>No prior-week pacing baseline yet</> : <>You're pacing <span className="num">{weeklyPct}% {weeklyDirection}</span> than last week</>}{topModel ? <>; <span className="num">{topModel.name}</span> is the biggest driver</> : ''}. <span className="num">{formatUsd(data.optimize.savingsUSD)}</span> is recoverable.</>}
</div>
<button className="ov-coach-cta" type="button" onClick={() => onNavigate?.('optimize')}>Review </button>
</div>
</div>
<SignalsCard signals={signals} />
<div className="ov-analytics-row">
<CostPerOutcome outcome={yieldReport} />
<RoutingWhatIf routing={data.current.routingWaste} onNavigate={onNavigate} />
</div>
<div className="ov-body-grid">
<div className="ov-main-column">
<div className="ov-card ov-panel ov-models-widget">
<div className="ov-panel-head"><h3>Models this period</h3><span className="r">Sorted by cost</span></div>
<div className="ov-panel-body ov-model-panel"><ModelsTable models={models} /></div>
</div>
<div className="ov-card ov-panel ov-sessions-widget">
<div className="ov-panel-head"><h3>Most expensive sessions</h3><span className="r"><button className="ov-link" type="button" onClick={() => onNavigate?.('sessions')}>See all </button></span></div>
<div className="ov-panel-body">
{data.current.topSessions.length ? data.current.topSessions.map((session, index) => {
const model = modelIndex.get(sessionModelKey(session.project, session.date, session.calls, session.cost))
const sub = [formatChartDate(session.date), model, `${session.calls} calls`].filter(Boolean).join(' · ')
return <ListRow key={`${session.project}-${session.date}-${index}`} no={String(index + 1).padStart(2, '0')} title={session.project} sub={sub} value={formatUsd(session.cost)} onClick={() => onNavigate?.('sessions')} />
}) : <EmptyNote>No sessions in this range.</EmptyNote>}
</div>
</div>
</div>
<div className="ov-side-column">
<div className="ov-card ov-panel ov-activities-widget">
<div className="ov-panel-head"><h3>Top activities</h3><span className="r">Sorted by cost</span></div>
<div className="ov-panel-body"><TopActivities activities={data.current.topActivities} /></div>
</div>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,273 @@
// @vitest-environment jsdom
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { setActiveCurrency } from '../lib/format'
import type { JsonPlanSummary, QuotaProvider, StatusJson } from '../lib/types'
import { Plans } from './Plans'
const { getPlans, getQuota } = vi.hoisted(() => ({
getPlans: vi.fn<(period: string) => Promise<StatusJson>>(),
getQuota: vi.fn<(force?: boolean) => Promise<QuotaProvider[]>>(),
}))
vi.mock('../lib/ipc', async orig => {
const actual = await orig<typeof import('../lib/ipc')>()
return { ...actual, codeburn: { getPlans, getQuota } }
})
const periodStart = new Date(2026, 5, 15).toISOString()
const periodEnd = new Date(2026, 6, 15).toISOString()
const claudePlan: JsonPlanSummary = {
id: 'claude-max',
provider: 'claude',
budget: 200,
spent: 230,
percentUsed: 115,
status: 'over',
projectedMonthEnd: 254,
daysUntilReset: 4,
periodStart,
periodEnd,
}
const cursorPlan: JsonPlanSummary = {
id: 'cursor-pro',
provider: 'cursor',
budget: 20,
spent: 8.2,
percentUsed: 41,
status: 'under',
projectedMonthEnd: 12.4,
daysUntilReset: 4,
periodStart,
periodEnd,
}
const codexPlan: JsonPlanSummary = {
id: 'none',
provider: 'codex',
budget: 0,
spent: 31.02,
percentUsed: 15,
status: 'under',
projectedMonthEnd: 31.02,
daysUntilReset: 4,
periodStart,
periodEnd,
}
const baseStatus = {
currency: 'USD',
today: { cost: 22.5, savings: 4.2, calls: 19 },
month: { cost: 269.02, savings: 52, calls: 181 },
} satisfies Omit<StatusJson, 'plan' | 'plans'>
const statusWithPlans: StatusJson = {
...baseStatus,
plans: {
claude: claudePlan,
cursor: cursorPlan,
codex: codexPlan,
},
}
function quotaProviders(): QuotaProvider[] {
const now = Date.now()
return [
{
provider: 'claude',
connection: 'connected',
primary: { label: 'Weekly', percent: 0.92, resetsAt: new Date(now + (3 * 24 + 14) * 60 * 60_000 + 30 * 60_000).toISOString() },
details: [
{ label: '5-hour', percent: 0.25, resetsAt: new Date(now + 2 * 60 * 60_000 + 30 * 60_000).toISOString() },
{ label: 'Weekly', percent: 0.92, resetsAt: new Date(now + (3 * 24 + 14) * 60 * 60_000 + 30 * 60_000).toISOString() },
],
planLabel: 'Max 20x',
footerLines: [],
},
{
provider: 'codex',
connection: 'disconnected',
primary: null,
details: [],
planLabel: null,
footerLines: [],
},
]
}
describe('Plans', () => {
beforeEach(() => {
setActiveCurrency({ code: 'USD', symbol: '$', rate: 1 })
getPlans.mockReset()
getQuota.mockReset()
getQuota.mockResolvedValue(quotaProviders())
})
it('renders live quota windows, tier, severity, disconnected hint, and manual plans below', async () => {
getPlans.mockResolvedValue(statusWithPlans)
const { container } = render(<Plans period="30days" />)
expect(await screen.findByText('Max 20x')).toBeInTheDocument()
expect(screen.getByText('25% used · resets in 2h 29m')).toBeInTheDocument()
expect(screen.getByText('92% used · resets in 3d 14h')).toBeInTheDocument()
expect(container.querySelector('[data-testid="quota-track-5-hour"] i')).toHaveClass('accent')
expect(container.querySelector('[data-testid="quota-track-Weekly"] i')).toHaveClass('bad')
expect(screen.getByText('Not connected. Log in with the Codex CLI.')).toBeInTheDocument()
expect(screen.getByRole('heading', { name: 'Budget plans' })).toBeInTheDocument()
expect(screen.getByText('Cursor Pro')).toBeInTheDocument()
expect(screen.getByText('$20.00 / month · cursor')).toBeInTheDocument()
expect(screen.getByText('$8.20 · 41%')).toBeInTheDocument()
const cursorFill = container.querySelector('[data-testid="plan-track-cursor"] i')
expect(cursorFill).toHaveStyle({ width: '41%' })
expect(cursorFill).not.toHaveClass('over')
expect(screen.getByText('On track')).toHaveClass('pace', 'ok')
expect(screen.queryByText('Claude Max')).not.toBeInTheDocument()
expect(screen.queryByText('API usage')).not.toBeInTheDocument()
})
it('keeps manual budget overage and clamped-track behavior', async () => {
getPlans.mockResolvedValue({
...baseStatus,
plans: {
grok: { ...claudePlan, id: 'supergrok', provider: 'grok' },
},
})
const { container } = render(<Plans period="30days" />)
expect(await screen.findByText('SuperGrok')).toBeInTheDocument()
expect(screen.getByText('$230.00 · 115% · $30.00 over')).toBeInTheDocument()
const fill = container.querySelector('[data-testid="plan-track-grok"] i')
expect(fill).toHaveStyle({ width: '100%' })
expect(fill).toHaveClass('over')
expect(screen.getByText('On pace to exceed; projected $254.00 by Jul 14')).toHaveClass('pace', 'hot')
})
it('renders near status as an amber non-exceeding projection when below budget', async () => {
getPlans.mockResolvedValue({
...baseStatus,
plans: {
grok: {
id: 'supergrok-heavy',
provider: 'grok',
budget: 300,
spent: 255,
percentUsed: 85,
status: 'near',
projectedMonthEnd: 280,
daysUntilReset: 4,
periodStart,
periodEnd,
},
},
})
render(<Plans period="30days" />)
const pace = await screen.findByText('85% of budget used; projected $280.00 by Jul 14')
expect(pace).toHaveClass('pace', 'hot')
expect(screen.queryByText(/On pace to exceed/)).not.toBeInTheDocument()
})
it('falls back to StatusJson.plan when the CLI returns a singular plan summary', async () => {
getPlans.mockResolvedValue({
...baseStatus,
plan: cursorPlan,
})
render(<Plans period="month" />)
expect(await screen.findByText('Cursor Pro')).toBeInTheDocument()
})
it('omits the budget section when StatusJson has no manual plan summaries', async () => {
getPlans.mockResolvedValue({
currency: 'USD',
today: { cost: 0, savings: 0, calls: 0 },
month: { cost: 0, savings: 0, calls: 0 },
})
render(<Plans period="month" />)
expect(await screen.findByText('Not connected. Log in with the Codex CLI.')).toBeInTheDocument()
expect(screen.queryByRole('heading', { name: 'Budget plans' })).not.toBeInTheDocument()
})
it('renders the CLI locate state when getPlans reports not-found', async () => {
getPlans.mockRejectedValue({ kind: 'not-found', message: 'codeburn not found' })
render(<Plans period="week" />)
expect(await screen.findByText('Locate the codeburn CLI')).toBeInTheDocument()
})
it('does not re-apply the FX rate to CLI-converted plan values (symbol swap only)', async () => {
// getPlans values arrive already converted by the CLI (convertCost). With a
// EUR rate active, the pane must only swap the symbol — a second ×0.9 here
// would render €18.00 / €7.38 instead of the correct €20.00 / €8.20.
setActiveCurrency({ code: 'EUR', symbol: '€', rate: 0.9 })
getPlans.mockResolvedValue({ ...baseStatus, currency: 'EUR', plans: { cursor: cursorPlan } })
render(<Plans period="30days" />)
expect(await screen.findByText('€20.00 / month · cursor')).toBeInTheDocument()
expect(screen.getByText('€8.20 · 41%')).toBeInTheDocument()
})
it('forces a quota refresh only when refreshToken changes, not on the steady poll', async () => {
getPlans.mockResolvedValue(statusWithPlans)
const { rerender } = render(<Plans period="30days" refreshToken={0} />)
await screen.findByText('Max 20x')
expect(getQuota).toHaveBeenCalledWith(false) // mount is a steady poll
getQuota.mockClear()
rerender(<Plans period="30days" refreshToken={1} />) // manual refresh bumps the token
await waitFor(() => expect(getQuota).toHaveBeenCalledWith(true))
getQuota.mockClear()
rerender(<Plans period="30days" refreshToken={1} />) // unchanged token must not re-force
for (const call of getQuota.mock.calls) expect(call[0]).toBe(false)
})
it('renders permission-denied CLI failures as the amber Full Disk Access state', async () => {
getPlans.mockRejectedValue({ kind: 'nonzero', message: 'Cursor permission denied: grant Full Disk Access' })
render(<Plans period="week" />)
expect(await screen.findByText('Permission denied')).toBeInTheDocument()
expect(screen.getByText('permission denied; grant Full Disk Access')).toHaveStyle({ color: 'var(--warn)' })
})
it('expands the Connect affordance and forces a keychain refresh from Refresh', async () => {
getPlans.mockResolvedValue(statusWithPlans)
render(<Plans period="30days" />)
const connect = await screen.findByRole('button', { name: 'Connect' })
expect(screen.getByText('Not connected. Log in with the Codex CLI.')).toBeInTheDocument()
fireEvent.click(connect)
expect(screen.getByText('codex login')).toBeInTheDocument()
getQuota.mockClear()
fireEvent.click(screen.getByRole('button', { name: 'Refresh' }))
await waitFor(() => expect(getQuota).toHaveBeenCalledWith(true))
})
it('renders the keychain access-denied state with recovery copy and a locked indicator', async () => {
getPlans.mockResolvedValue(statusWithPlans)
getQuota.mockResolvedValue([
{ provider: 'claude', connection: 'accessDenied', primary: null, details: [], planLabel: null, footerLines: [] },
{ provider: 'codex', connection: 'connected', primary: { label: 'Weekly', percent: 0.1, resetsAt: null }, details: [{ label: 'Weekly', percent: 0.1, resetsAt: null }], planLabel: 'Plus', footerLines: [] },
])
render(<Plans period="30days" />)
expect(await screen.findByText('Keychain access needed: click Allow when macOS asks, then Refresh.')).toBeInTheDocument()
expect(screen.getByText('locked')).toBeInTheDocument()
})
})

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