Merge pull request #679 from getagentseal/feat/desktop-app
CodeBurn Desktop: standalone Electron app
|
|
@ -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
|
|
@ -0,0 +1,5 @@
|
|||
node_modules/
|
||||
dist/
|
||||
release/
|
||||
*.log
|
||||
.vite/
|
||||
178
app/DISTRIBUTION.md
Normal 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
|
|
@ -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
|
After Width: | Height: | Size: 441 KiB |
213
app/electron/cli.test.ts
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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)
|
||||
115
app/electron/quota/claude.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
152
app/electron/quota/claude.ts
Normal 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') }
|
||||
}
|
||||
}
|
||||
145
app/electron/quota/codex.test.ts
Normal 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
|
|
@ -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') }
|
||||
}
|
||||
}
|
||||
58
app/electron/quota/index.test.ts
Normal 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
|
|
@ -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) })
|
||||
57
app/electron/quota/security.test.ts
Normal 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' })
|
||||
})
|
||||
})
|
||||
152
app/electron/quota/security.ts
Normal 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))
|
||||
}
|
||||
17
app/electron/quota/types.ts
Normal 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
67
app/package.json
Normal 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
|
|
@ -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
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
BIN
app/renderer/assets/flame.png
Normal file
|
After Width: | Height: | Size: 696 KiB |
1
app/renderer/assets/providers/claude.svg
Normal 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 |
1
app/renderer/assets/providers/copilot-dark.svg
Normal 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 |
1
app/renderer/assets/providers/copilot-light.svg
Normal 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 |
BIN
app/renderer/assets/providers/cursor-agent.jpg
Normal file
|
After Width: | Height: | Size: 8.3 KiB |
12
app/renderer/assets/providers/cursor-dark.svg
Normal 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 |
12
app/renderer/assets/providers/cursor-light.svg
Normal 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 |
1
app/renderer/assets/providers/gemini.svg
Normal 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 |
BIN
app/renderer/assets/providers/goose.png
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
4
app/renderer/assets/providers/grok-dark.svg
Normal 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 |
4
app/renderer/assets/providers/grok-light.svg
Normal 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 |
1
app/renderer/assets/providers/openai-dark.svg
Normal 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 |
1
app/renderer/assets/providers/openai-light.svg
Normal 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 |
7
app/renderer/assets/providers/opencode-dark.svg
Normal 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 |
6
app/renderer/assets/providers/opencode-light.svg
Normal 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 |
BIN
app/renderer/assets/providers/pi.png
Normal file
|
After Width: | Height: | Size: 316 B |
1
app/renderer/assets/providers/qwen-dark.svg
Normal 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 |
1
app/renderer/assets/providers/qwen-light.svg
Normal 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 |
BIN
app/renderer/assets/providers/warp.jpg
Normal file
|
After Width: | Height: | Size: 5 KiB |
BIN
app/renderer/assets/splash-loader.webm
Normal file
79
app/renderer/components/AboutModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
158
app/renderer/components/ActivityHeatmap.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
59
app/renderer/components/CliErrorPanel.tsx
Normal 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'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>
|
||||
)
|
||||
}
|
||||
41
app/renderer/components/ConnectAffordance.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
137
app/renderer/components/Dropdown.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
6
app/renderer/components/EmptyState.tsx
Normal 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>
|
||||
}
|
||||
31
app/renderer/components/ErrorBoundary.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
40
app/renderer/components/ErrorBoundary.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
}
|
||||
31
app/renderer/components/FlameMark.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
18
app/renderer/components/Hint.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
58
app/renderer/components/ListRow.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
31
app/renderer/components/Panel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
51
app/renderer/components/ProviderLogo.tsx
Normal 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" />
|
||||
</>
|
||||
}
|
||||
32
app/renderer/components/ProviderPop.tsx
Normal 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} />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
121
app/renderer/components/RangeCalendar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
164
app/renderer/components/Sankey.tsx
Normal 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
|
||||
}
|
||||
37
app/renderer/components/SegTabs.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
37
app/renderer/components/Sidebar.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
99
app/renderer/components/Sidebar.tsx
Normal 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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
18
app/renderer/components/Skeleton.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
21
app/renderer/components/Skeleton.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
86
app/renderer/components/Splash.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
73
app/renderer/components/Splash.tsx
Normal 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,
|
||||
)
|
||||
}
|
||||
50
app/renderer/components/StackedBars.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
93
app/renderer/components/StackedBars.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
14
app/renderer/components/StaleBanner.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
13
app/renderer/components/StaleBanner.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
22
app/renderer/components/Stat.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
40
app/renderer/components/ToastHost.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
36
app/renderer/components/ToastHost.tsx
Normal 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,
|
||||
)
|
||||
}
|
||||
148
app/renderer/components/TopBar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
6
app/renderer/components/Window.tsx
Normal 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>
|
||||
}
|
||||
58
app/renderer/hooks/usePolled.test.ts
Normal 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' })
|
||||
})
|
||||
})
|
||||
73
app/renderer/hooks/usePolled.ts
Normal 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
|
|
@ -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>
|
||||
19
app/renderer/lib/budget.ts
Normal 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
|
||||
}
|
||||
58
app/renderer/lib/format.test.ts
Normal 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('—')
|
||||
})
|
||||
})
|
||||
82
app/renderer/lib/format.ts
Normal 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
|
|
@ -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 }
|
||||
}
|
||||
55
app/renderer/lib/modelSeries.ts
Normal 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'
|
||||
}
|
||||
62
app/renderer/lib/motion.test.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
59
app/renderer/lib/motion.ts
Normal 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 })
|
||||
}
|
||||
87
app/renderer/lib/period.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
83
app/renderer/lib/period.ts
Normal 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' })
|
||||
}
|
||||
15
app/renderer/lib/testMatchMedia.ts
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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>,
|
||||
)
|
||||
113
app/renderer/sections/Compare.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
269
app/renderer/sections/Compare.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
259
app/renderer/sections/Models.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
342
app/renderer/sections/Models.tsx
Normal 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()]
|
||||
}
|
||||
250
app/renderer/sections/Optimize.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
233
app/renderer/sections/Optimize.tsx
Normal 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." />
|
||||
}
|
||||
680
app/renderer/sections/Overview.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
691
app/renderer/sections/Overview.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
273
app/renderer/sections/Plans.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||